联想框不是小号搜索框
很多人把输入联想当成「输入框下面挂一个列表」:监听 input,请求接口,渲染前十条。真到线上,问题几乎都不在渲染,而在输入与响应之间的时间差。用户还在打字,请求已经发出去;用户停下来了,旧请求才回来;用户切到拼音候选,联想列表却在中文输入法里闪。
本节只讨论「从按键到联想列表」这一段:什么时候发请求、发出去之后怎么取消、多个响应乱序回来听谁的、什么样的结果可以缓存。它不讨论搜索结果页的分页与筛选状态(12.2),也不讨论发现页的推荐位如何与信息流组合(12.3),更不讨论服务端如何给候选词排序。我们假定服务端已经能返回一个有序列表,客户端要解决的是别把这份列表用错。
一次联想要过的四道关
| 关卡 | 典型错误 | 正确做法 |
|---|---|---|
| 发不发 | 每次按键都请求 | 防抖,只在停顿后发一次 |
| 能不能发 | 拼音组合期间也请求 | 组合期间只记草稿,结束后再排期 |
| 旧的要不要 | 只防抖,不处理在途请求 | 取消在途请求,并用版本号兜底 |
| 结果能不能复用 | 用查询词前缀做 key | 用完整查询词做 key,并限制容量 |
四道关里,只有第一道是「少发请求」,后三道都是「发出去之后怎么保证结果属于当前输入」。防抖做得再好,也挡不住两个请求都合法发出、只是返回顺序颠倒。
防抖的计时从最后一次输入开始
防抖的实现是一个可取消的定时器:每次输入先取消上一个定时器,再按当前文本重新计时。用户在 200 毫秒内输入五个字符,只会发出最后一次请求。示意写法如下:
function schedule(text: string): void {
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
void fetchSuggestions(text);
}, delayMs);
}
这里有两个容易写错的细节。第一,定时器回调里要清空 timer,否则下一次输入取消的是一个已经执行过的句柄,状态会越来越乱。第二,延迟时间不要写死在业务代码里,构造时注入,测试才能用假调度器瞬间推进。250 毫秒只是常见示例值,真实取值要看接口成本和用户输入节奏。
防抖解决的是「请求太多」,它不解决「请求太旧」。用户打完「java」停住,请求发出;两秒后又输入「javascript」,第一个请求可能仍在路上。如果客户端只做了防抖,旧响应回来时依然会覆盖新结果。
输入法组合期间不请求
中文、日文、韩文输入法在选词完成前会不断触发 input 事件。用户想输入「搜索」,拼音阶段可能依次出现 s、so、sou、sous、sousuo。如果每个阶段都发请求,不仅浪费流量,联想列表还会在候选词面板下面抖动。
正确做法是监听组合事件:compositionstart 时进入组合态,取消已排期的定时器;组合期间只更新一份「草稿文本」,不排期;compositionend 时如果草稿与当前已查询的词不同,再按防抖规则排期。
这里的关键是把「用户正在输入什么」和「客户端正在查询什么」分开。组合期间草稿一直在变,但查询词保持不变,界面上可以继续显示上一轮结果,避免列表突然清空。组合结束后才把草稿提交为新的查询目标。
取消在途请求与 latest-wins 不能只做一半
AbortController 能取消传输层的请求,但它不是正确性保证。请求可能已经被服务端处理,响应可能已经在路上,代理或网关也可能忽略取消信号。只依赖 abort,迟早会遇到「明明取消了,旧结果还是回来了」。
所以需要两层:
- 取消:新输入到达时 abort 旧请求,节省带宽,降低服务端压力。
- 版本号:每次查询条件变化自增一个整数
seq,响应回来时只有seq等于当前值才允许写入界面。
两层职责不同。abort 是优化,seq 是判据。测试里可以故意让 fetcher 忽略 abort 信号、按任意顺序 resolve,只有 seq 能保证最终界面属于最后一次输入。
版本号不要用时间戳,客户端时钟回拨或同毫秒输入都会让它失效。
缓存 key 必须是完整查询词
联想结果适合缓存:同一个词在几分钟内重复输入的概率很高,用户删掉一个字符再补回来也很常见。但缓存 key 不能偷懒。
常见的错误 key 有三种:
- 前缀 key:只取查询词前 N 个字符。
typescript与typescript 泛型会落到同一个桶里,后者拿到前者的结果。 - 用户加前缀:把用户 id 和一个短前缀拼起来,仍然解决不了长词互相污染。
- 不带账号维度:如果联想包含历史记录、关注的人或私信联系人,缓存必须在账号切换时失效。
正确的 key 是「规范化后的完整查询词」:去掉首尾空白、把连续空白折叠成一个空格、拉丁字母转小写。中文不做额外分词,原样保留。规范化只影响 key,不改变展示文本。
缓存还要有容量上限。用 Map 的插入顺序做 LRU:命中时先删再插,超出上限时删最久未用的键。缓存里存完整结果,渲染时再按 limit 截断;如果只存截断后的列表,换个页面尺寸就得重新请求。
一个可运行的联想控制器
下面的 SuggestController 属于 L2:没有真实网络与定时器,但通过注入 Scheduler 控制防抖,通过 AbortController 与自增 seq 模拟取消和乱序。测试用一个假调度器推进时间,用一组手动 resolve 的 Promise 控制返回顺序。
// file: src/search/suggest.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/search/suggest.test.ts
export interface SuggestItem {
text: string;
kind: "history" | "query" | "user" | "topic";
score: number;
}
export interface SuggestRequest {
query: string;
seq: number;
signal: AbortSignal;
}
export type SuggestFetcher = (request: SuggestRequest) => Promise<SuggestItem[]>;
export interface Scheduler {
schedule(callback: () => void, delayMs: number): number;
cancel(handle: number): void;
}
export type SuggestStatus = "idle" | "debouncing" | "loading" | "ready" | "empty" | "error";
export interface SuggestSnapshot {
status: SuggestStatus;
query: string;
items: SuggestItem[];
fromCache: boolean;
staleDropped: number;
aborted: number;
}
export interface SuggestOptions {
delayMs?: number;
limit?: number;
cacheLimit?: number;
}
export function suggestCacheKey(query: string): string {
return query.trim().replace(/\s+/g, " ").toLowerCase();
}
export class SuggestController {
#fetcher: SuggestFetcher;
#scheduler: Scheduler;
#delayMs: number;
#limit: number;
#cacheLimit: number;
#draft = "";
#query = "";
#composing = false;
#timer: number | null = null;
#seq = 0;
#controller: AbortController | null = null;
#inflight: Promise<void> | null = null;
#status: SuggestStatus = "idle";
#items: SuggestItem[] = [];
#fromCache = false;
#staleDropped = 0;
#aborted = 0;
#cache = new Map<string, SuggestItem[]>();
constructor(fetcher: SuggestFetcher, scheduler: Scheduler, options: SuggestOptions = {}) {
const delayMs = options.delayMs ?? 250;
const limit = options.limit ?? 8;
const cacheLimit = options.cacheLimit ?? 32;
if (!Number.isFinite(delayMs) || delayMs < 0) throw new Error("delayMs 必须是非负数");
if (!Number.isInteger(limit) || limit < 1) throw new Error("limit 必须是正整数");
if (!Number.isInteger(cacheLimit) || cacheLimit < 0) throw new Error("cacheLimit 必须是非负整数");
this.#fetcher = fetcher;
this.#scheduler = scheduler;
this.#delayMs = delayMs;
this.#limit = limit;
this.#cacheLimit = cacheLimit;
}
get snapshot(): SuggestSnapshot {
return {
status: this.#status,
query: this.#query,
items: [...this.#items],
fromCache: this.#fromCache,
staleDropped: this.#staleDropped,
aborted: this.#aborted,
};
}
get composing(): boolean {
return this.#composing;
}
setComposing(composing: boolean): void {
if (composing === this.#composing) return;
this.#composing = composing;
if (composing) {
this.#cancelTimer();
return;
}
this.#schedule(this.#draft);
}
type(text: string): void {
this.#draft = text;
if (this.#composing) {
this.#cancelTimer();
return;
}
this.#schedule(text);
}
clear(): void {
this.#draft = "";
this.#cancelTimer();
this.#invalidate();
this.#query = "";
this.#items = [];
this.#fromCache = false;
this.#status = "idle";
}
async idle(): Promise<void> {
while (this.#inflight) {
await this.#inflight;
}
}
#schedule(text: string): void {
this.#cancelTimer();
if (text.trim() === "") {
this.#invalidate();
this.#query = "";
this.#items = [];
this.#fromCache = false;
this.#status = "idle";
return;
}
if (text === this.#query && (this.#status === "ready" || this.#status === "empty")) return;
this.#invalidate();
const key = suggestCacheKey(text);
const cached = this.#cache.get(key);
if (cached) {
this.#touchCache(key);
this.#query = text;
this.#items = cached.slice(0, this.#limit);
this.#fromCache = true;
this.#status = this.#items.length > 0 ? "ready" : "empty";
return;
}
this.#query = text;
this.#items = [];
this.#fromCache = false;
this.#status = "debouncing";
this.#timer = this.#scheduler.schedule(() => {
this.#timer = null;
void this.#fire(text);
}, this.#delayMs);
}
#fire(query: string): void {
const controller = new AbortController();
this.#controller = controller;
this.#items = [];
this.#fromCache = false;
this.#status = "loading";
const seq = this.#seq;
this.#inflight = this.#run(seq, query, controller);
}
async #run(seq: number, query: string, controller: AbortController): Promise<void> {
try {
const items = await this.#fetcher({ query, seq, signal: controller.signal });
if (seq !== this.#seq) {
this.#staleDropped += 1;
return;
}
this.#store(query, items);
this.#items = items.slice(0, this.#limit);
this.#fromCache = false;
this.#status = this.#items.length > 0 ? "ready" : "empty";
} catch {
if (controller.signal.aborted) return;
if (seq !== this.#seq) {
this.#staleDropped += 1;
return;
}
this.#items = [];
this.#fromCache = false;
this.#status = "error";
} finally {
if (this.#controller === controller) {
this.#controller = null;
this.#inflight = null;
}
}
}
#invalidate(): void {
this.#seq += 1;
if (this.#controller) {
this.#aborted += 1;
this.#controller.abort();
this.#controller = null;
}
}
#store(query: string, items: SuggestItem[]): void {
if (this.#cacheLimit === 0) return;
const key = suggestCacheKey(query);
this.#cache.delete(key);
this.#cache.set(key, [...items]);
while (this.#cache.size > this.#cacheLimit) {
const oldest = this.#cache.keys().next().value as string | undefined;
if (oldest === undefined) break;
this.#cache.delete(oldest);
}
}
#touchCache(key: string): void {
const items = this.#cache.get(key);
if (!items) return;
this.#cache.delete(key);
this.#cache.set(key, items);
}
#cancelTimer(): void {
if (this.#timer === null) return;
this.#scheduler.cancel(this.#timer);
this.#timer = null;
}
}
四个关键决策
防抖只负责延迟,不负责正确性
#schedule 里每次输入都先取消旧定时器,这只保证「停顿后发一次」。它不保证「只接受最后一次响应」。把两者混在一个定时器里,会出现一种典型 bug:用户输入 A,请求发出;输入 B,定时器重新排期;A 的响应在 B 的定时器触发前回来,界面先显示 A 的结果,等 B 回来再跳一次。用户看到的是列表闪动,而不是稳定收敛。
abort 与 seq 的分工要写进注释
#invalidate 同时做两件事:seq += 1 和 abort 当前控制器。前者是必须的,后者是尽力而为。响应处理里先看 seq,再看错误类型;被 abort 的请求不应该被记成业务错误,也不应该写入缓存。把这条分工写清楚,后来的人就不会为了「省一次请求」把 seq 删掉。
组合期用草稿,不用查询词
type() 在组合态只更新 #draft,不碰 #query。setComposing(false) 才拿草稿去排期。这样组合期间界面仍然显示上一轮结果,不会因为拼音中间态而清空。反过来,如果组合期间也更新查询词,防抖定时器会被反复重置,用户选完词反而要多等一个延迟。
测试与真实输出
$ node --test --test-concurrency=1 test/search/suggest.test.ts
✔ 防抖把连续输入合并成一次请求 (1.222042ms)
✔ 输入法组合期间不触发,组合结束后才排期 (0.170084ms)
✔ 新输入立即取消在途请求 (0.115209ms)
✔ 慢请求后到不能覆盖新结果 (0.540792ms)
✔ 缓存命中不再发请求,且 key 是完整查询词 (0.193083ms)
✔ 空输入清空结果、取消排期并中止在途请求 (0.167833ms)
✔ 请求失败进入 error,同一查询不会误用失败的缓存 (0.153292ms)
✔ 结果按 limit 截断但仍缓存完整列表 (0.120792ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 178.813167
八条用例覆盖了防抖合并、组合期抑制、取消、乱序、缓存命中、空输入、失败与截断。它们没有覆盖真实输入法与真实网络:不同输入法触发组合事件的时机并不一致,代理层是否尊重 abort 也只能在联调环境验证。假调度器证明的是状态机正确,不是端到端时延。
失败复盘:慢请求后到覆盖新结果
「回声」的搜索框最初只有防抖,没有版本号。产品经理复现了一个问题:输入「jav」后停住,再快速补成「java」,列表先显示「java」的结果,半秒后突然跳回「jav」的旧结果,过一会儿又跳回去。用户以为搜索框坏了。
日志里两个请求都成功返回,时间差 600 毫秒。旧请求走了一个冷启动节点,比新请求慢。代码在响应回调里直接 setItems(response.items),谁后到谁覆盖。防抖只保证「每次停顿发一个请求」,不保证「后发的先到」。
修复分两步。第一,给控制器加 seq,每次查询条件变化自增,响应回来先比对;旧响应只增加 staleDropped 计数,不写界面。第二,新输入到达时 abort 旧请求,减少无谓等待。上线后同样的操作序列里,旧响应仍然可能回来,但会被版本号挡住,界面不再回跳。
同一版本还修了一个缓存事故:key 用了查询词前 8 个字符,typescript 和 typescript 泛型 共用一个桶,用户输入长词时看到了短词的结果。修复后 key 使用完整规范化查询词,并加了容量上限。两个问题的共同点是「用近似值代替精确身份」,一个是响应身份,一个是缓存身份。
小结
联想框要同时处理四件事:防抖决定什么时候发,组合态决定能不能发,abort 加版本号决定旧响应算不算数,完整查询词决定结果能不能复用。防抖是流量优化,版本号才是正确性边界;缓存 key 必须精确到完整查询词,不能拿前缀猜。
联想解决「输入时给什么提示」,接下来要回答「按下回车之后,结果页怎么分页、筛选条件放在哪里、返回时怎么恢复」。下一节把搜索状态从组件里抽出来,做成可序列化、可回放的状态机。
阅读导航:上一节:11.3 举报链路与证据留存 · 下一节:12.2 结果分页与筛选状态的保持 。
常见错误与边界
- 每次
input都发请求,弱网下请求排队,返回顺序完全不可控。 - 组合期间也更新查询词,拼音中间态触发请求,列表在候选词下方抖动。
- 只做防抖不做版本号,慢请求后到覆盖新结果,界面来回跳。
- 只依赖
AbortController,代理忽略取消信号时旧响应仍然写入。 - 用时间戳判断响应新旧,客户端时钟回拨或同毫秒输入导致误判。
- 缓存 key 截断成前缀,长词与短词互相污染。
- 缓存不带账号维度,切换账号后看到上一个用户的历史联想。
- 缓存只存截断后的列表,换页面尺寸或展示密度就必须重新请求。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。