简介react-hooks-async 是一套面向 React 开发者的自定义 Hooks 工具库解决原生 Promise 无法中止/取消的痛点。它基于 AbortController 将可终止能力封装进 Hooks 中并提供 useAsyncTask、useAsyncRun、useAsyncCombineAll/Race/Seq 等现成钩子适用于请求竞态、条件执行、进度跟踪等场景。包内共有 92 个文件以 js/tsx 源码为主搭配 ts 类型定义、json 配置、html 示例与快照测试整体仅 295KB轻量易集成。已有 315 人学习下载。包内整理好了 core 钩子、axios/fetch/delay/timeout 等任务封装、TypeScript 声明、多场景示例含分页、类型搜索、Wasm 调用以及测试用例方便前端开发者快速上手、按需扩展或移植到既有项目。1. react-hooks-async把异步请求的取消与组合收进自定义 Hookreact-hooks-async 这套自定义 Hook核心就两句话让异步函数在组件卸载时能自动终止让多个异步任务能被按需组合编排。做中后台前端的人几乎每天都被这类问题绊住——列表页切走再切回来旧请求把新响应覆盖了搜索框输入还没停请求已经提前发出去了一个页面三个下拉框的依赖接口互相等待。这套库把 AbortController 和 React Hooks 的生命周期绑在一起用 useAsyncTask、useAsyncRun 以及 useAsyncCombineAll / Seq / Race 几个 Hook 把上述场景收敛成几十行代码。适合在 React 16.8 项目里替换手写的请求状态机尤其适合接口多、切页频繁、带搜索联动的管理端页面。2. 为什么需要 react-hooks-asyncuseEffect 里裸写 async 的三个死结2.1 竞态条件先发的请求不一定是先回的在说这套库怎么用之前得先讲清楚它到底在处理 useEffect 里的哪些老毛病。最常见的是竞态条件用户在搜索框里输入 react请求 A 发出去了继续输入 react hooks 又发出请求 B。网络不稳定时B 可能先返回A 可能慢悠悠最后才落地。如果代码只是简单地把 response 写入 state那最后写进去的很可能反而是 A 的旧数据界面就显示了过期结果。这类 bug 非常难 Debug因为本地几乎复现不出来用户网络一慢就随机出现。我最早是给每次请求编一个递增序号响应回来先比对序号再决定要不要 setState每个异步请求都写一遍这套逻辑代码很快就臭了。react-hooks-async 的做法是每次任务启动时归库内部持有当前的 AbortController下一次启动或组件卸载时先把上一个 abort 掉。abort 之后旧 Promise 即使 resolve 了状态也不会再被写进组件竞态从源头被掐断。这里有个关键点要提前说清楚这套库能防竞态前提是你的异步函数确实消费了 signal。如果 async 函数里压根没把 signal 传给请求层abort 就只是个空动作后面避坑章节会专门展开。2.2 AbortController 的两种消费方式与 React 的衔接AbortController 是浏览器原生的取消机制本身和 React 没有半点关系。它主要由两部分组成controller.abort() 方法负责触发取消controller.signal 负责被传递和监听。fetch 原生支持 signal 选项axios 从 0.22 开始也支持 config.signal自定义的异步任务则要靠 signal.addEventListener(abort, ...) 手动收尾。很多新手对 abort 有一个认知偏差以为调了 abort() 网络请求就会立刻断开。实际上 abort 只是把一个事件广播出去真正断开连接的是 fetch 或者 axios 内部对 signal 的监听逻辑。也就是说如果你的异步函数没有把 signal 透传给底层请求库abort 就是空放一枪。react-hooks-async 的做法是把 signal 作为参数注入到你的异步函数签名末尾你只需要在写请求时把它传给 fetch 或者 axios 的 signal 选项。controller 的生命周期它来管组件卸载它自动 abort你只负责用起来。2.3 把异步状态收敛成 pending / result / error 三个字段裸写 useEffect 的第三个痛点是状态字段膨胀。一个请求往往需要 loading、data、error 三个 useState页面同时挂着五个请求就是十五个 useState中间还夹着 setLoading(true) 忘还原、catch 之后 setError 忘置 null 这类手滑。状态一多组合判断就变得很玄学比如loading 和 error 同时为 true 时界面渲染哪个分支这种问题代码评审时谁也说不清。react-hooks-async 收敛了这个问题一个任务对象里固定有 pending、error、result 三个字段任务没启动、启动中、成功、失败全部从同一个返回值里读。加载分支、错误分支、成功分支各取所需。后面我会把每个字段的取值时机拆开讲这里先记住一个判断标准页面里出现三个以上手写 loading / data / error 组合并且这些状态互相之间有联动关系时就值得用这套东西做一次收敛。3. useAsyncTask 落地上手最小示例与参数拆解3.1 安装与最小完整示例安装只需要一条命令然后就可以在组件里直接用npm install react-hooks-async下面是一个拉取用户列表的最小示例覆盖了这套库最基础的用法import { useAsyncTask, useAsyncRun } from react-hooks-async; const fetchUserList async ({ signal }) { const response await fetch(/api/users, { signal }); if (!response.ok) { throw new Error(HTTP ${response.status}); } return response.json(); }; const UserList () { const task useAsyncTask(fetchUserList, []); useAsyncRun(task); if (task.pending) return div加载中.../div; if (task.error) return div加载失败{task.error.message}/div; return ( ul {task.result.map((user) ( li key{user.id}{user.name}/li ))} /ul ); };这段代码的逻辑是useAsyncTask 接收一个异步函数和一个依赖数组生成一个可被启动的任务对象useAsyncRun 在组件挂载时启动它。重点看第 4 行异步函数签名里解构出了 signalfetch 的第二个参数把它传给底层请求这就是取消信号生效的链路。组件卸载时库会自动调 abort()这次请求不会再有回调落地到已卸载的组件上。依赖数组在这里的作用和 useEffect 的 deps 完全一致依赖项变化时任务对象被重建useAsyncRun 会用新任务重新发请求。比如把 [] 换成 [userId]userId 一变立即重新拉数据同时上一次请求会被 abort。这个行为后面避坑章节会重点讲先记住依赖数组变化会带来任务重启和旧请求终止。3.2 参数与返回值逐个拆解useAsyncTask 的完整签名是 useAsyncTask(asyncFunc, deps)。asyncFunc 是你写的业务异步函数它的前几个参数是业务入参最后一个参数固定是包含 signal 的对象。deps 数组决定任务何时被视为新任务引用一旦变化旧任务弃用对应请求被 abort。useAsyncRun 的签名是 useAsyncRun(task, ...args)...args 会原样透传给 asyncFunc。比如要传一个 teamIdconst fetchUserListByTeam async (teamId, { signal }) { const response await fetch(/api/teams/${teamId}/users, { signal }); return response.json(); }; const TeamUsers ({ teamId }) { const task useAsyncTask(fetchUserListByTeam, [teamId]); useAsyncRun(task, teamId); // task.pending / task.error / task.result 与上例相同 };此时 fetchUserListByTeam 的定义要写成 async (teamId, { signal }) 的形式teamId 是业务参数{ signal } 是库里注入的取消信号。返回的 task 对象中pending 在启动前为 false任务 resolve 或 reject 后回到 falseresult 在成功后写入数据error 在失败后写入错误对象。注意 result 和 error 不会同时有值成功时 error 保持之前的默认值失败时 result 保持 undefined判断分支时不需要额外清理。提示如果团队内已经有统一封装的 request 函数记得在封装层暴露 signal 透传参数否则异步函数里拿到 signal 也递不到真正发请求的那一层。3.3 触发时机挂载启动与事件启动要分开选 APIuseAsyncRun 属于组件挂载就请求的被动模式只会在挂载和 deps 变化时启动。还有一类场景是点击按钮才请求比如提交表单、加载更多这类场景用 useAsyncRun 就不合适了它没有暴露手动启动的入口。对应的事件型 API 是 useAsyncFunctionimport { useAsyncFunction } from react-hooks-async; const submitForm async (formData, { signal }) { const response await fetch(/api/submit, { method: POST, body: JSON.stringify(formData), signal, }); return response.json(); }; const Form () { const submit useAsyncFunction(submitForm); const onClick () { submit({ name: 张三, level: 3 }); }; return ( button onClick{onClick} disabled{submit.pending} {submit.pending ? 提交中... : 提交} /button ); };useAsyncFunction 返回的是一个可随时调用的函数返回值同样挂着 pending、error、result 字段所以提交按钮的 loading 和禁用状态可以直接读 submit.pending。它内部同样维护专属的 AbortController组件卸载时自动 abort用户连续点击提交时上一次请求也会被 abort避免旧响应覆盖新响应。到这里挂载场景和事件场景都有了对应的 API下一步就是看多个任务怎么编排。4. 组合能力useAsyncCombineAll / Seq / Race 怎么选4.1 三种组合器的行为差异与选型依据单个任务的收放已经能跑通真正的价值在页面级编排。react-hooks-async 里最有特色的就是三个组合器useAsyncCombineAll、useAsyncCombineSeq、useAsyncCombineRace。组合器并发模式完成条件失败行为典型场景All并行全部成功才算成功任一失败整体进 error其余任务被 abort页面初始化拉取多个互不依赖的接口Seq串行最后一个任务成功才算成功中途失败整体进 error后续任务不再启动先拿 token 再拉详情数据Race并行第一个完成即定胜负先 reject 则整体 error先 resolve 则整体 result请求超时兜底、主备接口竞速三个组合器都接收一组 task 对象返回一个组合后的 task这个组合 task 同样具备 pending、error、result 字段可以继续传给 useAsyncRun 或者再嵌套组合。选型判断看接口关系就行互不依赖用 All强依赖顺序用 Seq需要抢时间用 Race。实际页面里 All 和 Race 最多Seq 只在 token 链路上比较常见。4.2 并行请求与串行请求的落地写法并行请求的写法是把两个独立任务交给 useAsyncCombineAll再统一启动import { useAsyncTask, useAsyncRun, useAsyncCombineAll, } from react-hooks-async; const fetchProfile async ({ signal }) (await fetch(/api/profile, { signal })).json(); const fetchOrders async ({ signal }) (await fetch(/api/orders, { signal })).json(); const Dashboard () { const profileTask useAsyncTask(fetchProfile, []); const ordersTask useAsyncTask(fetchOrders, []); const combined useAsyncCombineAll(profileTask, ordersTask); useAsyncRun(combined); if (combined.pending) return div加载中.../div; if (combined.error) return div加载失败{combined.error.message}/div; return ( div p昵称{profileTask.result.name}/p p订单数{ordersTask.result.length}/p /div ); };useAsyncCombineAll 不负责启动任务只负责把两个任务的状态合并成一个状态源。useAsyncRun(combined) 会让库同时启动 profileTask 和 ordersTask 对应的异步函数。errror 分支的判断要理解语义All 模式下任意一个任务 rejectcombined.error 就有值但另一个请求此刻可能还在飞。进入错误渲染分支时库内部其实已经对未完成的那个任务执行了 abort所以不会留下泄漏的请求回调。串行场景换成 useAsyncCombineSeq(taskA, taskB)taskB 对应的异步函数会在 taskA 成功后自动收到上一个任务的返回值作为参数。类似先调登录接口拿 token再用 token 调详情接口这种链路Seq 比在前一个 async 函数里手动 await 后一个请求要清晰因为每个环节的 abort 信号独立管理中途失败不会残留后续请求。4.3 和 Suspense / render-as-you-fetch 的边界React 生态里还有一条数据请求路线是基于 Suspense 的 render-as-you-fetch强调在渲染之前就发起请求组件切过去时数据已经在路上。react-hooks-async 走的是 hooks 编排路线组件挂载后才发起请求适合数据不敏感、组件按需加载的管理端页面。两者定位不同Suspense 解决数据预取与渲染时序这套库解决请求的取消与组合。它们可以配合使用外层用 React.lazy 加 Suspense 控制代码分包内层用 react-hooks-async 管数据请求的取消与编排。但我个人的习惯是一个项目只保留一套请求状态方案要么全部走 Suspense 风格要么全部走 hooks 编排混用会让团队里新人很难判断一个请求到底归谁管出问题时的排查路径会多出一整条。5. 避坑手册终止失效、卸载警告与重复触发5.1 现象组件卸载后控制台刷 state update on unmounted component 警告这个警告在写异步组件的项目里几乎人人见过。原因是请求的 Promise 在组件卸载之后才 resolve回调里的 setState 落在一个已经不存在的组件上。react-hooks-async 本意是自动终止来避免这个问题但如果你用的是 useAsyncFunction 手动触发或者异步函数体内没有消费 signal卸载后照样会刷这套警告。原因库内部依赖 signal 的 abort 事件来阻止后续 setStatesignal 没被消费就等于这道保护没生效。解决一是确认异步函数签名的对象参数里有 signal 并把它传给了请求层二是不要在组件卸载后继续调用手动触发函数事件回调里加一层 mounted 标记做防御更稳妥。5.2 现象调了 abort() 但请求照样完成这是整套方案里最常见的翻车点。原因很直接abort() 只是广播了一个事件你的 fetch 或 axios 实例没有监听这个信号。很多人把 signal 写在函数签名里但业务代码里用的还是封装好的 request 函数封装层根本没暴露 signal 透传参数信号在这一层就丢了。解决把 signal 一路透传到底——fetch 的第二个参数、axios 的 config.signal、或者你自己封装层的 options 对象。如果是自己写的 Promise要给 signal 注册 abort 事件监听在回调里把 Promise reject 掉。这一步做到位react-hooks-async 的终止能力才真正成立否则它和普通封装库没有区别。5.3 现象依赖数组一动任务就重复执行旧请求还在飞useAsyncTask 的 deps 数组和 useEffect 的依赖数组行为一致依赖引用变化就意味着新任务。很多人把不稳定的值写进 deps比如每次渲染都新建的内联函数或者对象字面量结果任务反复重建、请求连环发页面上同时飞着三四个重复请求。原因deps 里放的是派生值或字面量每次渲染引用都不同任务对象必然重建。解决deps 只放原始值或 useMemo 过的稳定引用确实要传复杂对象就把它拆成基本类型字段。另外deps 变化后旧任务被 abort 是预期行为不是 bug。如果你发现旧请求没被终止回到 5.2 检查 signal 透传。5.4 现象combineAll 里一个接口失败整页停在 error 分支严格说这是语义理解问题而不是库的缺陷。All 的语义就是全部成功才算成功一个失败整体进 error页面只能渲染错误分支。问题在于开发阶段很容易把 All 误当成互不干扰的并行结果一个本可降级的可选接口断了整页跟着崩。原因对组合器语义理解偏差。解决如果某个接口失败不应该拖垮整页把它拆出去单独用 useAsyncTask 管不要并入 All或者在该接口的异步函数体内 catch 住异常返回兜底值。Race 同理它是拿来抢快的不是拿来容错的容错要在任务函数内部做而不是在组合器层面做。5.5 现象浏览器报 AbortError又被自己的 catch 吞掉用 fetch 配合 signal 时abort 触发后 fetch 的 Promise 会以一个 AbortError 被 reject。很多人习惯在异步函数里 catch 所有异常返回 null于是一个正常的终止操作也被 catch 接住error 状态被污染成看起来像业务失败。原因catch 语句没有区分 AbortError 和真正的业务异常。解决在 catch 里判断错误对象的 name 是否为 AbortError是就直接 throw让它作为终止信号继续往上传播否才按业务错误处理。这是一个非常细节但影响判断正确性的点遇到玄学 loading 问题先查这里。6. 进阶把 abort 信号接进 axios 与自定义超时编排6.1 把 signal 传给 axios 与第三方 SDKaxios 从 0.22 起支持 config.signal直接透传即可const fetchUserList async ({ signal }) { const response await axios.get(/api/users, { signal }); return response.data; };第三方 SDK 不一定认 signal常见做法是包一层适配监听 signal 的 abort 事件在回调里调用 SDK 自带的 cancel 或 abort 方法。这套库本身不限制你怎么消费 signal它只负责把信号的生命周期交给组件signal 到了业务层怎么用是你自己的自由。6.2 用 Race 实现超时兜底与定时器清零竞赛模式最有价值的应用是超时控制把真实请求和一个超时任务丢进 Raceconst timeoutTask async ({ signal }) new Promise((_, reject) { const timer setTimeout( () reject(new Error(request timeout)), 2000 ); signal.addEventListener(abort, () { clearTimeout(timer); reject(new DOMException(aborted, AbortError)); }); }); const combined useAsyncCombineRace( useAsyncTask(fetchUserList, []), useAsyncTask(timeoutTask, []) ); useAsyncRun(combined);这段逻辑是2 秒内真实请求没返回timeoutTask 先 rejectRace 整体进入 error 分支反过来真实请求先返回Race 进入 result 分支同时库会 abort timeoutTask触发监听器里的 clearTimeout。超时和取消共用同一条 abort 链路定时器必然被清掉不会出现定时器泄漏。从那以后我每次新建异步请求组件都会强制走一遍三步自查先确认异步函数接收了 signal再确认 signal 透传到了底层请求调用最后看一眼 deps 里有没有不稳定引用。这套习惯帮我挡掉了绝大多数线上才复现的脏数据问题。希望帮到你。本文还有配套的精品资源点击获取