《互联网产品的客户端架构·内容、社交与实时互动》12.1 输入联想:防抖、竞态与缓存

联想框的难点在输入与响应之间:本节用可注入调度器的 TypeScript 控制器实现防抖、输入法组合抑制、取消在途请求与 latest-wins 版本号,解释缓存 key 为什么必须包含完整查询词,并复盘一次慢请求后到覆盖新结果的线上故障。

联想框不是小号搜索框

很多人把输入联想当成「输入框下面挂一个列表」:监听 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 截断成前缀,长词与短词互相污染。
  • 缓存不带账号维度,切换账号后看到上一个用户的历史联想。
  • 缓存只存截断后的列表,换页面尺寸或展示密度就必须重新请求。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查