Усуненне проблем з супернечымаючымі процесамі: метод дэбаунсінгу не можа рашыць ў інтэрфейсах пошуку
Дазнаецеся, чаму сама тэхніка дебаунсінга не можа запобiec таму, што застарэлыя адпаведзі AI можа заменіць свежы стан інтэрфейсу, і пазнаеце чатыры практычныя спосабы для абэрантнага выканання запитоў.
Мінулага годзіна я дадзеў 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 уже з’являе вбудованы механізм для такога сцэнарыю: функцыя cleanup. Гэты падход рекамендуецца безпосередна ў самай дакументацыі 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>
</>
);
}
Зверніце ўвагу, што гэты патэрн автаматычна дае вам. Вы дэбаусіваете сам value, а не функцыю, якая выклікае запит.
Это значыць, што кожны раз, калі зменяецца дэбаусіваны значэнне, спачатку выконуецца функцыя cleanup з паканальнага запуску, якая анулюе той застарэлы запит. Анунемаванне компонента таксама спрыяе выконанню функцыі cleanup. Для ўсьго гэтага вам не патрэбны refs чы ручна інкрементаваныя лічыльнікі.
Рашэнне 4: Дазвольце бібліятэкі для запошуку дадзеных адмахнуцца ад гэтага
Якщо ваш проект вже выкарыстоўвае TanStack Query (React Query), гэтая гарантія порядку працы даецца безкоштовна — не патрэбна жадная спецыяльная логіка.
Калі змінюецца ключ запиту, попярэдні запыт стае застарелым, і бібліятэка автаматычна перадае вашай функцыі fetch signal, який завершае ёё выконанне.
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
});
}
Праця ведзецца у двух месцах: signal, які перадаецца у queryFn, і опцыя placeholderData: keepPreviousData, яка заставляе попярэдній список застацца видным, замест таго каб экран заставаліся порожнім пад час завантажэння наступных рэзультаатаў.
SWR аддае тое жа паведамленне за дапамою свайго сабе keepPreviousData настаўлення.
Маленькія прычыны, якія значна спрыяюць правильнаму выкарыстоўванню
Наведзеныя нижэй прычыны вартаў уключыць у кожную рэалізацыю пошуку. Жадна з іх не ўскладнена, але кожная колісь запобегла справжням багам.
1. Заўсёды кодавайце строку запита. Некалькі прасоў чы рэгулярны знак & у самай фразе пошуку можа зламаць ваш URL. Кожны раз, калі ствараеце запит, абгорніце значэнне за дапамою encodeURIComponent(query). Поле пошуку ў працоўнай сітцы можа практычна не працаваць, якщо было введзена такая простая фраза, як "R&D".
2. Ігноравайце порожнія чы занадта короткія запиты. Адзьмяйце прасоў з введаных дадзенняў і не надсылайце запит, калі строка порожняя. Установка мінімальной дужыны, напрыклад двух симвалаў, зменшае колькасць непатрэбных запытоў і зямліць рызык выкарыстоўвання тэхніки race condition.
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, перайдзіце на раздел Network, увялічыце частоту запытанняў, выберыце "Slow 3G," і быстра запішыце тэкст. Вы пабачыце, як гэта не будзе працаваць.
7. Нажыце Enter, каб ускорыць прахожданне часу адлагоджэння. Калі корыстнік нажыў Enter, нехай пошук запускаецца адразу — час адлагоджэння прызначаны для пацёрквання тексту, а не для чытальнага сигналу намеру.
8. Заўжды анулюйце дзейнасць пад час знячыцтва компонента. Якщо корыстнік перейдзе на іншую сторону, пакалі пошук яшчэ трывае, рэзультат будзе прабаваць вызваць setState у компоненте, які ўжо не існуе. Функцыя чысткі, показаная ў Рашэнку 3, вяршыць гэты случай. Якщо вы вжываеце падход з номерам квітанцы, пераканайцеся, што функцыя чысткі пад час знячыцтва таксама адбавляе лічылку.
9. Ўжыце useDeferredValue, калі фільтруеце даныя локальна, а не через сеть. Якщо вы фільтруеце спіс, які вже завантажаны ў памяць, вам зовсім не трэба викорыстоўваць механізмы затрымкі або анулювання запитаў. useDeferredValue дапамагае падтрымваць плавную роботу пры вводзе, пакалі React адбывае роботу з фільтрацыяй на задньму плане — але ён не заменяе логіку анулювання, калі ў спрабе ўчаствуе сервер.
10. Напісце адзін тэст, які безпасебна пераканаецца ў наявнасці супернагонкі. Створыце макет двух запитоў fetch і вырашыце другі з ягоў раней, чым першы. Пераканаецеся, што інтэрфейс показвае рэзультат з другога запита. Це короткі тэст, але ён дапаможае выявіць регрэсію, якая інакш могла бы застацца непазначанай пад час перагляду коду.
Якщо ваша панель пошуку каліцей паказвала некоректныя рэзультаты пры першай спробе пошуку, а пасля сама іх выправіла, то прычыной ня ўсё тое затрымленне, якое дапамагае усунуць падвойныы запуск, і дадаткова затрымка таксама нічога не вырашыць.
Викорыстоўванне такога механізма для запуску пошуку застаецца хорашай практыкой — ён значна зменшае колькасць запытоў, якія вы адправляеце. Але ён сам по сабе не рашае всія проблемы.
Таксама неабходны явны правілы арранжавання: толькі найсвежэйшы запыт можа апдэйтаваць інтерфейс. Оасаблівае выкананне гэтага правіла можна забезпечыць за дапамогою ID запыту, об’екта AbortController, функціі чысткі useEffect або бібліятэкі, якая вже реалізавала такую схему. Будзь-які з гэтых падходаў працюе — галоўная частка — выбраць адны і ўсёчас яго вярнаваецца.