We create a cached pagination that is not afraid of unexpected data addition to the database

If your site contains a large amount of content, then in order to display it, the user has to share it in one way or another.





All the methods I know have drawbacks and I tried to create a system that can solve some of them without being too difficult to implement.





Existing methods

1. Pagination (division into separate pages)

An example from the site habr.com
An example from the site habr.com

Pagination or splitting into separate pages is a fairly old way of dividing content, which, among other things, is used on HabrΓ©. The main advantage is its versatility and ease of implementation both from the server side and from the client side.





The code for requesting data from the database is most often limited to a couple of lines.





Here and further examples in the arangodb aql language, I hid the server code because there is nothing interesting there yet.





//   20    . 

LET count = 20
LET offset = count * ${page}

FOR post IN posts
	SORT post.date DESC //     
	LIMIT offset, count
	RETURN post
      
      



On the client side, we request and display the resulting result, I use vuejs with nuxtjs for an example, but the same can be done on any other stack, I will sign all the vue-specific points.





# https://example.com/posts?page=3
main.vue

<template> <!--  template   body	-->
	<div>
    <template v-for="post in posts"> <!--   	-->
			<div :key="post.id">
				{{ item.title }}
			</div>
    </template>
	</div>
</template>

<script>
export default {
	data() {
		return {
			posts: [], //    
		}
	},
  computed: { //   this,    
  	currentPage(){
      //            +
      return +this.$route.query.page || 0
    },
  },
	async fetch() { //    
    
		const page = this.currentPage
    
    //   ,      
  	this.posts = await this.$axios.$get('posts', {params: {page}})
  }
}
</script>
      
      



Now we have all the posts on the page displayed, but wait, how will users switch between pages? Let's add a couple of buttons to turn pages.





<template> <!--  template   body	-->
  <div>
    <div>
      <template v-for="post in posts"> <!--   	-->
        <div :key="post.id">
          {{ item.title }}
        </div>
      </template>
    </div>
    <div>  <!--  	-->
      <button @click="prev">
      	 
      </button>
      <button @click="next">
      	 
      </button>
    </div>
  </div>
</template>

<script>
export default {
  //...     
  
  methods: {//    
    prev(){
			const page = this.currentPage()
      if(page > 0)
        //   https://example.com/posts?page={page - 1}
				this.$router.push({query: {page: page - 1}})
    },
    next(){
			const page = this.currentPage()
      if(page < 100) //          100 
        //   https://example.com/posts?page={page + 1}
				this.$router.push({query: {page: page + 1}})
    },
  },
}
</script>
      
      



Cons of this method





  • .





  • , . 2, , 3, 4 , . GET .





  • , , .





2.

, .





, .





β„–3 , 2 , , id , 40 ? 3  ,   , . 2 ( 20 ). !





:





  • , , , . , mvp.





  • , , . 2 . -,   . -,   , , .  ,   , , , .





  • , . , . !





, , .





, .





0, 1, (page) , . , offset ().





LET count = 20
LET offset = ${offset}

FOR post IN posts
	SORT post.date ASC //       
	LIMIT offset, count
	RETURN post
      
      



, GET "/?offset=0" .





, , ( nodejs):





async getPosts({offset}) {
  const isOffset = offset !== undefined
  if (isOffset && isNaN(+offset)) throw new BadRequestException()

	const count = 20
	//      ,    
	if (offset % count !== 0) throw new BadRequestException()

	const sort = isOffset ? `
		SORT post.date DESC
		LIMIT ${+offset}, ${count}
	` : `
		SORT post.date ASC
		LIMIT 0, ${count * 2} //        *
	`

	const q = {
		query: `
			FOR post IN posts
			${sort}
			RETURN post
		`,
		bindVars: {}
	}

	//         
	const cursor = await this.db.query(q, {fullCount: true, count: isOffset})
	const fullCount = cursor.extra.stats.fullCount

	/* 
  	*        count{20}      2  [21-39] 
		       .     
		     20      1-  c count{20} 
	*/

  let data;
	if (isOffset) {
    //          
		const allow = offset <= fullCount - cursor.count - count
		if (!allow) throw new NotFoundException()
		//    , .        
		data = (await cursor.all()).reverse()
	} else {
		const all = await cursor.all()
		if (fullCount % count === 0) {
      //   20 ,     ,      ,    
			data = all.slice(0, count) 
		} else {
      /*  ,         0-20 ,
            20     ,
             0-20   ,
                 40 
          
      */
			const pagesCountUp = Math.ceil(fullCount / count)
			const resultCount = fullCount - pagesCountUp * count + count * 2
			data = all.slice(0, resultCount)
		}
	}

	if (!data.length) throw new NotFoundException()

	return { fullCount, count: data.length, data }
}
      
      



:





  • id .





  • , id offset.





  • (





:





  • , , , null , , .. , , "null-" , null- .





  • ( ), . ( id).





β„–2.





<template>
	<div>
		<div ref='posts'>
			<template v-for="post in posts">
				<div :key="post.id" style="height: 200px"> <!--   ,    	-->
					{{ item.title }}
				</div>
			</template>
		</div>
		<div> <!--     .   	-->
			<button @click="prev" v-if="currentPage > 1">
				 
			</button>
		</div>
	</div>
</template>

<script>
const count = 20
export default {
	data() {
		return {
			posts: [],
			fullCount: 0,
			pagesCount: 0,
			dataLoading: true,
			offset: undefined,
		}
	},
	async fetch() {
		const offset = this.$route.query?.offset
		this.offset = offset
		this.posts = await this.loadData(offset)
		setTimeout(() => this.dataLoading = false)
	},
	computed: {
		currentPage() {
			return this.offset === undefined ? 1 : this.pageFromOffset(this.offset)
		}
	},
	methods: {
     //         
		pageFromOffset(offset) {
			return offset === undefined ? 1 : this.pagesCount - offset / count
		},
		offsetFromPage(page) {
			return page === 1 ? undefined : this.pagesCount * count - count * page
		},
		prev() {
			const offset = this.offsetFromPage(this.currentPage - 1)
			this.$router.push({query: {offset}})
		},
		async loadData(offset) {
			try {
				const data = await this.$axios.$get('posts', {params: {offset}})
				this.fullCount = data.fullCount
				this.pagesCount = Math.ceil(data.fullCount / count)
				//         
				if (this.fullCount % count !== 0)
					this.pagesCount -= 1
				return data.data
			} catch (e) {
				//...  404    
				return []
			}
		},
		onScroll() {
			//  1000      
			const load = this.$refs.posts.getBoundingClientRect().bottom - window.innerHeight < 1000
			const nextPage = this.pageFromOffset(this.offset) + 1
			const nextOffset = this.offsetFromPage(nextPage)
			if (!this.dataLoading && load && nextPage <= this.pagesCount) {
				this.dataLoading = true
				this.offset = nextOffset
				this.loadData(nextOffset).then(async (data) => {
					const top = window.scrollY
					//       
					this.posts.push(...data)
					await this.$router.replace({query: {offset: nextOffset}})
					this.$nextTick(() => {
						//    viewport      
						window.scrollTo({top});
						this.dataLoading = false
					})
				})
			}
		}
	},
	mounted() {
		window.addEventListener('scroll', this.onScroll)
	},
	beforeDestroy() {
		window.removeEventListener('scroll', this.onScroll)
	},
}
</script>
      
      



. , , .





:

1 , , ( ):





< 1 ... 26 [27] 28 ... 255 >







< [1] 2 3 4 5 ... 255 >







< 1 ... 251 252 253 254 [255] >







The basis of the method for generating pagination is taken from this discussion: https://gist.github.com/kottenator/9d936eb3e4e3c3e02598#gistcomment-3238804 and crossed with my solution.





Show bonus continuation

First, you need to add this helper method inside the <script> tag





const getRange = (start, end) => Array(end - start + 1).fill().map((v, i) => i + start)
const pagination = (currentPage, pagesCount, count = 4) => {
	const isFirst = currentPage === 1
	const isLast = currentPage === pagesCount

	let delta
	if (pagesCount <= 7 + count) {
		// delta === 7: [1 2 3 4 5 6 7]
		delta = 7 + count
	} else {
		// delta === 2: [1 ... 4 5 6 ... 10]
		// delta === 4: [1 2 3 4 5 ... 10]
		delta = currentPage > count + 1 && currentPage < pagesCount - (count - 1) ? 2 : 4
		delta += count
		delta -= (!isFirst + !isLast)
	}

	const range = {
		start: Math.round(currentPage - delta / 2),
		end: Math.round(currentPage + delta / 2)
	}

	if (range.start - 1 === 1 || range.end + 1 === pagesCount) {
		range.start += 1
		range.end += 1
	}

	let pages = currentPage > delta
		 ? getRange(Math.min(range.start, pagesCount - delta), Math.min(range.end, pagesCount))
		 : getRange(1, Math.min(pagesCount, delta + 1))

	const withDots = (value, pair) => (pages.length + 1 !== pagesCount ? pair : [value])

	if (pages[0] !== 1) {
		pages = withDots(1, [1, '...']).concat(pages)
	}

	if (pages[pages.length - 1] < pagesCount) {
		pages = pages.concat(withDots(pagesCount, ['...', pagesCount]))
	}
	if (!isFirst) pages.unshift('<')
	if (!isLast) pages.push('>')

	return pages
}
      
      



Adding missing methods





<template>
	<div ref='posts'>
		<div>
			<div v-for="post in posts" :key="item.id">{{ post.title }}</div>
		</div>
		<div style="position: fixed; bottom: 0;"> <!--      -->
			<template v-for="(i, key) in pagination">
				<button v-if="i === '...'" :key="key + i" @click="selectPage()">{{ i }}</button>
				<button :key="i" v-else :disabled="currentPage === i" @click="loadPage(pagePaginationOffset(i))">{{ i }}</button>
			</template>
		</div>
	</div>
</template>

<script>
export default {
	data() {
		return {
			posts: [],
			fullCount: 0,
			pagesCount: 0,
			interval: null,
			dataLoading: true,
			offset: undefined,
		}
	},
	async fetch() {/*   */},
	computed: {
		currentPage()  {/*   */},
		
		//          
		pagination() {
			return this.pagesCount ? pagination(this.currentPage, this.pagesCount) : []
		},
	},
	methods: {
		pageFromOffset(offset) {/*   */},
		offsetFromPage(page) {/*   */},
		async loadData(offset) {/*   */},
		onScroll() {/*   */},

		//       
		loadPage(offset) {
			window.scrollTo({top: 0})
			this.dataLoading = true

			this.loadData(offset).then((data) => {
				this.offset = offset
				this.posts = data
				this.$nextTick(() => {
					this.dataLoading = false
				})
			})
		},
		//     
		pagePaginationOffset(item) {
			if (item === '...') return undefined
			let page = isNaN(item) ? this.currentPage + (item === '>') - (item === '<') : item
			return page <= 1 ? undefined : this.offsetFromPage(page)
		},
		//       
		selectPage() {
			const page = +prompt("   ");
			this.loadPage(this.offsetFromPage(page))
		},
	},
	mounted() {
		window.addEventListener('scroll', this.onScroll)
	},
	beforeDestroy() {
		window.removeEventListener('scroll', this.onScroll)
	},
}
</script>

      
      



Now, if necessary, you can go to the desired page.








All Articles