首页 / 文章

解决搜索界面中无法通过防抖处理的竞态条件问题

了解为何仅靠去抖无法防止过时的 API 响应覆盖最新的 UI 状态,并探索四种实用的解决方案来确保请求按顺序处理。

发布者:REACTAPP.TOP阅读约 10 分钟2397 词

来源: I Thought Debouncing Fixed My Search Bar.

去年我在一个项目中为搜索输入框添加了300毫秒的防抖功能,便认为问题已解决。

后来有测试人员报告了一个奇怪现象:如果他们快速输入,搜索结果会短暂显示一些无关内容,之后才会恢复正常。

最初的反应是怀疑。查看网络标签页后发现,防抖功能确实按预期工作——每次暂停只发送一个请求,没有额外的异常数据。

不过这个漏洞确实存在,只是并非之前认为已经修复的那个问题。

任何开发过搜索栏的人应该都经历过类似情况。每次按键都会触发一次网络请求,导致后端不堪重负。

通常的建议是“加上防抖功能”。通过设置短暂延迟,请求量就会减少,问题也就随之解决。

像这样的小决定会以容易被低估的方式不断累积。这个具体的决定看似微不足道,但造成的损害可能比看上去要严重。

防抖机制实际上并不能保证什么:它只能控制请求的发送时间,却无法决定哪个响应会先到达。

防抖搜索栏真正解决的問題

搜索栏的防抖功能仅解决一个问题:过多的请求被发送出去。

这就是它功能的全部范围,不会影响请求的顺序。

以下是它可能出错的具体情况。假设防抖时间为300毫秒。

  1. 你输入rea,然后停止输入300毫秒。此时会为rea发送请求A。
  2. 你继续输入:react。再次暂停后,会为react发送请求B。
  • 请求A未命中服务器缓存,需要900毫秒才能处理完成。
  • 请求B命中了缓存,120毫秒即可返回结果。
  • 由于B先返回,界面会先显示react的结果。
  • 700毫秒后,A的请求到达,用rea的结果覆盖了屏幕上的内容。
  • 输入框中输入的是react,但下方的列表却显示了rea的结果。这种不一致正是测试人员注意到的闪烁现象。

    防抖机制可以减少这种情况发生的频率,但无法完全杜绝。而“频率降低”恰恰是那种在开发阶段容易被忽视的缺陷,因为此时API响应极快,且只有一个人在进行测试。

    目前尚不清楚这种现象在所有框架和工具组合中是否表现一致。

    然而,在使用 fetch 或 axios 的 React 项目中,这种问题每次都会出现。

    这不仅仅是搜索栏的问题

    一旦意识到这种故障模式,你就会在许多其他地方发现它。其实都是同一个根本缺陷换了不同表现形式:

    • 筛选下拉菜单。 用户先选择“鞋子”,然后迅速切换到“包袋”。如果获取鞋类数据的请求最后才完成,页面就会在“包袋”标签下显示鞋子列表。
    • 标签页。 某人点击“订单”,随即又点击“发票”。如果获取订单数据的响应较慢,它仍会先完成,导致错误的数据出现在正确的标签页下。
  • 分页问题。连续快速点击“下一页”数次,可能会导致第2页的响应在第4页之后才到达。
  • 仪表板上的日期选择器。迅速连续两次更改时间范围,可能会导致图表仍显示被替换前的数据范围。
  • 只要用户改变需求的速度快于后端响应的速度,这种竞态条件就会存在。

    在实际应用中,这种情况会更难解决。

    想象一个管理员仪表板,有人依次选择:

    • 过去30天
    • 然后是过去7天
    • 接着是今天

    此时,可能会有三个独立的请求同时正在处理中。

    一旦数据与用户已经操作过的内容对应,即使数据准确也会变成错误的数据。

    为何延长延迟时间并无帮助

    人们的第一反应通常是增加去抖时间——先试500毫秒,再试试800毫秒。

    这种调整大多出于习惯而非合理考量,根本无法减少闪烁发生的频率,只会让界面显得更加迟缓。即使请求未命中缓存且处理时间为900毫秒,依然会比800毫秒的去抖时间更长。

    调整时间值无法解决源于顺序问题本身的故障。

    此时,这个漏洞的真正本质便显现出来了。

    去抖是一种交通控制机制。

    竞态条件则是顺序错误。

    延长红灯的持续时间并不能决定哪辆车能先到达目的地。

    解决这个问题需要完全不同的规则。

    真正的解决方案:仅允许最新的请求更新用户界面

    这一句话就是完整的解决方案。有多种技术可以实现这一点,从简单到复杂不等。

    解决方案1:为每个请求分配一个工单号

    这是最容易解释的版本,因为它能让背后的机制一目了然。

    import { useState, useRef } from "react";
    
    function useSearch() {
      const [results, setResults] = useState([]);
      const latestRequestId = useRef(0);
    
      async function search(query) {
        const myId = ++latestRequestId.current;
    
        const res = await fetch(`/search?q=${encodeURIComponent(query)}`);
        const data = await res.json();
    
        // If a newer request started while we were waiting, throw this away.
        if (myId === latestRequestId.current) {
          setResults(data.results);
        }
      }
    
      return { results, search };
    }
    

    每个发出的请求都会被分配一个工单号。

    当响应返回时,系统会自问:“我仍然持有最新的工单号吗?”

    如果答案是否,该响应就会被丢弃。数据本身并没有错误——只是已经过时了。而一旦显示在屏幕上,过时的数据看起来就和错误的数据毫无区别。

    解决方案2:使用AbortController取消旧请求

    仅通过检查票号无法忽略已到达的过时响应。AbortController则能从源头阻止这些响应的到来。以下是纯JavaScript实现的思路,无需任何框架:

    let controller = null;
    
    async function search(query) {
      // Kill whatever is still in flight.
      controller?.abort();
      controller = new AbortController();
    
      try {
        const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
          signal: controller.signal,
        });
        const data = await res.json();
        renderResults(data.results);
      } catch (err) {
        // An aborted fetch throws. That's expected, not an error.
        if (err.name === "AbortError") return;
        showError(err);
      }
    }
    

    一个容易被忽视的细节是:被中止的fetch调用会以AbortError形式拒绝响应。如果忽略对此情况的处理,每次按键时都会记录下错误的错误信息。

    还需记住的是,中止请求仅能阻止浏览器等待响应——服务器仍可能继续处理该请求。对于搜索功能而言这并无大碍,但当请求涉及数据写入时问题就会变得严重。

    修复方案3:React的方式——使用useEffect进行清理

    当搜索逻辑位于效应内部时,React 已为这种场景提供了内置机制:清理函数。这正是 React 官方文档中推荐的解决方案。

    import { useState, useEffect } from "react";
    
    function useDebouncedValue(value, delay = 300) {
      const [debounced, setDebounced] = useState(value);
    
      useEffect(() => {
        const id = setTimeout(() => setDebounced(value), delay);
        return () => clearTimeout(id);
      }, [value, delay]);
    
      return debounced;
    }
    
    function SearchBox() {
      const [query, setQuery] = useState("");
      const [results, setResults] = useState([]);
      const debouncedQuery = useDebouncedValue(query, 300);
    
      useEffect(() => {
        const q = debouncedQuery.trim();
        if (!q) {
          setResults([]);
          return;
        }
    
        let ignore = false;
        const controller = new AbortController();
    
        fetch(`/search?q=${encodeURIComponent(q)}`, { signal: controller.signal })
          .then((r) => r.json())
          .then((data) => {
            if (!ignore) setResults(data.results);
          })
          .catch((err) => {
            if (err.name !== "AbortError") console.error(err);
          });
    
        // Runs when debouncedQuery changes again, or the component unmounts.
        return () => {
          ignore = true;
          controller.abort();
        };
      }, [debouncedQuery]);
    
      return (
        <>
          <input value={query} onChange={(e) => setQuery(e.target.value)} />
          <ul>
            {results.map((r) => (
              <li key={r.id}>{r.name}</li>
            ))}
          </ul>
        </>
      );
    }
    

    注意这种模式会自动带来什么好处。它是对 value 本身进行防抖处理,而非对触发请求的函数进行防抖。

    这意味着每当防抖后的值发生变化时,上一次执行留下的清理函数会首先被触发,从而取消那个过时的请求。组件卸载时也会触发相同的清理操作。实现这一功能无需使用 refs 或手动递增的计数器。

    解决方案 4:让数据获取库来处理

    如果你的项目已经依赖 TanStack Query(React Query),那么这种顺序保障是无需额外代码即可获得的——不需要任何自定义逻辑。

    每当查询键发生变化时,之前的查询就会失效,此时库会自动向您的获取函数传递一个signal,从而帮您中止该查询。

    import { useQuery, keepPreviousData } from "@tanstack/react-query";
    
    function useSearch(debouncedQuery) {
      return useQuery({
        queryKey: ["search", debouncedQuery],
        queryFn: ({ signal }) =>
          fetch(`/search?q=${encodeURIComponent(debouncedQuery)}`, { signal })
            .then((r) => r.json()),
        enabled: debouncedQuery.trim().length > 0,
        placeholderData: keepPreviousData, // v5 replacement for keepPreviousData: true
      });
    }
    

    实际的处理工作发生在两个地方:传递给queryFn的signal,以及placeholderData: keepPreviousData选项——该选项会在加载下一批结果时保持之前的列表显示,避免屏幕变为空白。

    SWR也通过其自身的keepPreviousData设置提供了类似的功能。

    让事情更易处理的小习惯

    以下是一些值得在每个搜索实现中养成的习惯。它们并非特别巧妙,但每一种都在某些时候避免过实际故障。

    1. 始终对查询字符串进行编码。搜索词中出现的多余空格或&字符都会导致URL出错。每次构建请求时都应使用encodeURIComponent(query)对查询值进行封装。即便是像“R&D”这样普通的输入,也可能让生产环境中的搜索框无法正常工作。

    2. 忽略空内容或过短的查询。去除输入中的空白字符,若查询字符串为空则跳过请求发送。设定最小长度要求(如两个字符),既能减少不必要的请求,也能降低竞争条件出现的可能性。

    3. 在新请求加载时继续显示之前的结果。避免每次按键都清空列表。在输入框旁显示一个小旋转图标,远比搜索之间屏幕一直空白要好得多——哪怕列表稍显过时,总比完全没有要好。这正是keepPreviousData功能所实现的效果。

    4>缓存已获取的结果。人们经常删除字符后又重新输入。使用以搜索词为键的简单Map结构,就能确保在输入rea、再输入react,然后退格又回到rea时,不会因相同的查询而两次向服务器发送请求。

    const cache = new Map();
    
    async function searchWithCache(query, signal) {
      if (cache.has(query)) return cache.get(query);
      const res = await fetch(`/search?q=${encodeURIComponent(query)}`, { signal });
      const data = await res.json();
      cache.set(query, data);
      return data;
    }
    

    5. 让后端在响应中一并返回查询结果。不要直接返回普通数组,而应以类似 { query: "react", results: [...] } 的格式响应。这样客户端就可以再进行一次合理性检查——if (data.query !== currentQuery) return;——这相当于一种简单的额外防护措施,任何错误都会立即在网络标签页中显现。

    6. 故意重现竞态条件。在本地主机上,面对响应时间为5毫秒的API,你很可能注意不到这个错误。在模拟服务器中加入人为的随机延迟后,问题几乎会立刻暴露出来:

    app.get("/search", async (req, res) => {
      await new Promise((r) => setTimeout(r, Math.random() * 2000));
      res.json({ query: req.query.q, results: fakeResults(req.query.q) });
    });
    

    或者,打开Chrome开发者工具,进入“网络”选项卡,启用限流功能,选择“慢速3G”,然后快速输入内容。你就会看到请求失败的现象。

    7. 按下回车键可跳过防抖延迟。当用户按下回车键时,应立即触发搜索——防抖延迟是为持续输入设计的,而非用于处理明确的操作意图信号。

    8>卸载时务必取消操作。如果在搜索进行中用户离开页面,最终的响应会试图对已不存在的组件调用setState方法。修复方案3中所示的清理函数已涵盖这种情况。若你采用票号处理方式,也要确保在卸载时同步增加计数器数值。

    9. 在本地进行过滤时使用 useDeferredValue,而非通过网络传输。 如果你要过滤的列表已经加载到内存中,就完全无需使用防抖功能或取消请求。useDeferredValue 能让输入体验更加流畅,同时由 React 在后台处理过滤操作——但一旦涉及服务器请求,它就无法替代取消逻辑。

    10. 编写一个直接针对竞态条件的测试。 模拟两次 fetch 调用,并在第一次调用之前就完成第二次调用的处理。验证 UI 最终显示的是第二次调用返回的结果。虽然这是一个简短的测试,但它能捕捉到那些在其他情况下可能被代码审查遗漏的回归问题。

    如果你的搜索栏曾出现过短暂显示错误结果后又自动修正的情况,那问题并不出在防抖延迟上,增加延迟也无法解决此问题。

    对搜索输入进行防抖仍然是个好习惯——它能显著减少发送的请求数量。只不过它本身无法解决所有问题。

    你还需要一个明确的排序规则:只有最新的请求才被允许更新用户界面。可以通过请求ID、AbortController、useEffect的清理函数,或是已经实现该机制的库来确保这一点。这些方法中的任何一种都可以使用——关键是要选定一种并始终如一地应用它。