Пагинация

На большинстве сайтов, особенно блогах, есть пагинация, то есть, разбиение по страницам, и почти везде она сделана неправильно: номера начинаются с единицы для самого нового и идут вверх.

Пагинация пришла из книжной и журнальной вёрстки, где номера страниц удобно использовать для того, чтобы делать оглавление и индекс — можно просто использовать номер страницы, практически как идентификатор. 1

На подавляющем большинстве сайтов элементы (страницы, посты, обновления, как угодно) показываются в обратном хронологическом порядке: более новое — сверху. 2

Но книга и журнал статичны — когда журнал выезжает из пресса, новых статей туда не добавить, и страницы сами по себе не вклеятся, номера страниц получаются стабильными. Когда мы вслепую копируем эту систему в обратно хронологический веб, мы теряем это свойство. Пост, который был на третьей странице, съезжает на четвёртую, как только автор обновил свой блог.

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

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

Нет, он всё ещё может сдвинуться, конечно, если автор удалит более ранний пост — но это происходит гораздо реже, чем добавление новых статей. 3

Это можно рассматривать как частное применение принципа Тима Бернерса-Ли Клёвые урлы не меняются. У каждого элемента в отдельности скорее всего есть свой пермалинк — ссылка именно на него, которая вероятно не изменится. 4 Но есть ценность и в поддержании контекста, как пост смотрелся в окружении других постов. Так, например, если результаты поиска ведут на какую-то страницу в 2009 году, хочется быть уверенным, что перейдя по ссылке вы увидите нужный фрагмент на самой странице, а не полистав дополнительно вокруг.

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

И легко предположить, что в наших шаблонах есть кусочек, ответственный за отображение пагинации. Представим такой условный синтаксис:

.pagination
	- for (let $pageNumber = 1; $pageNumber <= $totalPages; $pageNumber++)
		a href=("/page/" + $pageNumber)
			= $pageNumber

Нельзя просто заменить $pageNumber на $totalPages - $pageNumber, потому что тогда при добавлении трёх статей все статьи сдвинутся на три позиции.

Чтобы сделать пагинацию стабильной, нам нужно нырнуть глубже, и посмотреть, как происходит пагинация.

«Классический» способ заключается в том, что из базы делается выборка, например SELECT * FROM posts ORDER BY published DESC, а разбиение на страницы прикручивается костылём LIMIT..OFFSET:

SELECT * FROM posts ORDER BY published DESC LIMIT 10 -- первая страница
SELECT * FROM posts ORDER BY published DESC LIMIT 10 OFFSET 10 -- вторая страница

У этого метода есть беда с производительностью: OFFSET обычно делается как «выберем всё равно всё, и отбросим ненужное уже в оперативной памяти», и когда мы пытаемся делать большой offset, то это становится заметно: страница 90 грузится дольше, чем страница 10.

Из преимуществ же, помимо простоты-для-разработчика, — мы можем «прыгнуть» на произвольную страницу: для этого достаточно посчитать нужный offset: умножить номер страницы на количество элементов на странице. Больше преимуществ у него нет.

Другой вариант, который предпочитает сочувствующая базам данных публика, называется keyset pagination, и заключается в выборке элементов со значением ключей больше/меньше чем у элемента с предыдущей страницы. В случае с постами таким ключом может выступать дата: «десять постов с датой меньше, чем 2018-01-10», например. Это гораздо быстрее и не становится сильно медленнее даже на «дальних» страницах, но теперь нельзя перескочить на произвольную страницу, не зная нужный ключ.

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

Тут есть альтернативный вариант пагинации: что, если вместо разбиения «по десять» мы начнём разбивать «по дням»? Тогда keyset pagination сработает ещё лучше — можно прыгнуть на дату. На ней может ничего не оказаться, правда.

Но продолжим с пагинацией «по десять». Здесь полезно остановиться и посмотреть, а что мы, собственно, разбиваем по страницам? Сколько этих элементов, сто? Десять тысяч? Миллионы? Как часто добавляются новые элементы?

Если элементы добавляются редко, то есть вариант тупой как пробка, и потому работающий 5: добавить каждому элементу свойство «номер страницы». Тогда наш запрос превращается в тривиальный SELECT * FROM posts WHERE page_number = 5. 6 Тогда остаётся только один вопрос, что же делать на новой странице, когда сайт ещё не набрал нужное количество элементов? Не хочется показывать «обрубок» из трёх постов, где обычно должно быть двадцать. Самое просто решение — позволить на самой новой странице разместиться 2*n - 1 элементам. Это добавляет немного нестабильности для самых новых элементов, но для большинства — тех, что на следующих страницах — соответствующий им номер страницы меняться не будет.

Если же нам нужно обрабатывать миллионы элементы, к которым постоянно добавляются десятки тысяч новых, то такой метод не сработает, как минимум, в простом варианте, но возникает сомнение в том, насколько здесь полезна пагинация, привязанная к количеству элементов, как средство ссылок в целом. Не имеет смысла говорить про «десятую страницу твиттера», но возможно есть смысл говорить о твитах с 2018-04-09T14:00:00 по 2018-04-09T14:30:00, и это решается методом keyset pagination.

Есть ещё один момент, что стабильная пагинация нужна, если мы сортируем элементы хронологически. В масштабах больших поисковых систем нет возможности пронумеровать статически все элементы под всевозможные варианты выдачи. С другой стороны, от них и не ожидается стабильности порядка выдачи — подразумевается, что он меняется по мере появления более актуального и качественного контента. Когда мы отходим от хронологической сортировки к любой другой, то пагинация превращается в фильтр качества, и у пользователей нет ожидания, что такая сортировка долговечна.

Даже в случае фильтра качества, человек может захотеть поделиться ссылкой: представим условный кубинг.ком, где можно бронировать номер в гостиницах Кубы. На каждой странице по десять вариантов, я посмотрел тридцать, из которых мне понравилось два на третьей странице, и я хочу отправить друзьям на согласование. (Дурацкая метафора, но оставайтесь со мной). Если друзья перейдут по этой ссылке и не увидят те два номера, то это грустно, но вряд ли они перейдут по ссылке через два месяца, а за две минуты выдача вряд ли изменится. Практически любая пагинация становится условно стабильной, если мы говорим про малый промежуток времени.

Но привязанная к хронологии пагинация, пожалуй, актуальна только для блогов и похожих сайтов. На таких сайтах я хотел бы видеть стабильную пагинацию.

Спасибо за внимание.


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

  2. Случай, где элементы создаются динамически, например, поисковая выдача, рассматриваются вкратце ближе к концу эссе, но в целом выходят за его рамки.

  3. Это, кстати, хороший принцип для проектирования систем в целом — как правило, все решения неидеальны, поэтому стоит выбирать то, что наименее неидеально для самых частых сценариев.

  4. Для этой статьи пермалинк: https://timmarinin.net/2018/pagination — и я намерен поддерживать его по мере сил бесконечно.

  5. Если вам хочется термин, то я только что придумал такой: ahead-of-time pagination.

  6. Стоит посыпать индексом, ну этой sql-шамано-магией.