
1. 是时候重新审视“搜索”这件小事了如果你做过几年的React前端大概率会对这么个场景刻骨铭心用户在搜索框里输入“React数据获取”刚敲完“R”请求发出敲到“Rea”又一个请求发出等到完整输入“React数据获取”时已经发了六七条网络请求。更头疼的是这些请求的返回顺序完全不可控——最后发出的请求可能最先回来而最早发出的那个反而最后才到。于是页面上的搜索结果就开始“闪烁”一会儿显示“R”的结果一会儿被“React”的结果覆盖最后又莫名其妙跳回“React数据获取”的旧结果。这个现象业内叫竞态条件Race Condition。而与之相伴的另一个高频问题是用户在快速输入时触发了大量无效请求白白消耗带宽和服务器资源。于是**防抖Debounce和节流Throttle**就成了老生常谈的解决方案。但2026年了搜索功能依然在“闪烁”这说明我们并没有真正把这三个概念之间的关系理清楚。这篇文章想做的就是一次彻底的复盘竞态条件到底是怎么产生的防抖节流分别解决什么问题为什么两者叠加后仍然会出问题以及我们到底该怎么设计一个在真实场景下站得住脚的搜索数据获取方案。这篇文章适合谁看如果你正在用React做搜索框、筛选器、自动补全、表格查询这类“用户输入驱动请求”的功能或者你面试时被问到“如何避免React搜索中的竞态条件”那这篇内容基本就是为你准备的。我会把原理、代码、踩坑经验放在一起讲。2. 竞态条件为什么“先发的请求”反而“后到”2.1 从一次真实的“闪烁”事故说起我先还原一个非常典型的场景。假设你在写一个商品搜索页面输入框的onChange直接触发fetch大概是这样function SearchPage() { const [keyword, setKeyword] React.useState(); const [results, setResults] React.useState([]); React.useEffect(() { if (!keyword.trim()) { setResults([]); return; } fetch(/api/search?q${keyword}) .then(res res.json()) .then(data setResults(data.items)); }, [keyword]); return ( div input value{keyword} onChange{e setKeyword(e.target.value)} placeholder输入商品名称 / ul {results.map(item li key{item.id}{item.name}/li)} /ul /div ); }用户在输入框里快速敲击“iPhone 15”实际产生的keyword变化序列是-i-ip-iph-ipho- …… -iPhone 15。每变化一次useEffect就触发一次网络请求。假设用户在200毫秒内敲完了全部字符这里就出现了两个问题网络请求发出的顺序和用户输入的字符顺序一致但返回的顺序完全不可控。iph的请求可能因为网络波动2秒后才返回而iPhone 15的请求300毫秒就返回了。那么页面先展示iPhone 15的结果随后又被iph的迟到结果覆盖。这就是“闪烁”的本质。即使没有网络波动200毫秒内发出7个请求也是对后端接口的无谓压力。如果接口需要查数据库、做全文检索这些重复请求还可能导致慢查询堆积。2.2 竞态条件与“过期响应”的关系竞态条件这个词源自多线程编程指多个执行单元同时访问共享资源最终结果取决于执行的时序。在React的数据获取场景里多个“执行单元”是多个异步请求“共享资源”是组件里的results状态。你需要特别注意的是“过期响应”这个概念。一个响应是否过期不是看它花了多少时间返回而是看它请求时对应的keyword是否还是当前搜索框中展示的关键词。假如用户现在搜索框里是iPhone 15那iph请求返回的数据对这个用户来说就是过期的、无用的。很多人会说“加个loading状态就好了”但这只会让问题看起来更乱loading状态本身也被多个请求竞争最后loading是true还是false取决于哪个请求最后set而不是哪个请求最有价值。2.3 竞态条件的影响范围不只是搜索结果搜索框只是最典型的重灾区。凡是“用户操作触发异步请求异步请求又更新同一份UI状态”的场景都可能踩中竞态条件。例如Tab切换用户快速点击“订单详情”和“售后记录”两个请求先后发出返回乱序后详情内容串了。分页器快速翻页用户连续点击第2页、第3页结果第3页的数据先返回但又被第2页的返回结果覆盖。筛选器多选联动用户勾选多个筛选项每个筛选都触发一次请求最终展示的数据可能和当前筛选项不匹配。自动保存用户编辑内容后自动保存保存A版本和B版本的请求乱序返回最终数据库里存的是旧版本。所以当你理解了竞态条件你就同时理解了React数据获取中一大类“灵异Bug”的根源。3. 防抖和节流它们不是用来解决竞态条件的3.1 防抖等用户“消停”了再发请求防抖的核心思想是在一段时间内如果事件被连续触发就重置计时器只有事件停止触发一段时间后才执行一次。用生活类比电梯门即将关闭时有人按了开门键电梯就会重新计时等人不再按开门键了电梯才真正关门上行。搜到“React防抖节流”这件事也一样——用户敲击键盘时我们不急着发请求等用户停了300毫秒再发。在代码里实现防抖最常见的方式是维护一个定时器function useDebouncedValue(value, delay 300) { const [debouncedValue, setDebouncedValue] React.useState(value); React.useEffect(() { const timer setTimeout(() { setDebouncedValue(value); }, delay); return () { clearTimeout(timer); }; }, [value, delay]); return debouncedValue; }注意这里的关键点useEffect的清理函数会在value变化时先执行把上一个定时器清掉再创建新的定时器。这就是“重置计时器”的过程。如果你不清理定时器那防抖就失效了等于每隔delay时间就会执行一次那其实是节流的效果。使用方式也很直观const debouncedKeyword useDebouncedValue(keyword, 300); React.useEffect(() { if (!debouncedKeyword.trim()) return; fetch(/api/search?q${debouncedKeyword}) .then(res res.json()) .then(data setResults(data.items)); }, [debouncedKeyword]);这样一来用户快速输入时只有停止输入300毫秒后才会发请求。请求次数从“每敲一个字符一次”变成“每停顿一次一次”这是质的改善。3.2 节流控制请求频率的上限节流的思想是在一段时间内无论事件触发了多少次最多只执行一次。生活类比景区闸机检票无论有多少人涌过来闸机每分钟放行的批次是固定的后面的人只能排队等下一批。如果你需要的是“用户拖拽滑块调整数值”这种高频场景防抖就不太合适——因为防抖会让请求一直往后拖延用户拖到底了请求还没发出去。这时用节流保证每200毫秒最多发一次请求体验会好很多。React里用lodash.throttle很常见但要注意它会引入额外依赖而且如果直接包在组件内层函数上要记得用useRef或useMemo保持函数引用稳定否则每次渲染都会重新创建一个节流函数根本起不到节流作用。3.3 防抖和节流都解决不了竞态条件这是全文最想强调的一点。防抖和节流解决的是“请求数量过多”的问题它们把N次请求降为1次或少数几次。但剩下的那“少数几次”请求依然是异步的返回顺序依然不可控。举例来说用户在输入框里输入iPhone 15停顿300毫秒后发了一次请求。但用户马上又删掉了两个字变成iPhone又停顿300毫秒发了第二次请求。现在第二次请求可能因为服务器处理慢或网络抖动比第一次晚返回。于是页面先展示iPhone 15的搜索结果几秒后被迟到的iPhone结果覆盖。用户明明搜的是iPhone 15页面却显示iPhone的结果。这时候你会说“我明明加了防抖搜索还是闪烁”。没错防抖不是用来解决闪烁的它只负责减少请求次数。真正解决闪烁的是请求竞态管理。3.4 防抖节流选型与参数调整的实践建议关于delay值怎么定我踩过一段时间的坑最后的结论是不要让后端开发拍脑袋也不要照抄别人的300ms要去看真实用户的操作数据。比如电商搜索框里用户平均输入间隔是200毫秒上下那300毫秒就是合理区间。而如果是代码编辑器的自动补全输入速度可以很快但用户期望的反馈也更快那150毫秒可能是更好的平衡点。节流和防抖的选择上我的经验是搜索框、自动补全、筛选器这类“等结果出来再展示”的场景优先防抖。地图缩放、拖拽滑块、滚动加载这类“持续操作、需要反馈”的场景优先节流。如果拿不准还有一个选择先防抖再节流。例如防抖200毫秒后再节流500毫秒保证最多每500毫秒一次请求不过这样组合复杂度也上来了非必要不用。4. 主流方案实操请求序号、AbortController 与竞态管理4.1 方案一请求序号标记法最朴素但依然有效的做法是给每次发出的请求一个递增的序号只有最新的序号返回的数据才能更新状态。function SearchPage() { const [results, setResults] React.useState([]); const requestSeqRef React.useRef(0); React.useEffect(() { if (!keyword.trim()) { requestSeqRef.current 1; setResults([]); return; } const currentSeq requestSeqRef.current 1; requestSeqRef.current currentSeq; fetch(/api/search?q${keyword}) .then(res res.json()) .then(data { if (requestSeqRef.current currentSeq) { setResults(data.items); } }); }, [keyword]); // ... }思路拆解每次请求发出前从全局的requestSeqRef里取一个currentSeq并把它设为最新的序号值。当请求返回时检查当前requestSeqRef.current是否还等于currentSeq。如果不相等说明在这个请求发出之后又发了一个新请求那么旧请求的结果就应该直接丢弃。这个方案的优点是理解简单、几乎不依赖任何新API兼容性极好。缺点是它只处理了“结果过期”并没有真正取消底层网络请求。也就是说那些被丢弃结果的请求依然占用了带宽和服务器资源。在请求数量已经过大的极端场景下服务器还是会被打爆。4.2 方案二AbortController 真正取消请求现代浏览器提供了AbortController可以通过AbortSignal中断一个正在进行的fetch请求。一旦中断fetch会抛出AbortError我们可以捕获这个错误并忽略它。function SearchPage() { const [results, setResults] React.useState([]); React.useEffect(() { if (!keyword.trim()) { setResults([]); return; } const controller new AbortController(); fetch(/api/search?q${keyword}, { signal: controller.signal }) .then(res res.json()) .then(data setResults(data.items)) .catch(err { if (err.name AbortError) return; console.error(请求失败, err); }); return () { controller.abort(); }; }, [keyword]); // ... }这个方案利用ReactuseEffect的清理机制当keyword变化或组件卸载时上一次useEffect的清理函数会被执行从而abort()掉上一个请求。新的请求完全不受影响。这个方案最大的价值在于它不仅解决了“UI状态被过期结果覆盖”的问题还真正省掉了网络层面的资源占用。请求已经被取消自然不会浪费带宽也不会让服务器继续执行无意义的查询。实际测试中快速切换搜索词时网络面板里能明显看到状态为canceled的请求。需要注意的坑是AbortController只能中断在途的请求如果请求已经返回完成abort()自然没有意义另外某些代理、CDN或Mock工具可能不完全遵循abort语义会导致AbortError没有如期抛出这时你需要在后端或Mock层做适配。4.3 方案三社区库怎么办如果你不想手写这些机制社区里有现成的解决方案。比较有代表性的是swr的mutate和useSWR自带的竞态保护以及TanStack Query前身是react-query中基于请求key的自动过期丢弃机制。TanStack Query的思路是每个查询都有一个queryKey比如[search, keyword]。当keyword变化时新查询会自动创建旧查询的返回结果不会再更新到新查询状态里。它从框架层面天然规避了竞态条件不用你手动维护序号或控制器。import { useQuery } from tanstack/react-query; function SearchPage() { const [keyword, setKeyword] React.useState(); const { data, isPending } useQuery({ queryKey: [search, keyword], queryFn: () fetch(/api/search?q${keyword}).then(res res.json()), enabled: keyword.trim().length 0, }); return ( div input value{keyword} onChange{e setKeyword(e.target.value)} / {isPending ? div加载中.../div : null} ul {data?.items?.map(item li key{item.id}{item.name}/li)} /ul /div ); }从2026年这个时间点往回看我的看法是如果你的项目已经引入了TanStack Query或swr直接用它们的竞态保护能力没必要重复造轮子。但如果你只是一个小页面不想为了一个搜索框引入完整的数据请求库那么“防抖AbortController”的组合就是性价比最高的方案。5. 完整落地React搜索框“防抖竞态管理”组合方案5.1 需求目标与拆分我们要设计一个商品搜索页要求有较好的用户体验用户停顿200毫秒再触发请求避免无效请求轰炸后端。如果用户再次输入上一次未完成的请求要取消防止过期响应覆盖最新结果。搜索框为空时清空结果不发请求。用户能看到基本的加载状态但加载状态不能被过期请求干扰。请求失败时给出明确提示不影响下一次搜索。为了代码可维护我把逻辑拆成两个hook一个负责防抖一个负责请求竞态管理。这样在别的组件里也能复用小逻辑。5.2 代码实现防抖Hookimport React from react; function useDebouncedValue(value, delay 200) { const [debouncedValue, setDebouncedValue] React.useState(value); React.useEffect(() { const timer setTimeout(() { setDebouncedValue(value); }, delay); return () { clearTimeout(timer); }; }, [value, delay]); return debouncedValue; }这个hook我已经在好几个项目里反复用了没出过什么问题。唯一要留意的是如果value初始值非空且需要立即显示可能要考虑初始值的问题——不过搜索框场景里初始值基本都是空字符串不用额外处理。5.3 代码实现请求竞态管理Hookfunction useRaceSafeFetch() { const controllerRef React.useRef(null); const fetchRaceSafe React.useCallback((url, options {}) { // 取消上一次未完成的请求 if (controllerRef.current) { controllerRef.current.abort(); } const controller new AbortController(); controllerRef.current controller; return fetch(url, { ...options, signal: controller.signal, }).catch(err { // 如果是因为取消而失败直接抛出一个特殊标记方便调用方忽略 if (err.name AbortError) { throw new DOMException(Aborted, AbortError); } throw err; }); }, []); const abort React.useCallback(() { if (controllerRef.current) { controllerRef.current.abort(); } }, []); React.useEffect(() { return () { if (controllerRef.current) { controllerRef.current.abort(); } }; }, []); return { fetchRaceSafe, abort }; }这里的核心思想是fetchRaceSafe被调用时先abort掉上一次请求再发出新请求。因为旧请求已经被取消它的then回调根本不会正常执行也就不会污染状态。对于组件卸载时发起的遗留请求abort也能在清理阶段被调用避免组件卸载后setState造成的内存泄漏警告。5.4 组件层组合把防抖和竞态管理组合进搜索组件function SearchPage() { const [keyword, setKeyword] React.useState(); const [results, setResults] React.useState([]); const [loading, setLoading] React.useState(false); const [error, setError] React.useState(); const debouncedKeyword useDebouncedValue(keyword, 200); const { fetchRaceSafe, abort } useRaceSafeFetch(); React.useEffect(() { const trimmed debouncedKeyword.trim(); if (!trimmed) { setResults([]); setLoading(false); setError(); return; } setLoading(true); setError(); fetchRaceSafe(/api/search?q${encodeURIComponent(trimmed)}) .then(res { if (!res.ok) throw new Error(HTTP ${res.status}); return res.json(); }) .then(data { setResults(data.items); setLoading(false); }) .catch(err { // 取消请求导致的异常不视为错误 if (err.name AbortError) return; setError(搜索失败请稍后重试); setResults([]); setLoading(false); }); return () { abort(); }; }, [debouncedKeyword, fetchRaceSafe, abort]); return ( div style{{ maxWidth: 600, margin: 0 auto }} input value{keyword} onChange{e setKeyword(e.target.value)} placeholder搜索商品 style{{ width: 100%, padding: 12px 16px, fontSize: 16, border: 1px solid #ddd, borderRadius: 6, }} / {loading ? div正在搜索.../div : null} {error ? div style{{ color: #c00 }}{error}/div : null} ul {results.map(item ( li key{item.id}{item.name}/li ))} /ul /div ); }注意encodeURIComponent(trimmed)这一步直接把用户输入拼进URL是一个高危操作。如果用户输入了空格、中文或特殊符号URL会异常甚至崩溃。这一步是很多新手容易忽略的细节。还要注意在setLoading(true)之前我们并没有在useEffect开头abort上一次请求。因为fetchRaceSafe内部已经实现了“先取消再请求”所以这里的abort()主要在清理阶段使用确保组件卸载时不会再有在途请求。双层保险稳。5.5 为什么这个方案能同时解决“闪烁”和“性能问题”简单梳理一下执行链路用户输入iPhone 15每敲一个字符keyword变化一次。useDebouncedValue让debouncedKeyword只有在用户停顿200毫秒后才变化。所以用户在快速输入时debouncedKeyword保持不变不会触发请求。停顿200毫秒后debouncedKeyword变成iPhone 15触发fetchRaceSafe。此时用户又删掉一个字符输入变为iPhone停顿200毫秒后debouncedKeyword变更为iPhone。fetchRaceSafe执行时先把iPhone 15的请求abort掉再发出iPhone的请求。因为iPhone 15的请求已经被取消它不可能再回来更新结果页面也就不会“闪烁”。在极端情况下如果第一个请求已经返回完成abort无效果但此时数据其实已经渲染过了等第二个请求返回后再更新即可也不存在乱序覆盖的问题因为在途的旧请求均已取消。这套方案的边界情况我也专门想过如果用户没有停顿而是一直以较快节奏输入debouncedKeyword始终不变直到停止输入后才更新。所以并不会出现“上一个防抖请求还没完成新防抖请求又发出”的状态。真正需要竞态保护的核心场景依然是用户停顿后输入发生变化、然后再次停顿的那一瞬间。6. 从搜索场景走向通用的React数据获取模式6.1 竞态条件的通用解法你现在已经掌握了三种核心武器请求序号、AbortController、竞态安全Hook。这三者的能力层次是不同的方案解决UI覆盖取消网络请求依赖适用场景请求序号标记法是否无任何浏览器环境简单请求AbortController是是浏览器原生支持现代浏览器常规react项目社区库TanStack Query等是是额外依赖项目已使用该库复杂查询场景如果你的浏览器环境比较特殊比如某些老旧WebViewAbortController可能不可用。此时请求序号法是更稳妥的兜底方案。我的建议是优先用AbortController方案同时把请求序号方案作为降级策略写进注释方便后人维护。6.2 服务端状态与客户端状态的边界2026年了React数据获取领域还有一个根本性问题需要想清楚哪些数据属于服务端状态哪些属于客户端状态搜索结果、订单详情、用户资料这些从服务端读取的数据本质上是服务端状态的“镜像”。它们的生命周期应该绑定在数据源上而不是绑定在组件挂载上。这就是TanStack Query和swr存在的意义提供缓存、过期、重试、竞态保护等机制把服务端状态和UI状态解耦。而搜索结果旁边的“是否正在加载”“是否有错误”这些是纯UI状态可以使用useState或useReducer管理。我见过不少项目把服务端数据放进全局Store导致每次搜索都要手动更新全局状态最终竞态问题被放大到全局范围排查起来异常痛苦。这个习惯非常不可取。数据获取的竞态边界应该是越靠近请求发生处越好管理。6.3 React 19和未来方向对这套方案的影响聊到2026年就不能不提React 19带来的变化。React团队在并发渲染和数据获取上做了大量工作比如useHook、Server Functions等。这些新特性让部分数据获取可以在服务端完成也能减少一部分客户端竞态问题。但搜索框这种高频交互场景依然绕不开客户端异步竞态管理——毕竟用户触发请求的频率远高于服务端渲染可以响应的频率。我个人的判断是React 19的并发能力会让UI渲染层面的竞态更少但“用户输入后发请求”这件事并不会有根本性改变。AbortController、防抖节流这些基本功该掌握还是得掌握而且会越来越重要因为产品对搜索响应速度和准确性的要求只会越来越高。6.4 你真的需要防抖节流加竞态管理吗最后聊一个现实问题是不是所有搜索框都必须上这套组合拳如果搜索功能是在一个低频后台管理系统里用户每次输入完都会停顿很久那直接发请求也不会造成太大压力。此时加防抖的价值不大因为用户天然是“防抖”的。但如果是一个面向C端的搜索框用户输入速度快、并发量大那就必须防抖竞态管理一起上。我之前做过一个客服工作台的历史记录搜索框只加了200毫秒防抖没有加竞态管理。结果就是客服快速切换搜索条件时页面偶发显示旧数据。排查了半天才发现防抖把请求数量压下来了但没有解决两个请求先后返回的问题。后来补上AbortController问题彻底消失。这段经历让我确立了“防抖负责减少请求量竞态管理负责保证结果正确性”的判断标准两者各司其职缺一不可。7. 搜索框的隐藏细节键盘事件、中文输入法、网络错误和空状态7.1 为什么中文输入法会让防抖失效如果你做的是中文搜索那有个坑几乎人人都会踩当用户在拼音输入法里输入yixia时onChange事件可能在拼音组合阶段就触发了。也就是说用户还没确认选词keyword已经变成了拼音串比如“yixia”然后触发请求后端返回一堆拼音相关的搜索结果体验非常差。React官方文档里也提到过处理组合输入需要使用onCompositionStart和onCompositionEnd事件。一个常见的做法是在onCompositionEnd之后才更新keyword避免拼音组合过程中的虚假输入值。更简单的方式是使用useMemo对keyword做处理在组合状态时不触发请求const [keyword, setKeyword] React.useState(); const isComposingRef React.useRef(false); const handleChange e { if (!isComposingRef.current) { setKeyword(e.target.value); } }; const handleCompositionEnd e { isComposingRef.current false; setKeyword(e.target.value); };这样用户在拼音输入过程中搜索框不会发请求只有选词结束后才触发更新体验好很多也减少了无意义的请求。7.2 网络错误、超时和重试策略在搜索场景里网络错误的处理有一个小原则不要让用户看完错误后才发现输入的内容也没了。我在代码里会在catch里保留keyword和results的旧值用户可以直接重试而不是被迫重新输入。超时设置同样重要。fetch本身没有默认超时机制如果不处理一些慢请求可能让用户一直看着loading。我用AbortController结合setTimeout来实现超时const controller new AbortController(); const timeoutId setTimeout(() controller.abort(), 8000); fetch(url, { signal: controller.signal }) .finally(() clearTimeout(timeoutId));8秒是我常用的阈值具体根据你后端接口的P99耗时来定。如果接口普遍需要5秒那超时设成10秒更合理不要让超时比正常请求还快。7.3 空状态和loading展示的正确姿势搜索结果的加载状态很容易被忽略。我曾经见过一个搜索页搜索过程中什么都没显示请求结束后突然冒出20行结果用户会有一种“卡顿后突然爆出”的割裂感。实际上更合理的做法是首次加载时显示一个明显的骨架屏或者“搜索中”文案。后续关键词变化时如果不是完全清空关键词可以保留旧结果同时在页面顶部提示“正在更新”。当结果为空时显示“没有找到相关商品”而不是空白页。保留旧结果显示“正在更新”这个细节很关键——它既避免了闪烁又不会让用户觉得页面失灵。这个策略在处理竞态时也有心理层面的缓冲作用。8. 如何系统化排查搜索“闪烁”问题8.1 建立复现路径排查闪烁问题的第一步是稳定复现。我自己常用的方法是在网络面板里把Network Throttling调成“Slow 3G”再快速输入搜索词。如果问题出现是很随机的可以尝试输入一个很长的关键词然后立刻删光再输入另一个关键词。这会让新旧请求之间的竞争时间窗口拉大闪烁现象更容易暴露。在React项目里还有一个高效手段在组件里临时打印日志记录keyword、debouncedKeyword、请求序号、拿到结果的时刻。不要只盯着UI看数据流动的过程才能暴露竞态源头。8.2 常见竞态问题速查表现象可能原因排查方向老数据覆盖新数据防抖之后仍有多个请求在途无竞态管理请求序号或AbortController输入停止后loading一直不消失某个请求被取消但loading未复位检查catch处理、finally逻辑快速切Tab后内容串了Tab对应请求未隔离为每个Tab维护独立查询状态拼音输入时搜索乱跳组合输入未处理监听composition事件组件卸载后报警告卸载后请求未清理useEffect清理函数中abort结果正确但请求过多防抖参数太大或太小调整delay或在防抖前先节流一次8.3 我常用的调试小工具调试竞态问题时我一般会临时给请求URL加一个_tDate.now()参数这样能肉眼看到请求发出的顺序配合Network面板的“请求时间线”可以非常直观地看出哪个请求先发、哪个后回。加这个参数在生产环境要记得移除否则会破坏缓存。如果你是纯前端mock数据的比如用mswMock Service Worker注意它的请求拦截也需要支持AbortController。有些mock实现并不传递signal导致abort只是“表面上取消”实际请求仍在mock层里“假装完成”。遇到这种情况可以暂时用请求序号法验证逻辑再看mock层是否需要升级。9. 说在最后竞态管理是一次“认知升级”我做React开发这么多年搜索框是一个看起来简单、实际考验基本功的组件。很多面试官爱问“如何避免搜索竞态”其实考察的不仅是你知不知道AbortController更是你能否区分请求频率优化和请求结果正确性这两个不同维度的问题。防抖、节流属于前者它们让请求数量减少到合理范围请求序号、AbortController、数据请求库的缓存机制属于后者它们确保最终展示的那个结果一定对应当前用户想要的那个关键字。我个人在实际项目中的体会是不要在“什么时候用防抖”上花太多时间纠结真正值得花时间的是把“过期响应”这四个字刻进潜意识里。只要你看到“异步请求更新状态”这个模式第一反应就应该是“这里会不会有过期响应”。一旦形成这种条件反射你写出来的数据获取逻辑会自动带上竞态保护的意识搜索框的闪烁问题也会从“灵异事件”变成“可解释、可复现、可修复的常规Bug”。最后再分享一个小技巧架构上千万别把“取消请求”的逻辑散落在各个组件里最好抽象成统一的数据获取封装层。这样等React未来继续演进或者你需要切换数据请求库时改动范围可以控制在一个文件内。搜索框虽然小但它背后暴露的是你整个团队对异步数据流的掌控力。先把这里做好再谈更复杂的状态管理会顺畅得多。