Вы загружаете два элемента, нажимаете «Далее» и снова видите последний элемент предыдущей страницы. В другой раз при переходе между страницами один элемент пропадает. Представим небольшой список задач, который меняется во время просмотра: код должен определять, что значит «дальше», когда строки меняют положение.
Читайте фильтр, сортировку и выбор страницы вместе
Список сортируется по createdAt по убыванию, затем по уникальному числовому id, тоже по убыванию. В учебном примере время задано небольшими целыми числами. Шесть элементов open идут в порядке 6, 5, 4, 3, 2, 1; у элементов 5 и 4 одинаковое время — 50. Есть также элемент 0 со статусом done и временем 70.
| Что читать | Какое правило искать |
|---|---|
selected |
Фильтрация по статусу до выбора страницы |
compare |
Сначала более новое время; при равном времени — больший ID |
offsetPage / cursorPage |
Какие строки попадают на следующую страницу |
| Вызывающий код | Сохранение nextCursor, добавление актуальных результатов, сброс при изменении запроса |
Начните с действия «Далее» и проследите его аргументы до функции выборки. Если читать только финальный срез массива, легко упустить фильтр и порядок, определяющие смысл позиций.
Посмотрите, как один offset указывает на разные строки
Эта реализация пропускает заданное число строк, которые подходят под фильтр в текущий момент:
const compare = (a, b) => b.createdAt - a.createdAt || b.id - a.id;
const selected = (rows, status) =>
rows.filter((row) => row.status === status).sort(compare);
function offsetPage(rows, offset, take = 2, status = 'open') {
return selected(rows, status).slice(offset, offset + take);
}
Первая страница содержит 6 и 5. Если между вызовами внести одно изменение, запрос второй страницы с offset = 2 даст разные результаты:
| Изменение после первой страницы | Текущий порядок подходящих строк | Вторая страница | Результат |
|---|---|---|---|
| Нет | 6, 5, 4, 3, 2, 1 | 4, 3 | Ожидаемое продолжение |
| Добавлен 7 со временем 70 | 7, 6, 5, 4, 3, 2, 1 | 5, 4 | 5 повторяется |
| Удалён 6 | 5, 4, 3, 2, 1 | 3, 2 | 4 пропущен |
Каждый случай начинается с исходных данных. Число пропускаемых строк остаётся прежним, но их позиции меняются. Удаление повторяющихся ID в интерфейсе может скрыть повтор, но не вернёт пропущенный элемент.
Сравнивайте полный ключ сортировки
Курсор хранит ключ последнего возвращённого элемента. Условие только по времени, например createdAt < cursor.createdAt, потеряет элемент 4 после элемента 5: у обоих время 50. Замена на <= снова допустит предыдущий элемент.
Сохраните оба значения (createdAt, id) и используйте то же сравнение, что и при сортировке:
function cursorPage(rows, after = null, take = 2, status = 'open') {
const items = selected(rows, status)
.filter((row) => after === null || compare(row, after) > 0)
.slice(0, take);
const last = items.at(-1);
return {
items,
nextCursor: last ? { createdAt: last.createdAt, id: last.id } : null,
};
}
При таком порядке по убыванию compare(row, after) > 0 означает, что строка находится строго после сохранённой позиции. При равном времени порядок определяет id. После описанных выше добавления или удаления следующая страница содержит 4, 3.
Объект nextCursor копирует значения ключа, а не ищет предыдущую строку заново. Поэтому даже после удаления элемента 5 можно продолжить с сохранённого ключа. Пустая страница возвращает null. В этом примере может потребоваться дополнительный запрос, чтобы обнаружить конец списка.
Запустите изменения в отдельных тестах
Скачайте pagination-reading.mjs и запустите в Node.js 22 или новее:
node --test pagination-reading.mjs
Все десять тестов проходят. Три теста с префиксом reproduce: намеренно проверяют ошибочные результаты: повтор с offset, пропуск с offset и пропуск при сравнении курсора только по времени. Остальные семь проверяют добавление, удаление перед курсором, удаление исходной строки курсора, полный обход с одинаковым временем и пустой последней страницей, новую строку после курсора, фильтрацию до ограничения размера и сброс фильтра.
Тесты используют новые массивы и синхронные изменения между вызовами. Сервис, учётная запись, таймеры и установка пакетов не нужны.
Прежде чем считать исправление готовым, прочитайте вызывающий код
Если сменить фильтр на done, сохранив старый курсор, пример вернёт пустую страницу: элемент 0 расположен до этого курсора. Начав изменённый запрос с after = null, получим элемент 0. При смене фильтра или сортировки вызывающий код должен одновременно сбрасывать накопленные строки и курсор.
Также проверьте, где хранится nextCursor и когда он используется. Здесь null после пустого ответа означает остановку; если сразу передать его обратно в функцию, снова откроется первая страница. Ищите повторные запросы, ответы в обратном порядке и двукратное добавление одного ответа. Эти сценарии интерфейса не входят в синхронный пример; устаревшие ответы отдельно разобраны в статье о смене выбора при асинхронном чтении.
Определите, как показывать изменения читателю
Курсор не создаёт снимок данных. После чтения первой страницы добавьте элемент 8 со временем 49: следующая страница станет 4, 8. Новая строка находится после сохранённой позиции и поэтому появляется в уже начатом обходе. Новая строка перед курсором будет видна после обновления списка.
Проверенное продолжение предполагает уникальные ID и неизменные ключи сортировки у оставшихся элементов. Изменение ключа или статуса и удаление ещё не прочитанного элемента требуют явного правила продукта; выгрузке с фиксированным содержимым может понадобиться отдельный механизм снимков. Пример с массивом не проверяет изоляцию базы данных, индексы, передачу данных или кодирование курсора. Полная фильтрация и сортировка при каждом вызове также не подходят как решение для больших объёмов данных.
При чтении своей реализации сопоставьте фильтр, сравнение, сохранённый ключ и сброс в вызывающем коде. В Yenpo можно открыть связанные файлы и пройти по ссылкам на код, чтобы проверить каждый шаг.