Посторінкова навігація і фільтри окремо виглядають простими.
Складність з’являється, коли вони починають впливати одне на одного.
Уявімо, що користувач відкрив восьму сторінку великого списку, а потім змінив фільтр. Нова вибірка може мати лише дві сторінки.
Якщо залишити старий номер сторінки, сервер правильно поверне порожній результат. Але для користувача це виглядатиме так, ніби за новим фільтром взагалі нічого немає.
Тому після зміни критеріїв сторінку потрібно повернути на початок.
Але тут легко створити другу проблему: запустити два однакові запити замість одного.
Чому сторінку потрібно скидати після зміни фільтра
До зміни фільтра 78 результатів8 сторінокпоточна сторінка: 8 Після зміни фільтра 14 результатів2 сторінкипоточна сторінка все ще: 8 Результат: Технічно тут усе може працювати правильно.
Сервер отримує номер сторінки, якої немає в новій вибірці, і повертає порожній список.
Але стан інтерфейсу семантично вже неправильний.
Поточна сторінка не є незалежним параметром. Вона залежить від критеріїв, за якими побудована вибірка.
Тому зміна фільтра для мене означає не тільки "завантажити нові дані", а й "почати нову вибірку з першої сторінки".
Інтерфейс рахує сторінки від 1, а внутрішній індекс може починатися з 0
Інтерфейс Внутрішній pageIndex 1 02 13 24 3У багатьох таблицях користувач бачить сторінки від 1, але внутрішній стан компонента зберігає pageIndex від 0.
У цьому немає проблеми, якщо межа чітка.
Проблеми починаються, коли одна частина коду передає номер сторінки, інша очікує індекс, а третя ще раз додає або віднімає одиницю.
Тому я вибираю одне внутрішнє представлення і виконую перетворення лише на межі інтерфейсу або запиту.
Перетворення між номером сторінки та індексом має бути явним
const pageIndex = ref(0) const currentPage = computed({ get: () => pageIndex.value + 1, set: (page) => { pageIndex.value = Math.max( 0, page - 1, ) },})Такий невеликий computed прибирає потребу пам’ятати про зміщення на одиницю в кожному обробнику.
Компонент посторінкової навігації працює з нормальними для користувача сторінками 1, 2, 3..., а внутрішній стан залишається 0, 1, 2....
Це особливо корисно, коли одна таблиця має кілька місць, які можуть змінити сторінку.
Наївне рішення легко запускає два запити
watch( filters, () => { pageIndex.value = 0 reload() }, { deep: true, },) watch( pageIndex, () => { reload() },На перший погляд цей код логічний.
Змінився фільтр - скидаємо сторінку і завантажуємо дані.
Змінилася сторінка - теж завантажуємо дані.
Але якщо користувач був не на першій сторінці, зміна фільтра виконує одразу дві реакції:
pageIndex.value = 0запускає спостерігач сторінки;- спостерігач фільтра сам викликає
reload().
У результаті один рух користувача може створити два однакові HTTP-запити.
Подвійний запит виникає через ланцюжок реактивних змін
Користувач змінює фільтр | vспостерігач фільтра | +--> pageIndex = 0 | | | v | спостерігач сторінки | | | v | reload() | +--> reload() Я залишаю один шлях до повторного завантаження
watch( filters, () => { if (pageIndex.value !== 0) { pageIndex.value = 0 return } reload() }, { deep: true, },) Тут є дві ситуації.
Користувач був не на першій сторінці. Спостерігач фільтра змінює pageIndex на 0 і завершується. Зміну сторінки побачить другий спостерігач, який виконає єдиний reload().
Користувач уже був на першій сторінці. Значення pageIndex не зміниться, тому спостерігач сторінки не запуститься. У цьому випадку дані завантажує сам спостерігач фільтра.
Таким чином в обох випадках відбувається рівно одне завантаження.
Просто скинути pageIndex і завжди зробити return теж недостатньо
watch( filters, () => { pageIndex.value = 0 return }, { deep: true, },)Тут проявляється інша сторона реактивності.
Якщо pageIndex уже дорівнює 0, присвоєння того самого значення не створює нової зміни.
Отже спостерігач сторінки не має причини запускатися.
Фільтр змінився, але запит узагалі не відбудеться.
Саме тому умова pageIndex.value !== 0 тут не дрібна оптимізація, а частина правильної поведінки.
На сервер я передаю вже правильний номер сторінки
async function reload() { await loadItems({ page: pageIndex.value + 1, search: filters.search, status: filters.status, })}Якщо API очікує сторінки від 1, я роблю перетворення безпосередньо перед запитом.
Тоді в усій внутрішній логіці pageIndex має одне значення, а сервер отримує звичний параметр page.
Це зменшує ризик помилки на одиницю і робить код простішим для перевірки.
Сторінка є частиною стану вибірки, але не рівноправним фільтром
Для мене тут важлива семантика.
Пошуковий рядок, статус або інший фільтр визначають, які записи входять у вибірку.
Номер сторінки визначає лише, яку частину вже сформованої вибірки показати.
Тому залежність одностороння:
- зміна сторінки не повинна змінювати фільтр;
- зміна фільтра може зробити стару сторінку недійсною.
Саме ця різниця пояснює, чому при зміні критеріїв сторінка повертається на початок.
Кілька фільтрів повинні поводитися однаково
const filters = reactive({ search: '', status: null, category: null,}) watch( filters, handleCriteriaChange, { deep: true, },)Я не хочу окремої поведінки для пошуку, статусу, категорії та кожного майбутнього фільтра.
Усі вони змінюють критерії вибірки, тому правило однакове: поточна сторінка більше не повинна вважатися актуальною.
Це також зменшує ризик, що новий фільтр додадуть у форму, але забудуть додати до логіки скидання сторінки.
Для пошуку окремо перевіряю частоту запитів
Текстовий пошук має ще одну особливість: критерій може змінюватися після кожного введеного символу.
Навіть якщо логіка сторінки вже не створює подвійний запит, без затримки можна отримати багато послідовних запитів під час набору тексту.
Тому для пошукового поля я окремо розглядаю затримку перед завантаженням або інший контроль частоти.
Це інша проблема. Вона не повинна маскувати помилку з подвійним reload(), але обидві добре видно під час перевірки мережевих запитів.
Один запит за дію також спрощує боротьбу зі старими відповідями
Подвійний запит - це не лише зайве навантаження.
Два запити можуть завершитися в іншому порядку, ніж почалися.
Якщо старіша відповідь прийде останньою і код не захищений від такого сценарію, вона може перезаписати новіший стан таблиці.
Тому перший крок для мене простий: не створювати зайві запити там, де вони взагалі не потрібні.
А для сценаріїв, де кілька запитів справді можуть виконуватися паралельно, вже окремо потрібна перевірка актуальності відповіді або скасування попереднього запиту.
Що я перевіряю тестами
1. Користувач на сторінці 42. Змінює фільтр3. pageIndex стає 04. Виконується 1 запит 1. Користувач уже на сторінці 12. Змінює фільтр3. pageIndex залишається 04. Виконується 1 запит 1. Користувач змінює тільки сторінку2. Фільтри не змінюються3. Виконується 1 запитТут недостатньо перевірити лише кінцеве значення pageIndex.
Мене також цікавить кількість завантажень.
Інтерфейс може виглядати абсолютно правильно, але мережевий журнал покаже два однакові запити після кожної зміни фільтра.
Тому в таких тестах я перевіряю і стан, і побічний ефект.
Що я перевіряю, коли фільтри працюють разом із пагінацією
Для таких таблиць я проходжу короткий список:
- Чи скидається сторінка після зміни критеріїв?
- Чи працює це, якщо користувач уже на першій сторінці?
- Чи не викликають два спостерігачі два однакові запити?
- Чи є одне внутрішнє представлення сторінки?
- Чи перетворюється pageIndex у номер сторінки лише на межі?
- Чи всі фільтри мають однакову поведінку?
- Чи не створює текстовий пошук зайві запити під час введення?
- Чи перевіряють тести кількість завантажень, а не лише вигляд таблиці?
Правильний стан ще не означає правильну кількість запитів
Цей випадок мені подобається тим, що зовні помилка майже непомітна.
Сторінка після зміни фільтра справді повертається на початок. Таблиця показує правильні дані. Користувач може взагалі не побачити проблеми.
Але всередині одна зміна стану запускає іншу реакцію, і разом вони створюють зайвий запит.
Тому в реактивному інтерфейсі я дивлюся не лише на кінцеві значення, а й на ланцюжок побічних ефектів.
Якщо зміна фільтра повинна скинути сторінку і завантажити дані один раз, саме це і має бути гарантовано кодом: одна зміна критеріїв, одна актуальна сторінка, один запит.
У реактивному коді важливо перевіряти не лише куди прийшов стан, а й скільки дій він запустив дорогою.



