首页 / 资讯中心 / 文章详情

React异步请求取消与竞态保护:react-hooks-async 核心原理与实战

React异步请求取消与竞态保护:react-hooks-async 核心原理与实战 ★ FEATURED ARTICLE
简介react-hooks-async是一套面向React开发者的自定义Hooks库用于解决JavaScript Promise无法直接中止或取消的问题。它基于AbortController实现异步任务的可终止性并提供useAsyncTask、useAsyncRun等核心Hook以及组合全部、竞态、顺序执行等高级用法可满足请求取消、竞态控制、进度展示等常见前端场景。资源包含完整源码、类型声明、示例工程与测试用例共92个文件以JS、TS、TSX源码为主另有JSON配置、HTML演示、Markdown文档和WASM示例等压缩包仅295KB轻量易读。已有315人学习。通过该资源可快速掌握React Hooks异步处理模式也可为封装Axios、WASM等异步任务提供参考模板。目录结构涵盖dist、src、examples、__tests__等模块源码与示例对应清晰便于按需阅读和二次开发。1. 从一次“点太快就白屏”的故障说起React 异步函数到底缺什么某次给内部后台加列表页用户反馈“把查询按钮连点几下页面偶尔卡死数据还是旧的那份”。查了半天问题不是接口慢而是没有做“请求取消”。前一个慢请求回来时把后一个请求的 setState 覆盖了网络和时间全部错乱。这种问题在 React 里并不稀有因为useEffect天生的执行时机和异步回调的完成时机完全对不上而官方 hooks 并没有直接提供“可终止、可组合”的异步能力。react-hooks-async就是补这个缺的那批自定义 hooks它围绕AbortController把每次异步请求变成“能取消、能组合、能安全 setState”的受控单元专治搜索列表、弹窗连续提交、图表频繁刷新这类容易竞态的场景。适合被异步状态折磨过并且想在 React 里拿回“请求生命周期控制权”的开发者。2. 项目定位与 API 概览三个核心 hook 解决响应式异步的三类问题2.1 三个核心 hooksuseAsync / useAsyncCallback / useAsyncAbortable这套库本质是“把异步函数变成 React 状态机”的封装集合。先看它的定位当你决定不使用 Redux Toolkit Query、React Query 这类重型数据方案又不想每次写五六个useState去管理pending / data / error时它就是中间层。核心导出的 hooks 有三个职责边界非常清晰。Hook 名称核心职责典型返回结构适用场景useAsync组件渲染后自动发起异步请求{ data, loading, error, abort }页面初始加载、依赖 props 变化自动重新请求useAsyncCallback由事件触发异步请求{ data, loading, error, execute, abort }点击按钮、提交表单、分页切换useAsyncAbortable每次执行自动生成新的AbortController{ data, loading, error, execute, abort, signal }渲染时自动持有可取消信号依赖变化即取消旧请求先说一个容易混淆的点useAsync和useAsyncAbortable看起来都是“自动请求”但前者偏向于“只关心数据最终回来”后者把每一次请求的signal暴露给你让你可以把取消信号透传给子组件、fetch、甚至 axios 的signal配置。我通常把useAsyncAbortable理解为“需要更精细控制取消时机”的版本需要手动把 signal 传给异步函数useAsync适合那种“请求发出去就不管setState 时给我安全保护”的默认场景。从使用者的角度useAsync的参数一般是这样的const { data, loading, error, abort } useAsync( async (signal) { const res await fetch(/api/list, { signal }); return res.json(); }, [page] // 依赖数组page 变化时自动重新执行 );逻辑说明useAsync接收两个参数第一个是返回 Promise 的异步函数第二个是依赖数组依赖变化时 hook 会在内部触发重新执行。上面代码里把signal传给fetch当依赖变化触发下一次执行时前一次请求的signal会被abort()浏览器层面会直接取消这次网络请求。这就是“可终止性”落在代码里的第一步。参数说明signal类型是AbortSignal | null首次渲染时为 null第二次开始才有值deps数组参与内部useEffect的依赖收集必须传引用稳定或者基础类型的值否则会陷入重复请求的死循环。我用的时候会刻意把signal放在第一位参数保证它在promiseFn内部一定可用。useAsyncCallback则不接受依赖数组它把触发权交给你const { execute, loading, data } useAsyncCallback( async (userId, { signal }) { return await api.getUser(userId, { signal }); } ); // 事件里手动触发 button onClick{() execute(userId)}查询/button逻辑说明execute返回 Promise可以在 await 后拿到结果内部同样维护 loading 状态连续点击时前一次执行会被标记为 stale最后写 state 时会做“是否还是最新一次”的判断避免旧请求覆盖新数据。这两个 hook 的关系理解之后第三层useAsyncAbortable就很好说它把signal从配置项变成返回项你可以在父组件里把 signal 往下传给子组件的fetch甚至给axios.get(url, { signal })。这种能力在“不同子组件各自发请求但需要由父组件统一取消”的场景里尤其有用。2.2 源码包结构与模块边界一个 hooks 库该怎么组织拿到源码包先看目录熟悉结构比急着跑 demo 更重要。react-hooks-async 的源码组织方式是典型的“hooks 单一职责拆包”src/ ├── useAsync.ts # 自动感知依赖的异步状态 hook ├── useAsyncCallback.ts # 事件驱动的异步状态 hook ├── useAsyncAbortable.ts # 暴露 signal 的可终止 hook ├── useAbortController.ts # AbortController 生命周期管理内部 hook ├── createAbortableFn.ts # 核心把 promiseFn 与 signal 绑定的工具 ├── useTrackedPromise.ts # 内部竞态保护与状态更新分发 ├── types.ts # PromiseFn / AsyncResult 等类型 ├── index.ts # 统一导出 └── __tests__/ ├── useAsync.test.tsx └── useAsyncAbortable.test.tsx模块职责可以这么理解useAbortController.ts管“控制器什么时候创建、什么时候销毁”createAbortableFn.ts管“把用户的异步函数变成能接收 signal 的形式”useTrackedPromise.ts管“每次执行生成一个 idsetState 前先判断 id 是否还是最新的”这就是整个库最关键的竞态保护层。很多开发者自己写自定义 hook 时习惯把“状态管理、请求触发、取消逻辑”全混在同一个函数里导致后来加一个取消功能就要改三处。这个库的拆分方式是状态管理在useTrackedPromise控制器生命周期在useAbortController两者通过组合方式叠加成useAsyncAbortable。任何一个 hook 都可以独立测试这是它可组合性的架构基础。我自己的感受是读这个项目时不要只把它当“工具库”它更值得学习的是“如何把一个通用逻辑切成多个可注入的小 hook”。比如useAbortController你完全可以单独拿出来放在自己的项目里解决“组件卸载时自动 abort 所有未完成请求”这件事。2.3 最小可运行示例带取消的搜索联想请求先用最典型的“搜索框输入 → 请求 → 展示”来跑通。这个例子里搜索关键词每次变化都会触发新的请求但用户输入很快时旧请求必须立即取消否则结果会错乱。import { useAsyncAbortable } from react-hooks-async; function SearchBox() { const [keyword, setKeyword] useState(); const { data, loading, execute, abort } useAsyncAbortable( async (kw, { signal }) { const res await fetch(/api/search?q${kw}, { signal }); return res.json(); } ); return ( div input value{keyword} onChange{(e) { setKeyword(e.target.value); if (e.target.value) { execute(e.target.value); } else { abort(); } }} / {loading spanloading.../span} ul {(data ?? []).map((item) ( li key{item.id}{item.name}/li ))} /ul /div ); }逻辑说明这里用useAsyncAbortable而不是useAsync是因为我们需要在 input 的onChange里主动控制“要不要取消”。execute接收一个参数kw它会把这个参数透传给异步函数的第一个形参第二个形参自动获得当前这次执行的{ signal }。当用户清空输入框时调用abort()正在进行的请求在 fetch 层面被取消不会再有 setState 行为。参数说明异步函数签名里的{ signal }不是随意写的它是useAsyncAbortable内部createAbortableFn按约定注入的。只要你的 promiseFn 声明了两个参数第二个参数解构出来的就是{ signal }对象。这里的execute返回一个 Promise 对象可以在事件中 await 它并处理返回值。每次调用execute时hook 内部都会先abort()上一次请求再创建一个新的AbortController确保同一时间只有一个请求处于活动状态。跑通这个 demo 后你就掌握了这套 hooks 的核心用法需要自动触发用useAsync需要手动触发用useAsyncCallback需要“手动触发 主动取消 拿到 signal”用useAsyncAbortable。剩下的问题就是它内部到底怎么做到“旧请求被取消后不更新状态”的。3. 核心实现拆解一个请求从触发到被取消的完整链路3.1 AbortController 如何进入 React从创建到清理的完整生命周期要理解这个库必须先看懂AbortController在 React 组件里的生命周期组件挂载时不创建第一次执行异步函数时创建下一次执行或组件卸载时销毁。看起来简单但把“销毁”和“setState 不执行”绑在一起才是精髓。内部useAbortController的伪代码逻辑长这样function useAbortController() { const controllerRef useRefAbortController | null(null); const getAbortController useCallback(() { if (!controllerRef.current) { controllerRef.current new AbortController(); } return controllerRef.current; }, []); const abortController useCallback(() { if (controllerRef.current) { controllerRef.current.abort(); controllerRef.current null; } }, []); useEffect(() { return () { if (controllerRef.current) { controllerRef.current.abort(); } }; }, []); return { getSignal: () getAbortController().signal, abortController, }; }逻辑说明getAbortController采用懒创建策略第一次调用时才实例化AbortController避免组件渲染阶段创建不必要的对象abortController在 abort 后把controllerRef.current置为 null这样下一次调用会重新创建新的控制器。组件卸载时的useEffectcleanup 是关键防线只要组件卸载时还有未完成的请求就立刻abort()从源头阻止后续 setState。参数说明getSignal返回当前的AbortSignal这个信号需要传给 fetch 或 axiosabortController是主动取消入口。这里有一个值得注意的设计abort 之后引用重置为 null意味这个 controller 不会被复用。AbortController的 abort 操作是终态的一旦执行signal 永远处于 aborted 状态后续 fetch 遇到它都会直接拒绝所以“abort 后重建”比“abort 后复用”更安全。3.2 可组合性多个异步请求如何串联成一个状态可组合性是这个库不同于普通“请求 hooks”的核心卖点。它允许你传入一个“异步函数”这个函数内部可以自由组合多个 Promise比如先拿配置、再拿用户数据、最后拿列表它只会给你一次状态变化。const { data, loading } useAsync(async (signal) { const config await getConfig(signal); const user await getUser(config.userId, signal); const list await getList(user.permission, signal); return { user, list }; }, []);逻辑说明getConfig、getUser、getList三个请求按顺序执行但它们的 signal 都来自同一个AbortController。这意味着外部调用一次abort()三个阶段的请求全部被取消不需要每个阶段单独维护取消逻辑。这就是“可组合性”的一种体现——不是把多个 hook 拼在一起而是把多次 Promise 组合进一个 hook 的状态机里。如果换成Promise.all并发组合一样成立const { data } useAsync(async (signal) { const [menu, user] await Promise.all([ getMenu(signal), getUserInfo(signal), ]); return { menu, user }; }, []);这里更适合解释组合与取消的关系Promise.all里任何一个 fetch 因为 signal 被 abort整个组合 Promise 都会 rejecthook 内部捕获到后会把它当作“主动取消”而不是“错误”因此不会触发error状态更新。参数说明signal 在组合场景必须逐层透传这也是最容易遗漏的地方。如果getMenu内部只用fetch(/menu)而拒绝接受 signal那么取消信号就无法透传到 fetch 层abort 后网络请求实际仍在继续。我在开发时有个固定习惯给项目里的统一请求封装函数强制第二个参数接收signal拿不到就报 lint 警告。3.3 竞态保护id 标记法如何避免旧请求覆盖新状态整个库状态更新的安全夹层在useTrackedPromise负责“保证只有最新一次执行的结果能 setState”。这个机制很多人会误以为是“取消后不再 setState”其实更准确的说法是“被标记为过期的那次执行即使它回调了也不碰 state”。function useTrackedPromise(asyncFn) { const currentRef useRef(0); const trackedRun useCallback( async (...args) { const runId currentRef.current; setLoading(true); try { const result await asyncFn(...args); if (runId currentRef.current) { setData(result); } } catch (e) { if (runId currentRef.current) { setError(e); } } finally { if (runId currentRef.current) { setLoading(false); } } return result; }, [asyncFn] ); return { trackedRun, setData, setError }; }逻辑说明每次调用trackedRun时自增一个runId并存入 ref。异步函数完成后先判断runId是否等于 ref 里最新的值只有相等才能 setState。如果用户在第一次请求未返回时又触发了第二次第二次的runId会成为最新值第一次请求回来时runId ! currentRef.current直接跳过 setState。这就是“stale 丢弃”机制它不依赖 AbortController 是否生效即使请求无法真正取消比如用了不可取消的定时器、Socket也仍然能保护 React 状态不被旧数据覆盖。参数说明asyncFn是用户传入的 promise 函数trackedRun执行时透传所有参数。这段代码没有处理 abort 信号的区分所以useAsyncAbortable会在它外面再包一层先获取新的 signal再把 signal 传给asyncFn并组合 AbortController 的 abort 事件确保“取消后即使 Promise resolve 了也无法通过 id 校验”。到这里可终止性和可组合性的实现路径基本清晰useAbortController负责信号的创建与销毁createAbortableFn把用户异步函数变成能接收 signal 的函数useTrackedPromise负责竞态保护。三者组合才有了对外看起来简洁的useAsync系列 hooks。4. 把项目跑起来从空目录到可取消请求的完整 Demo4.1 初始化工程并安装依赖这个库不依赖任何运行时框架的特殊能力所以用 Vite 建一个标准 React TypeScript 工程就够了。命令如下npm create vitelatest async-demo -- --template react-ts cd async-demo npm install npm install react-hooks-async npm run dev命令说明--template react-ts会生成 React TypeScript 脚手架安装react-hooks-async时不需要额外配置它内部依赖 react 的 hooks API不引入第三方运行时。跑完npm run dev后浏览器打开终端提示的本地地址就能开始改代码了。如果网络源安装较慢我一般会切换镜像源重试这是唯一一个可能出现的环境问题。安装完成后打开src/App.tsx把默认内容整个替换成上面 2.3 节里的搜索框示例保存后页面应该能正常渲染输入框。4.2 用假的慢接口验证取消行为真实环境很难控制接口返回速度我习惯用 Promise setTimeout 模拟一个 3 秒后返回的“慢请求”专门验证取消是否生效。import { useAsyncAbortable } from react-hooks-async; const fakeRequest (keyword: string, { signal }: { signal?: AbortSignal }) new Promise((resolve, reject) { const timer setTimeout(() { resolve([ { id: 1, name: ${keyword}-结果1 }, { id: 2, name: ${keyword}-结果2 }, ]); }, 3000); // 关键监听 signal 的 abort 事件手动清除定时器并 reject signal?.addEventListener(abort, () { clearTimeout(timer); reject(new DOMException(Aborted, AbortError)); }); }); function App() { const { data, loading, execute, abort } useAsyncAbortable( fakeRequest, [] ); return ( div input placeholder输入关键词触发请求 onChange{(e) e.target.value execute(e.target.value)} / button onClick{abort}取消/button {loading p请求中3 秒后返回.../p} pre{JSON.stringify(data, null, 2)}/pre /div ); }逻辑说明fakeRequest的核心是监听signal的abort事件如果 signal 被 abort就clearTimeout并且 reject 一个AbortError。这一步很重要因为 Promise 本身没有取消机制取消只能靠“监听 signal 后手动执行清理动作”。如果省略这个监听就算 signal 被 abortsetTimeout3 秒后依然会 resolve数据仍会进入后续流程。参数说明fakeRequest的函数签名和useAsyncAbortable要求的异步函数格式严格匹配第一个参数是业务参数第二个参数是{ signal }对象。执行execute(hello)时hello 会成为第一个参数传递进去。返回的数据结构是数组所以渲染时可以直接用JSON.stringify查看。4.3 浏览器里验证三个关键行为跑起来之后重点不是看界面而是验证三个关键行为。第一输入关键词后立即点击取消按钮3 秒后控制台不会出现任何 resolve 日志说明 abort 事件确实切断了定时器的后续流程。第二连续输入两个关键词比如先输入 a 再输入 b3 秒后最终显示的数据中name里的关键词必须是 b不能出现 a 的结果这验证的就是竞态保护。第三在请求未返回时直接刷新页面观察控制台不能出现 React 的 “Cant perform a React state update on an unmounted component” 警告。验证过程中可以打开浏览器开发者工具的 Network 面板。如果请求是通过 fetch 发出的abort 后对应请求会在网络面板里显示为 canceled这是一种非常直观的反馈。如果接口是用 XMLHttpRequest 封装的abort 后会触发onabort事件同样能观测到。我一般会在验证完这三个行为后再故意把代码里fakeRequest的signal.addEventListener(abort)注释掉跑一遍对比——这时即使取消按钮被点击请求还是会把结果 set 进来但因为有useTrackedPromise的 id 竞态保护不会出现报错只是数据会“迟到”。这个实验能帮你理解两个保护层的区别abort 负责停止底层异步任务id 校验负责阻断过期回调的 setState两者缺一不可。5. 踩坑与排查接入 react-hooks-async 后最容易翻车的 5 个细节5.1 现象点击取消后控制台仍然有“在卸载组件上调用 setState”的警告原因useAsync内部虽然有竞态保护但它只能保证“当前最新一次执行”的 setState 生效如果组件卸载时机恰好发生在请求 pending 阶段而异步操作没有被 signal 监听比如用户传入的是一个不会被取消的 Promise那么卸载后 setState 依然会被调用。这个警告不一定会崩溃但会影响排查真正问题的视线。解决使用useAsyncAbortable并在异步函数第一行通过signal.addEventListener(abort, () { throw ... })或者像 4.2 节示例里那样手动清理资源。从根本上保证“请求 pending 期间组件卸载时 abort 会自动触发并中断 Promise”。如果你的异步操作无法被 AbortSignal 监听那就自己维护一个mountedRef在 useEffect cleanup 里置为 falsesetState 前先判断。5.2 现象abort 后再次 execute请求始终处于已取消状态永远不发出原因AbortController.abort()是终态操作调用后它的signal.aborted永远为 true。如果代码里复用了同一个 controller 实例并再次把它传给 fetchfetch 会直接以 “Aborted” 结束。有的封装把 controller 存在 ref 里且未在 abort 后置空就会出现“取消一次后整个页面再也无法请求”的问题。解决检查你写的 controller 是否在 abort 后被置为 null。参考 3.1 节里的abortController实现abort 后必须controllerRef.current null下一次请求时重新 new 一个。这也是这个库在useAbortController里刻意做“懒创建 销毁”的原因确保每次请求都拿到全新的 signal。5.3 现象Promise 组合请求中只取消了最外层子请求还在继续原因useAsync把 signal 传给异步函数后内部如果调用了getConfig()、getUser()但这两个函数内部没有接收 signal则取消信号被“吞掉”。组件看起来已经没有 loading 了网络面板里那个子请求却还在 pending浪费连接数。解决给项目所有请求封装函数增加第二个参数signal?: AbortSignal并在 fetch、axios 里都挂上。我自己的工程标准是所有 api 方法签名统一为(params, signal) PromiseT哪怕是 GET 请求也不例外。这样无论嵌套多深signal 都能透过参数逐层传递到最底层的网络层。5.4 现象React StrictMode 环境下请求发了两遍原因React 18 起 StrictMode 在开发环境下会刻意让useEffect执行两遍挂载 → 清理 → 挂载目的是帮你暴露忘记清理的副作用。useAsync内部依赖useEffect触发请求因此也会在挂载阶段发两次请求。第一次的效果被清理时 abort第二次正常接收结果属于“预期行为”但如果后端没有幂等性可能导致重复数据插入。解决如果后端接口不是幂等的尽量不在开发环境使用 StrictMode或者在网络层做请求去重。这个库本身不背这个锅它是严格遵守 React 生命周期语义的清理阶段 abort 第一次请求第二次请求是新的 controller行为完全符合预期。排查时注意看 abort 是否真的在第一次请求上触发如果两次请求都成功且没有 cancel说明你的 watch effect 依赖传得有问题。5.5 现象execute 返回的 Promise 在 abort 后一直不 resolve原因内部实现中abort 触发时是执行 controller.abort()并不是像useAsyncCallback那样 reject 掉execute的 promise。如果你在事件里写了await execute()并希望在 abort 后收到一个明确结果你会发现 await 永远挂起。这是“主动取消”和“请求失败”两种语义的差异很多初次使用者会在这里卡住。解决把 abort 理解成“不再关心执行结果”而不是“让执行失败”。如果你确实需要在外层 await 并感知取消就不能依赖 execute 的 Promise应自己在异步函数里通过try/catch捕获 AbortError 后返回特殊标记值。例如{ canceled: true }execute 外层再判断这个标记。6. 扩展思路给 useAsync 做一层“缓存 竞态丢弃”的增强封装6.1 封装目标与参数设计跑通这个库不代表能直接放进生产项目生产环境还有一个高频需求短时间内相同参数的请求不要重复发出直接从缓存返回。与其每次业务组件里写一堆缓存判断不如基于useAsync包一层自定义 hook命名为useCachedAsync。参数设计如下参数类型作用promiseFn(signal) PromiseT基础的数据请求函数depsany[]依赖数组变化时重新请求cacheKeystring缓存键相同键走缓存cacheTimenumber缓存有效期单位 ms默认 50006.2 核心实现缓存表 stale 丢弃import { useAsyncAbortable } from react-hooks-async; // 模块级缓存表相同 cacheKey 复用数据 const cacheStore new Mapstring, { data: unknown; expire: number }(); export function useCachedAsyncT( promiseFn: (signal: AbortSignal) PromiseT, cacheKey: string, deps: any[], cacheTime 5000 ) { const { data, loading, error, execute } useAsyncAbortable( async (signal) { const cached cacheStore.get(cacheKey); if (cached Date.now() cached.expire) { return cached.data as T; } const result await promiseFn(signal); cacheStore.set(cacheKey, { data: result, expire: Date.now() cacheTime, }); return result; }, deps ); return { data, loading, error, execute }; }逻辑说明cacheStore使用模块级Map存储已完成的请求结果缓存键由调用方传入。在异步函数内部先检查缓存是否存在且未过期命中则直接返回缓存数据不再发起网络请求未命中则调用原始 promiseFn拿到数据后在写入缓存。signal照常传递因此取消逻辑依然有效——如果请求被 abort缓存不会写入。参数说明cacheTime默认 5000 毫秒超出后重新请求deps变化时 hook 会重新执行但缓存的命中判断会优先于请求执行所以相同数据不会重复拉取。注意这里的deps需要包含cacheKey的依赖因素否则 key 变了但请求没重跑会导致数据和 key 不匹配。6.3 验证方法验证这个封装是否可靠我会采用两层验证。第一层是单元测试用 jest testing-library/react 渲染组件第一次执行后 mock 请求函数被调用 1 次第二次用相同 cacheKey 渲染mock 请求函数仍只被调用 1 次等待超过 cacheTime 后再渲染请求函数调用次数应变成 2。第二层是手动验证在 React DevTools 里直接修改组件 props 触发重新渲染观察 Network 面板没有发出重复请求。这个 “缓存 竞态丢弃” 的组合就是 react-hooks-async 可组合性的实际应用你可以用它当一个基础异步电池自己拼装出带缓存、带重试、带轮询的各种业务定制版本。从那以后我每次接触新 hooks 库都会先强制走一遍“最小用例 → 读实现 → 包一层增强”的流程读别人的源码不是为了照抄而是为了让自己的封装更有底气。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站