Pull to refresh

Создаем кэшируемую пагинацию, которая не боится неожиданного добавления данных в БД

Reading time10 min
Views24K

Если на вашем сайте присутствует большое количество контента, то для отображения пользователю его приходится так или иначе делить.

Все известные мне способы имеют недостатки и я попытался создать систему, которая сможет решить некоторые из них и при этом не будет слишком сложна для реализации.

Существующие методы

1. Пагинация (разделение на отдельные страницы)

Пример с сайта habr.com
Пример с сайта habr.com

Пагинация или разделение на отдельные страницы — достаточно старый способ разделения контента, который, в том числе используется на Хабре. Основным преимуществом является его универсальность и простота реализации как со стороны сервера так и клиентской части.

Код запроса данных из бд чаще всего ограничивается парой строк.

Тут и далее примеры на языке arangodb aql, я скрыл код сервера т.к там пока ничего интересного.

// Возврат по 20 элементов для каждой страницы. 

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

FOR post IN posts
	SORT post.date DESC // сортируем от новых к старым
	LIMIT offset, count
	RETURN post

На стороне клиента мы запрашиваем и выводим получившийся результат, я использую vuejs с nuxtjs для примера, но то же самое можно проделать на любом другом стеке, все специфичные для vue моменты я буду подписывать.

# 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>

Теперь у нас выводятся все посты на странице, но погодите, как пользователи будут переключаться между страницами? Добавим пару кнопок для перелистывания страниц.

<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>

Минусы данного способа

  • При достижении конца страницы пользователю нужно переключаться на следующую страницу вручную.

  • Не получится кешировать результаты, т.к посты находящиеся на странице 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] >

Основа метода для генерации пагинации взята из этого обсуждения: https://gist.github.com/kottenator/9d936eb3e4e3c3e02598#gistcomment-3238804 и скрещена с моим решением.

Показать продолжение бонуса

В начале вам нужно добавить этот вспомогательный метод внутрь тега <script>

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
}

Добавляем недостающие методы

<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>

Теперь, при необходимости, можно перейти на нужную страницу.

Tags:
Hubs:
Total votes 5: ↑5 and ↓0+5
Comments10

Articles