Автокомплит адресов: маленький компонент, огромные грабли
«Иногда подсказки не совпадают с тем, что я ввёл» - баг, который невозможно воспроизвести по описанию. Разбираю его и ещё три из нашего сервиса адресов.
Когда мы делали свой сервис адресов поверх ГАР (по сути аналог Dadata, только свой), самым проблемным компонентом оказалось поле ввода с подсказками. Выглядит как инпут, а внутри три классические засады.
Запрос на каждую букву
Человек печатает «Иркутск, улица Ленина» - это 21 символ и 21 запрос. Бэкенд не в восторге, пользователь тоже.
const search = useDebounceFn(async (q: string) => {
suggestions.value = await addressApi.suggest(q);
}, 300);
watch(query, (q) => {
if (q.trim().length < 3) { suggestions.value = []; return; }
search(q);
});
Почему 300 мс, а не 500 или 150: пауза между нажатиями при быстром наборе - примерно 150-200 мс, так что 300 отсекает набор, но остаётся заметно ниже секунды. Секунда - это классический порог у NN/g, после которого человек перестаёт чувствовать, что система отвечает сразу.
Ещё две мелочи, которые вечно забывают: не искать по одному-двум символам (сервер вернёт полбазы) и не искать по пустой строке, когда поле очистили.
Ответы приходят не в том порядке
Вот тот самый баг, который приходит из поддержки в формулировке «иногда подсказки не соответствуют введённому тексту» и не воспроизводится у разработчика никогда.
Пользователь напечатал «ирк», потом «иркутск». Запросы ушли по порядку, а ответ на «ирк» пришёл позже - сеть порядок не гарантирует. В поле висит список от старого запроса.
Лечится в два слоя. Сначала отменяем предыдущий запрос:
let controller: AbortController | null = null;
async function suggest(q: string) {
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/api/suggest?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
});
suggestions.value = await res.json();
} catch (e) {
if ((e as Error).name === 'AbortError') return; // это не ошибка, это мы сами
throw e;
}
}
Axios понимает тот же signal, так что приём переносится один в один.
А потом всё равно проверяем актуальность ответа - отмена не всегда успевает:
let lastRequestId = 0;
async function suggest(q: string) {
const id = ++lastRequestId;
const data = await addressApi.suggest(q);
if (id !== lastRequestId) return; // ответ устарел, выбрасываем
suggestions.value = data;
}
На TanStack Query оба механизма приезжают бесплатно: ключ ['suggest', query] разводит ответы, устаревшие в актуальный кэш не попадают.
Мышка есть, клавиатуры нет
Автокомплит, в котором нельзя выбрать вариант с клавиатуры, - это половина автокомплита. Минимум, который я требую:
function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') { active.value = Math.min(active.value + 1, suggestions.value.length - 1); e.preventDefault(); }
if (e.key === 'ArrowUp') { active.value = Math.max(active.value - 1, 0); e.preventDefault(); }
if (e.key === 'Enter' && suggestions.value[active.value]) select(suggestions.value[active.value]);
if (e.key === 'Escape') close();
}
Плюс подсветка активного элемента с прокруткой в зону видимости, плюс role="combobox" с aria-expanded и aria-activedescendant - без них скринридер не понимает, что вообще происходит. И обработка mousedown вместо click, иначе клик по подсказке будет теряться из-за blur. На этом мы потеряли часа три, пока не поняли, почему выбор мышкой иногда «не срабатывает».
Сервер тоже надо пожалеть
Три вещи, которые сняли нам основную нагрузку:
Кэш в памяти на сессию. Люди постоянно стирают и печатают заново, Map на сотню записей отбивает добрую часть повторов.
Cache-Control: public, max-age=3600 на ответах. Адреса меняются раз в квартал, а не раз в секунду - и это ноль строк кода на фронте.
Десять подсказок вместо пятидесяти. Дальше пятой строки никто не смотрит, а трафик и рендер падают в разы.
Проверить себя просто: открой DevTools, поставь throttling «Slow 4G» и быстро напечатай запрос в свой автокомплит. Больше трёх-четырёх запросов или подсказки, не совпадающие с текстом, - у тебя ровно те баги, о которых выше.