模糊搜索防抖与竞态条件(竞态请求冲突)

在前端做 B 端或者各种信息流项目时,搜索框自动联想(Auto-complete / Suggestion) 绝对是最常被问、最常出 BUG 的高频组件。

看起来极其简单的需求:用户边敲打键盘输入关键字,下方边弹出一个下拉框,实时展示从接口查出来的建议词条。

如果不加控制,你将面临以下两个灾难级的场景。

灾难一:接口轰炸(解决法:防抖 Debounce)

老生常谈的场景:用户想搜 MacBook,他极其快速地敲了 m a c b o o k 7 个字母。 如果监听 input 事件,前端会在短短 1 秒内朝服务端发射 7 个一模一样的模糊搜索请求。这不仅白白浪费服务器资源,在网络略差的情况下卡顿感更是毁灭级的。

解法防抖(Debounce)。 (关于防抖的具体原理我们已在防抖与节流篇讨论过,此处不多赘述)

我们只需用防抖包裹一下请求函数,设定 300ms 的冷静期。只要用户还在噼里啪啦连续敲字,就不发请求。直到他打完 MacBook 停下手 300ms 后,才真正去请求 MacBook 这一个词,大大释放了网关压力。

灾难二:极其隐蔽的竞态漏洞(Race Condition)

即便你加了防抖,在稍慢或者波动的网络下,仍然会爆发极其隐蔽、极其难以复现的数据错乱问题,我们称之为 竞态请求冲突(Race Condition)

案发现场再现

假设防抖设为 300ms:

  1. 时刻 0:用户输入输入了关键字 Apple,停顿了。防抖触发,发出请求 A(查 Apple)。
  2. 时刻 1:网络极其拥堵,请求 A 卡在了半空中,迟迟没有返回结果。
  3. 时刻 2:用户发现不准,把单词改成了更具体的 Apple Watch,停顿了。防抖再次触发,发出请求 B(查 Apple Watch)。
  4. 时刻 3:网络突然通畅了一秒!请求 B 这个接口骨骼惊奇处理极快,先一步返回了包含 [Watch Series 9, Watch Ultra] 的数据。前端下拉框欣喜地呈现了正确的联想词。
  5. 时刻 4:那艘古老的 请求 A 终于气喘吁吁地到达了服务端并缓慢返回了包含 [Apple TV, Apple Pencil] 的数据。
  6. 灾难发生:毫无防备的前端代码用这该死的古董数据 A,盲目覆盖了已经展示的最新数据 B 下拉框的内容。

用户此刻输入框里写着 Apple Watch,但下面的下拉提示框全是 Apple TV,陷入懵逼。这就是由于异步生命周期的不受控引发的时序错乱覆盖。

完美解法一:拦截过期标志位(基础版)

非常简单粗暴但极为实用,利用外部闭包自增变量给每次请求加上 "世代 ID",只敢借取全村最年轻(最新)的那一代的数据。

let currentRequestId = 0; // 全局/组件级别的标识

async function handleSearch(keyword) {
  // 发起请求前,拿到这批次的令牌
  const thisRequestId = ++currentRequestId; 
  
  // 发起真实的接口请求
  const data = await fetchSuggestions(keyword);
  
  // 核心拦截判定:在我等待异步的这段时间里,这个全局令牌是不是已经被后面的请求更新过了?
  // 如果当前令牌小于最新的,说明我是时代的眼泪,默默退场,不渲染
  if (thisRequestId < currentRequestId) {
    return; // 抛弃过时的数据,拒绝 setState
  }
  
  // 确保只有最新请求的回调才有资格操纵 UI
  renderData(data);
}

// 别忘了在这个外套一个防抖
const boundedSearch = debounce(handleSearch, 300);

完美解法二:利用原生的AbortController 斩首行动(优雅高阶)

上面一种方法请求 A 虽然没渲染,但依然浪费了浏览器到服务器全链路的网络和内存。现代浏览器提供了一个更底层的神器可以物理掐死(Abort)掉正在路上的过期请求AbortController

核心思路:只要发起了新的请求 B,就把之前还在路上的请求 A 拦腰斩断(抛出一个 AbortError),从而它的 .then()await 后续永远不会被执行。

let abortController = null;

async function handleSearch(keyword) {
  // 1. 如果之前还有一个 AbortController 在生效,物理斩杀掉它关联的请求
  if (abortController) {
    abortController.abort();
  }

  // 2. 为本次新的请求生成一个全新的生命收割机
  abortController = new AbortController();
  
  try {
    // 3. 将收割机的信标(signal)传给 fetch API
    const response = await fetch(`/api/search?q=${keyword}`, {
      signal: abortController.signal
    });
    
    const data = await response.json();
    renderData(data); // 顺利存活,渲染 UI
    
  } catch (err) {
    // 如果是被我们主动 abort() 掐死的请求,就会报错并被捕获在这里
    if (err.name === 'AbortError') {
      console.log(`请求过时,已被拦截销毁: ${keyword}`);
    } else {
      console.error('真正的网络错误', err);
    }
  }
}

高频面试题剖析

Q:在一个涉及复杂的模糊搜索组件开发中,你是如何保证请求性能,并处理用户极速更改输入引发的数据错乱的?

回答思路:

  1. 防抖减压(基本操作保底):点出应对这种超高频交互,必须在拿到 onInput 的 Value 后加上一层 debounce 来控制,避免键盘每一个击键都击穿去查后端的 DB,节流网络与 CPU。
  2. 点出竞态毒瘤(展示深度与经验):直接挑明这不仅仅是防抖的问题。强调在弱网恶劣环境下,先发出的 A 请求可能会比后发出的具体 B 请求晚到(后发先至),如果不加以约束,A 的旧数据回笼时会把正确的下拉展示污染。
  3. 甩出落地方案
    • 方案一:在逻辑层设置全局时间戳或累加递增的 Request Token,异步返回值拿到时对比是否仍然等于最新 Token,不等于则拦截赋值行为(防止旧数据上墙)。
    • 方案二(秀操作):提及现代浏览器的更硬核解法—— AbortController,把它的 signal 交予 Fetch / Axios 底层。只要输入变了需要发新请求,直接老老实实调用 controller.abort() 物理终结掉上一次连 TCP 底层的等待,不留任何后患。