简介: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-async:useEffect 里裸写 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 行,异步函数签名里解构出了 signal,fetch 的第二个参数把它传给底层请求,这就是取消信号生效的链路。组件卸载时库会自动调 abort(),这次请求不会再有回调落地到已卸载的组件上。依赖数组在这里的作用和 useEffect 的 deps 完全一致:依赖项变化时任务对象被重建,useAsyncRun 会用新任务重新发请求。比如把 [] 换成 [userId],userId 一变立即重新拉数据,同时上一次请求会被 abort。这个行为后面避坑章节会重点讲,先记住"依赖数组变化会带来任务重启和旧请求终止"。3.2 参数与返回值逐个拆解useAsyncTask 的完整签名是 useAsyncTask(asyncFunc, deps)。asyncFunc 是你写的业务异步函数,它的前几个参数是业务入参,最后一个参数固定是包含 signal 的对象。deps 数组决定任务何时被视为"新任务",引用一旦变化,旧任务弃用,对应请求被 abort。useAsyncRun 的签名是 useAsyncRun(task, ...args),...args 会原样透传给 asyncFunc。比如要传一个 teamId:const 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 后回到 false;result 在成功后写入数据;error 在失败后写入错误对象。注意 result 和 error 不会同时有值,成功时 error 保持之前的默认值,失败时 result 保持 undefined,判断分支时不需要额外清理。提示:如果团队内已经有统一封装的 request 函数,记得在封装层暴露 signal 透传参数,否则异步函数里拿到 signal 也递不到真正发请求的那一层。3.3 触发时机:挂载启动与事件启动要分开选 APIuseAsyncRun 属于"组件挂载就请求"的被动模式,只会在挂载和 deps 变化时启动。还有一类场景是点击按钮才请求,比如提交表单、加载更多,这类场景用 useAsyncRun 就不合适了,它没有暴露手动启动的入口。对应的事件型 API 是 useAsyncFunction:import { 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 模式下任意一个任务 reject,combined.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 事件来阻止后续 setState,signal 没被消费就等于这道保护没生效。解决:一是确认异步函数签名的对象参数里有 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 实现超时兜底与定时器清零竞赛模式最有价值的应用是超时控制,把真实请求和一个超时任务丢进 Race:const 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 先 reject,Race 整体进入 error 分支;反过来真实请求先返回,Race 进入 result 分支,同时库会 abort timeoutTask,触发监听器里的 clearTimeout。超时和取消共用同一条 abort 链路,定时器必然被清掉,不会出现定时器泄漏。从那以后,我每次新建异步请求组件都会强制走一遍三步自查:先确认异步函数接收了 signal,再确认 signal 透传到了底层请求调用,最后看一眼 deps 里有没有不稳定引用。这套习惯帮我挡掉了绝大多数线上才复现的脏数据问题。希望帮到你。本文还有配套的精品资源,点击获取
简介: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-async:useEffect 里裸写 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 行,异步函数签名里解构出了 signal,fetch 的第二个参数把它传给底层请求,这就是取消信号生效的链路。组件卸载时库会自动调 abort(),这次请求不会再有回调落地到已卸载的组件上。依赖数组在这里的作用和 useEffect 的 deps 完全一致:依赖项变化时任务对象被重建,useAsyncRun 会用新任务重新发请求。比如把 [] 换成 [userId],userId 一变立即重新拉数据,同时上一次请求会被 abort。这个行为后面避坑章节会重点讲,先记住"依赖数组变化会带来任务重启和旧请求终止"。3.2 参数与返回值逐个拆解useAsyncTask 的完整签名是 useAsyncTask(asyncFunc, deps)。asyncFunc 是你写的业务异步函数,它的前几个参数是业务入参,最后一个参数固定是包含 signal 的对象。deps 数组决定任务何时被视为"新任务",引用一旦变化,旧任务弃用,对应请求被 abort。useAsyncRun 的签名是 useAsyncRun(task, ...args),...args 会原样透传给 asyncFunc。比如要传一个 teamId:const 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 后回到 false;result 在成功后写入数据;error 在失败后写入错误对象。注意 result 和 error 不会同时有值,成功时 error 保持之前的默认值,失败时 result 保持 undefined,判断分支时不需要额外清理。提示:如果团队内已经有统一封装的 request 函数,记得在封装层暴露 signal 透传参数,否则异步函数里拿到 signal 也递不到真正发请求的那一层。3.3 触发时机:挂载启动与事件启动要分开选 APIuseAsyncRun 属于"组件挂载就请求"的被动模式,只会在挂载和 deps 变化时启动。还有一类场景是点击按钮才请求,比如提交表单、加载更多,这类场景用 useAsyncRun 就不合适了,它没有暴露手动启动的入口。对应的事件型 API 是 useAsyncFunction:import { 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 模式下任意一个任务 reject,combined.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 事件来阻止后续 setState,signal 没被消费就等于这道保护没生效。解决:一是确认异步函数签名的对象参数里有 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 实现超时兜底与定时器清零竞赛模式最有价值的应用是超时控制,把真实请求和一个超时任务丢进 Race:const 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 先 reject,Race 整体进入 error 分支;反过来真实请求先返回,Race 进入 result 分支,同时库会 abort timeoutTask,触发监听器里的 clearTimeout。超时和取消共用同一条 abort 链路,定时器必然被清掉,不会出现定时器泄漏。从那以后,我每次新建异步请求组件都会强制走一遍三步自查:先确认异步函数接收了 signal,再确认 signal 透传到了底层请求调用,最后看一眼 deps 里有没有不稳定引用。这套习惯帮我挡掉了绝大多数线上才复现的脏数据问题。希望帮到你。本文还有配套的精品资源,点击获取