Усунення проблем конкурентних ситуацій: технологія дебаунсингу не може вирішити їх у інтерфейсах пошуку
Дізнайтеся, чому сама лише боротьба з ефектом дебаунсингу не може запобігти тому, що застарілі відповіді API перезаписують свіжий стан інтерфейсу, та ознайомтесь з чотирма практичними рішеннями для забезпечення правильної послідовності запитів.
Минулого року я додав затримку у 300 мілісекунд до поля пошуку в одному проєкті та вважав проблему вирішеною.
Пізніше тестувальник повідомив про дивну річ: якщо вводити текст швидко, результати на мить показували щось нестосовне, перш ніж стати правильними.
Спочатку всі не повірили. Перевірка вкладки мережі показала, що затримка функціонує саме так, як і мала — один запит за кожну паузу, без зайвих сигналів.
Проте баг справді існував. Просто це був не той баг, який ми лікували.
Кожен, хто створював поле пошуку, ймовірно, стикався з цією проблемою. Кожен натиск клавіші викликає мережевий запит, і сервер опиняється перевантаженим.
Зазвичай радять «просто додати затримку». Ви встановлюєте коротку паузу, кількість запитів зменшується, і справа закривається.
Такі дрібні рішення накопичуються у способи, які легко недооцінити. Це конкретне рішення здається незначним, але воно може завдати більшої шкоди, ніж здається.
Ось що механізм debounce насправді не гарантує: він контролює коли надсилається запит. Він не впливає на те, яка відповідь надійде першою.
Що насправді виправляє механізм debounce у пошукових полях
Механізм debounce у пошуковому полі вирішує лише одну проблему — надмірну кількість вихідних запитів.
Це і є весь обсяг його функцій. Порядок надсилання запитів залишається незмінним.
Ось як саме можуть виникнути проблеми. Припустимо, інтервал debounce становить 300 мс.
- Ви вводите
rea, потім припиняєте введення на 300 мс. Надсилається запит A дляrea. - Ви продовжуєте вводити:
react. Після ще однієї паузи надсилається запит B дляreact.
react.rea.У полі введення вказано react, але у списку нижче показані результати для rea. Саме ця розбіжність і спричинила мерехтіння, яке помітив тестувальник.
Механізм дебаунсингу зменшує частоту виникнення цього явища, але не усуває ймовірність його появи. А „менша частота“ — це саме той тип дефекту, який залишається непоміченим під час розробки, коли API відповідає миттєво та тестує його лише одна людина.
Незрозуміло, чи буде ця поведінка ідентичною у всіх комбінаціях фреймворків та інструментів.
У проектах на React, де використовуються fetch або axios, ця проблема постійно з’являється щоразу.
Це не просто проблема пошукової строки
Як тільки ви усвідомлюєте цей спосіб виникнення помилок, починаєте помічати його в багатьох інших місцях. Це та сама основна помилка, яка проявляється у різних формах:
- Списки фільтрів. Користувач обирає „Взуття“, а потім швидко переходить на „Сумки“. Якщо запит щодо взуття обробляється останнім, на сторінці все одно відображаються списки взуття під заголовком „Сумки“.
- Вкладки. Хтось натискає „Замовлення“, а потім одразу — „Рахунки-фактури“. Якщо відповідь щодо замовлень надходить повільніше, вона все одно „виграє“, і неправильні дані залишаються під правильною вкладкою.
Кожного разу, коли користувач може змінити свої наміри швидше, ніж сервер може відповісти, існує ця сама проблема конкуренції.
У реальних додатках це може ускладнити ситуацію ще більше.
- Останні 30 днів
- Потім останні 7 днів
- Потім сьогодні
У цьому випадку одночасно можуть надходити три окремі запити.
Навіть точні дані стають неправильними, якщо вони відповідають дії, яку користувач вже здійснив.
Чому подовження затримки не допомагає
Першою інстинктивною реакцією зазвичай є збільшення проміжку дебаунсу — спробуйте 500 мс, а потім можливо 800 мс.
Ця корекція, здійснена переважно через звичку, а не на основі реальних міркувань, нічим не зменшила частоту виникнення мерехтіння. Вона лише зробила інтерфейс повільнішим. Запит, який не знаходиться в кеші та виконується протягом 900 мс, все одно буде діяти довше, ніж проміжок дебаунсу у 800 мс.
Корекція значень часу не може вирішити проблему, що криється у послідовності виконання операцій.
Саме в цей момент стає зрозумілою справжня природа багу.
Дебаунс — це механізм керування потоком.
Умова змагання — це порушення послідовності.
Подовження часу, протягом якого світло на перехресті залишається червоним, не визначає, яка машина досягне пункту призначення першою.
Щоб це вирішити, потрібне зовсім інше правило.
Справжнє рішення: лише останній запит дозволяє оновлювати інтерфейс
Це одне речення — і це вже вся суть рішення. Існує кілька методів для його реалізації, від простих до більш комплексних.
Рішення 1: Надати кожному запиту номер тикета
Це найпростіша версія, яку варто пояснити першою, адже вона робить основні механізми зрозумілими.
import { useState, useRef } from "react";
function useSearch() {
const [results, setResults] = useState([]);
const latestRequestId = useRef(0);
async function search(query) {
const myId = ++latestRequestId.current;
const res = await fetch(`/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
// If a newer request started while we were waiting, throw this away.
if (myId === latestRequestId.current) {
setResults(data.results);
}
}
return { results, search };
}
Кожному виходячому запиту присвоюється номер тикета.
Коли повертається відповідь, система запитує себе: «Чи все ще у мене є найновіший тикет?»
Якщо відповідь «ні», відповідь відкидається. Самі дані не є неправильними — вони просто застарілі. А застарілі дані виглядають точно так само, як неправильні, коли відображаються на екрані.
Рішення 2: Скасувати старий запит за допомогою AbortController
Перевірка номерів замовлень просто ігнорує застарілі відповіді після їх отримання. AbortController запобігає їх отриманню з самого початку. Ось ця ідея у простому JavaScript, без використання будь-яких фреймворків:
let controller = null;
async function search(query) {
// Kill whatever is still in flight.
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
const data = await res.json();
renderResults(data.results);
} catch (err) {
// An aborted fetch throws. That's expected, not an error.
if (err.name === "AbortError") return;
showError(err);
}
}
Одна деталь, яку легко пропустити: виклик fetch, який було скасовано, відхиляється з помилкою AbortError. Якщо проігнорувати обробку цього, ви будете фіксувати хибну помилку після кожного натискання клавіші.
Також варто пам’ятати, що скасування лише заважає браузеру чекати на відповідь — сервер все одно може продовжувати обробку запиту. Це нешкідливо для функції пошуку, але стає проблематичним, коли запит передбачає запис даних.
Виправлення 3: Спосіб React — очищення через useEffect
Коли логіка пошуку знаходиться всередині ефекту, React вже надає вбудований механізм саме для такої ситуації: функцію очищення. Саме цей підхід рекомендується безпосередньо в документації React.
import { useState, useEffect } from "react";
function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const debouncedQuery = useDebouncedValue(query, 300);
useEffect(() => {
const q = debouncedQuery.trim();
if (!q) {
setResults([]);
return;
}
let ignore = false;
const controller = new AbortController();
fetch(`/search?q=${encodeURIComponent(q)}`, { signal: controller.signal })
.then((r) => r.json())
.then((data) => {
if (!ignore) setResults(data.results);
})
.catch((err) => {
if (err.name !== "AbortError") console.error(err);
});
// Runs when debouncedQuery changes again, or the component unmounts.
return () => {
ignore = true;
controller.abort();
};
}, [debouncedQuery]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{results.map((r) => (
<li key={r.id}>{r.name}</li>
))}
</ul>
</>
);
}
Зверніть увагу, що ця схема автоматично забезпечує. Вона дебаунсує саме значення, а не функцію, яка відправляє запит.
Це означає, що щоразу, коли змінюється дебаунсоване значення, спочатку виконується функція очищення з попереднього запуску, яка скасовує старий запит. Видалення компонента також викликає аналогічне очищення. Для цього не потрібні refs чи вручну інкрементовані лічильники.
Рішення 4: Дозвольте бібліотеці для отримання даних виконати це
Якщо ваш проєкт вже використовує TanStack Query (React Query), ця гарантія порядку працює безкоштовно — жодної додаткової логіки не потрібно.
Кожного разу, коли змінюється ключ запиту, попередній запит стає застарілим, і бібліотека автоматично передає вашій функції отримання даних сигнал, який припиняє її виконання.
import { useQuery, keepPreviousData } from "@tanstack/react-query";
function useSearch(debouncedQuery) {
return useQuery({
queryKey: ["search", debouncedQuery],
queryFn: ({ signal }) =>
fetch(`/search?q=${encodeURIComponent(debouncedQuery)}`, { signal })
.then((r) => r.json()),
enabled: debouncedQuery.trim().length > 0,
placeholderData: keepPreviousData, // v5 replacement for keepPreviousData: true
});
}
Справжня робота відбувається у двох місцях: сигнал, який передається до queryFn, та опція placeholderData: keepPreviousData, яка зберігає попередній список видимим, замість того щоб екран залишався порожнім під час завантаження наступної порції результатів.
SWR забезпечує таку саму поведінку завдяки власній налаштуванню keepPreviousData.
Невеликі звички, які значно полегшили правильне виконання
Нижче наведені звички, які варто включити до кожної реалізації пошуку. Жодна з них не є особливо винахідливою, але кожна з них у певний момент допомогла уникнути справжньої помилки.
1. Завжди кодуйте рядок запиту. Випадковий пробіл або символ & у самому запиті може зламати ваш URL. Кожного разу, коли ви створюєте запит, обгорніть значення функцією encodeURIComponent(query). Пошукове поле в продакшені може справді зламатися через такий простий вхідний даний, як „R&D“.
2. Ігноруйте порожні або занадто короткі запити. Видаліть пробіли з вхідних даних та не надсилайте запити для порожнього рядка. Встановлення мінімальної довжини, наприклад двох символів, зменшує кількість марних запитів та знижує ймовірність виникнення конкуренції за обробку запиту.
3. Продовжуйте відображати попередні результати під час завантаження нового запиту. Уникайте очищення списку після кожного натискання клавіші. Невеликий індикатор завантаження поруч з полем введення значно менше дратує, ніж порожній екран під час зміни пошуку — трохи застарілий список кращий, ніж зовсім жодного. Саме таку поведінку забезпечує параметр keepPreviousData.
4. Зберігайте у кеші результати, які ви вже отримали. Люди часто видаляють символи та переписують їх. Простий об’єкт Map, ключем до якого є термін пошуку, гарантує, що при введенні спочатку rea, потім react, а потім поверненні до rea знову, сервер не буде обробляти один і той самий запит двічі.
const cache = new Map();
async function searchWithCache(query, signal) {
if (cache.has(query)) return cache.get(query);
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, { signal });
const data = await res.json();
cache.set(query, data);
return data;
}
5. Нехай бекенд надсилає запит разом із відповіддю. Замість того, щоб повертати звичайний масив, відповідайте щось на кшталт { query: "react", results: [...] }. Це дозволяє клієнту виконати ще одну перевірку — if (data.query !== currentQuery) return; — яка є дешевим додатковим захистом та робить будь-яку помилку миттєво видимою у вкладці Мережа.
6. Навмисно створіть умови конкурентної ситуації. Навряд чи ви помітите цю помилку на localhost при роботі з API, яке відповідає протягом п’яти мілісекунд. Додайте штучну випадкову затримку у ваш сервер-емулятор, і проблема проявиться майже відразу:
app.get("/search", async (req, res) => {
await new Promise((r) => setTimeout(r, Math.random() * 2000));
res.json({ query: req.query.q, results: fakeResults(req.query.q) });
});
Альтернативно відкрийте Chrome DevTools, перейдіть у розділ Мережа, увімкніть обмеження швидкості, виберіть „Повільний 3G“ та швидко введіть текст. Ви побачите, що це не спрацює.
7. Дозвольте натисканню клавіші Enter пропустити затримку debounce. Коли користувач натискає Enter, необхідно негайно ініціювати пошук — затримки debounce призначені для безперервного введення тексту, а не для чіткого сигналу наміру.
8. Завжди скасовуйте дію під час видалення компонента. Якщо користувач відходить, поки триває пошук, подальша відповідь спробує викликати setState у компоненті, якого вже немає. Функція очищення, показана у Рішенні 3, вже враховує цей випадок. Якщо ви використовуєте підхід з номером тикета, переконайтеся, що функція очищення під час видалення також збільшує лічильник.
9. Використовуйте useDeferredValue під час фільтрації локально, а не через мережу. Якщо ви фільтруєте список, який вже завантажений у пам’ять, вам зовсім не потрібні механізми затримки або скасування запитів. useDeferredValue забезпечує плавність роботи під час введення тексту, тоді як React виконує роботу з фільтрацією на тлі — проте він не замінює логіку скасування, коли задіяний запит до сервера.
10. Напишіть один тест, який безпосередньо перевірятиме ситуацію з конкурентними умовами. Симулюйте два запити типу fetch та завершіть другий до першого. Переконайтеся, що інтерфейс відображає результат другого запиту. Це короткий тест, але він допоможе виявити регресію, яка інакше могла б залишитися непоміченою під час перевірки коду.
Якщо ваше поле пошуку коли-небудь короткочасно відображало неправильні результати, перш ніж самостійно їх виправити, причиною не є затримка дебаунсингу, і додавання ще більшої затримки також не вирішить проблему.
Застосування механізму дебаунсингу до введення тексту у пошук все одно є хорошою практикою — воно значно зменшує кількість надсиланих запитів. Проте воно саме по собі не вирішує всієї проблеми.
Вам також потрібне чітке правило порядку: лише найновіший запит має право оновлювати інтерфейс користувача. Це можна забезпечити за допомогою ідентифікатора запиту, об’єкта AbortController, функції очищення useEffect або бібліотеки, яка вже реалізує цю схему. Будь-який з цих підходів підходить — головне — обрати один та послідовно ним користуватися.