资讯文章 资讯 · NEWS
每敲一个字就发一次请求:目录站搜索框下拉的性能问题该怎么解
2026-10-24 约 2,675 字 预计阅读 7 分钟 yiyunkj
每
搜索框的下拉联想是个看起来只值两小时、实际能吞掉两周的模块。它的问题不在功能,在于输入这件事本身太快、太碎、太容易被打断。
用户连续敲五个字,你可能发了五个请求,回来的顺序还不一定和发的顺序一样。加上中文输入法那一层,事情就更复杂了。

图片来源:网络,版权归原作者所有,侵权请联系删除
第一道闸:延迟触发
最基础的一件事是别每个按键都发请求。用防抖,等用户停一下再发:
let timer = null;
input.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(runSuggest, 180);
});
延迟值怎么定?太快(50 毫秒以下)基本等于没防抖,太慢(超过 400 毫秒)用户会觉得卡。180 到 250 毫秒这个区间是常见的取值,中文输入场景可以稍微再长一点。
注意这里用防抖而不是节流。防抖是「停下来才发一次」,节流是「固定间隔发一次」。联想词要的是前者——用户打完一串再查,中间过程没有意义。
第二道闸:取消和竞态
防抖只能减少请求数,减不掉竞态。用户打了「设计」又删掉改成「设计师」,两个请求先后发出,第一个可能后回来,把下拉里的结果换成「设计」的——用户明明输的是「设计师」,看到的却是上一次的内容。
解决办法是发新请求前把旧的取消掉:
let ctrl = null;
async function runSuggest() {
if (ctrl) ctrl.abort(); // 取消上一次
ctrl = new AbortController();
try {
const r = await fetch('/api/suggest?q=' + encodeURIComponent(input.value),
{ signal: ctrl.signal });
render(await r.json());
} catch (e) {
if (e.name !== 'AbortError') render([]); // 主动取消不算失败
}
}
AbortController 会真的把请求中断掉,服务端也能少算一次。不支持的老环境里,退而求其次的办法是给每次请求编号,只接受编号最大的那个响应——不能省服务端资源,但至少结果不会错乱。
中文输入法这一关必须过
这是中文站点特有的一坑,也是最容易漏的一坑。
用户用拼音输入法打「设计」,在按下空格确认之前,输入框里已经出现了「sheji」这串拼音,input 事件会照常触发。如果这时候发请求,服务端收到的就是一串无意义的字母,下拉里会闪现一堆莫名其妙的结果。
要借助输入法事件避开这段窗口:
let composing = false;
input.addEventListener('compositionstart', () => { composing = true; });
input.addEventListener('compositionend', () => { composing = false; runSuggest(); });
input.addEventListener('input', () => { if (!composing) schedule(); });
逻辑很直白:输入法组合期间不请求,组合结束(用户确认了候选词)再请求。这样服务端收到的永远是确认后的中文,下拉也不会闪。
顺便说一句,移动端和 Mac 上这个事件的表现还有些差异,测试时两个平台都要过一遍。
本地缓存能省掉大半请求
联想词有个特点:热门查询高度集中。同一个词会被反复搜,而且用户经常是打打删删——「设计」「设计师」「设计工具」这几个前缀会来回出现。
加一层内存缓存,命中就直接用,不发请求:
const cache = new Map(); // key: 查询词, value: { at, data }
const TTL = 5 * 60 * 1000;
function get(q) {
const hit = cache.get(q);
if (hit && Date.now() - hit.at < TTL) return hit.data;
return null;
}
function set(q, data) {
if (cache.size > 200) cache.delete(cache.keys().next().value); // 简单的 FIFO 淘汰
cache.set(q, { at: Date.now(), data });
}
目录站的联想词变化不快,五分钟甚至半小时的 TTL 都可以接受。数据量控制在两百条以内,内存压力可以忽略。注意淘汰策略要简单——用 Map 的插入顺序做 FIFO 就够了,不用实现完整的 LRU。
还有一层是前缀复用:已经取过「设计」的结果,再输入「设计工具」时,可以先用上一次的结果做一次本地过滤,把明显不匹配的先渲染出来,等网络结果回来再替换。用户感知上会快很多。
键盘导航和无障碍
一个只能靠鼠标点的下拉是不完整的。至少要支持:上下键移动选项、回车选中、Esc 关闭、Tab 移出后自动关闭。
无障碍方面,标准的组合是输入框上标 role="combobox" 加 aria-expanded,列表容器标 role="listbox",每个选项标 role="option",并用 aria-activedescendant 指向当前高亮的那一项。这样屏幕阅读器才能播报「第几项,共几项」。
另外,高亮项应该跟着上下键滚动到可视区域内,别让焦点跑到看不见的地方。
几个容易忽略的细节
- 失败要静默。联想词请求失败时直接清空下拉,不要弹错误提示。它是辅助功能,不是主流程。
- 空结果不要留一个空框。没匹配到就干脆不显示下拉,或者显示一行「没有匹配的条目」,别让用户对着一个空白的白色方块发愣。
- 不要把整段 HTML 拼出来再塞进去。每次输入都重建整个列表的 DOM,在低端手机上会有明显卡顿。给选项加稳定的 key 做复用,只更新变化的部分。
- 高亮要转义。把用户输入直接拼进高亮的 HTML 里,是个现成的 XSS 入口。用文本节点加 CSS 类实现高亮,别拼字符串。
- 移动端注意键盘遮挡。下拉定位要考虑软键盘弹出后的可视高度,别让结果被键盘盖住。
做完怎么验
- 快速连续输入一串字,看 Network 面板里实际发出几个请求(理想情况应该远少于按键次数)。
- 把网络限速到慢速 3G,输入后快速删改,确认最终显示的结果和输入框里的内容一致。
- 用中文输入法完整打一遍,确认拼音阶段没有请求发出。
- 纯键盘操作一遍:输入、上下选择、回车、Esc。
- 关掉 JS 看一眼,搜索框本身应该还能正常提交——下拉是增强,不能是唯一入口。
这几件事做完,下拉才算真正能用。少做一件,用户不一定说得出哪里不对,但会觉得这个搜索框「有点别扭」。