简介:这是 react-hooks-async 开源库的完整源码包,专注于用 React Hooks 方式处理可终止(abortable)与可组合(composable)的异步函数。针对 JavaScript Promise 本身不可取消、但可通过 AbortController 实现中止这一痛点,它提供了一批即用型自定义 Hook,如 useAsyncTaskFetch、useAsyncTaskAxios、useAsyncTaskDelay 等,并支持基于核心 Hook 二次封装,适合需要精细控制异步请求取消或竞态条件的前端开发者。压缩包内含92个文件,包括28个 js 逻辑代码、20个 tsx 示例页面、10个 ts 类型定义、14个 json 配置以及 md 说明文档等,整体仅295KB,结构清晰,便于按需查阅。源码附带了从最小示例到类型下拉、分页、进度展示、并发竞态、WASM 等10种可运行场景,基本覆盖常见异步交互。已有315人学习,适合对自定义 Hooks 与异步取消机制感兴趣的中高级 React 开发者参考。
1. 可终止不等于取消:为什么要给异步函数装一个 AbortController
在后台管理页面里,用户连续切换两个筛选条件,第一批数据晚到但第二次请求的结果反而被覆盖,这是最常见的竞态现场。用“请求序号比较”能挡住旧数据进 state,可挡不住底层网络请求还占着带宽;组件卸载后还会收到 setState 警告。react-hooks-async 就是为这类问题设计的自定义钩子:它把 Promise 包成带 abort 能力的任务对象,并把这些任务用 All、Race、Seq 组合起来,让可终止性和可组合性成为 React 异步编程的一等公民。这套东西适合谁?被竞态条件、卸载告警和多请求编排折腾过的前端开发者。如果你只是想在 useEffect 里发一个请求、觉得能跑就行,那这套抽象你用不上,但值得看一次。
2. 项目定位:可终止与可组合如何重构异步状态管理
2.1 可终止性:从“卸载后不报错”到“真的把请求掐断”
先看传统写法长什么样。一个典型的 useEffect 请求:
useEffect(() => { let ignore = false; fetch('/api/users') .then((res) => res.json()) .then((data) => { if (!ignore) setUsers(data); }) .catch((err) => { if (!ignore) setError(err); }); return () => { ignore = true; }; }, []);这段代码能解决卸载后的 setState 告警,但只解决了显示层的问题。fetch 一旦发出去,浏览器里的网络请求依然会跑完;用户切走页面后还继续下载几百 KB 的 JSON,在弱网环境里就是实打实的流量浪费。react-hooks-async 的可终止性走的是另一条路:创建任务时,内部自动生成一个 AbortController,把它的 signal 传给执行函数,同时把 abort 暴露出来。组件卸载、依赖变化、任务被新任务替换、或用户手动取消,都会走到 abort(),让底层请求真正中断。
const fetchUsers = useAsyncTask(async (signal) => { const res = await fetch('/api/users', { signal }); return res.json(); }, []); useAsyncRun(fetchUsers);useAsyncTask 的第一个参数是异步函数,signal 由内部注入,不需要自己 new AbortController;第二个参数与 useEffect 的依赖数组语义一致,决定 start 函数何时重建。useAsyncRun 则负责在组件挂载后启动任务,并在卸载时自动清理。这样外部控制权和内部执行权分开:手动触发时调用 task.start(),想中止时调用 task.abort()。
为什么选择 AbortController 而不是自己维护一个 cancelled 布尔值?因为布尔值能挡住状态更新,却挡不住真实请求。AbortController 是浏览器原生 API,主流浏览器和 Node 15 之后都支持,fetch、axios 新版都认它;signal 还能多层传递,父任务 abort 时所有继承同一 signal 的子任务都会感知。唯一要清醒的是:如果异步函数不接收 signal,比如里面只有一个不可中断的定时器,abort 只能止住状态更新,网络或计算本身还在跑。设计对接层时要把这一点留好,后面避坑章节会再展开。
2.2 可组合性:All、Race、Seq 三种任务编排
单个任务只解决一个请求的生命周期,可实际页面往往同时有多个异步依赖:仪表盘要同时拉用户列表和文章列表,两个都回来才能渲染;两级联动下拉,第一级选中后才请求第二级;还有“三选一”的容灾场景。react-hooks-async 用组合器处理这类编排。
const usersTask = useAsyncTaskFetch('/api/users'); const postsTask = useAsyncTaskFetch('/api/posts'); const dashboardTask = useAsyncCombineAll(usersTask, postsTask); useAsyncRun(dashboardTask);useAsyncCombineAll 是 Promise.all 语义:所有子任务全部成功,组合任务才算成功。它的重点在失败处理——任一子任务失败,组合任务会主动 abort 其余还在飞的任务,而不是让它们继续空转。仪表盘场景里一个接口 500,另外两个没必要陪跑。useAsyncCombineRace 对应 Promise.race,适合“多个数据源谁先回来用谁”的加速或容灾。useAsyncCombineSeq 是串行,每个任务执行完才进入下一个,比如先登录再拿用户资料。
组合器还能嵌套:先 All 两个基础数据,再 Seq 一个依赖基础数据的计算任务。组合任务的 data 会按子任务顺序打包成一个数组,UI 层只需要读组合任务自己的 loading 和 data,不需要分别追三个子任务的状态。可组合性因此不是简单的 then 链条,而是能和业务数据结构对齐的编排能力。
2.3 与 useEffect + useState 主流写法的差异
把这条区别用表格看最快:
| 能力项 | useEffect + useState 手写 | react-hooks-async 任务 |
|---|---|---|
| 卸载或替换时停掉旧请求 | 自己维护 ignore 标记 | abort 自动执行 |
| 手动取消进行中的请求 | 通常做不到 | task.abort() |
| 多请求并行全部成功后渲染 | 手写 Promise.all 且要处理清理 | combineAll 失败时自动 abort 伙伴 |
| 同一套逻辑多处复用 | 复制粘贴或抽公共函数 | 任务对象复用,start 可传参 |
| loading/error 状态集中度 | 分散在各 useState | 统一收敛到任务上 |
手写方案在简单页面完全够用,我见过不少团队是一条“请求公共函数 + ignore 变量”的模板走天下,稳定也没问题。但一旦出现搜索框快速输入、下拉级联、多接口相互依赖三个场景,手写代码里就开始堆 cancel 标识、副本变量、回调顺序判断。把 abort 和组合收进自定义钩子里之后,这些复杂度就从组件里搬走了。
也不是所有项目都该用。如果业务只有一个单例请求,并且请求与组件生命周期天然同步,多套一层任务反而增加心智负担。选型的关键判断是:你这里到底有没有“被遗弃的请求”?没有,就用最简单的方式写;有,再引入这套可终止、可组合的任务体系。
3. 架构拆解:一个 Hook 库的分层设计
3.1 顶层 API 一览:任务、运行器与组合器
react-hooks-async 的对外 API 可以按职责分成三组。第一组是任务定义:useAsyncTask 接收一个 async 函数和依赖数组,返回带 start、abort、loading、data、error 的任务对象;useAsyncTaskFetch 是它的内置变体,直接接收 URL 和请求参数,适合不需要手写 fetch 细节的普通请求。第二组是运行控制:useAsyncRun 负责在挂载后执行任务,并在卸载和依赖变化时清理。第三组是组合器:把多个任务组装成一个任务对象。
这种拆分与“在组件里直接调用 useFetch(url)”的设计完全不同。表面看使用者要多写一行,但换来的是任务的可控性——useFetch 返回里塞满重复的 start/stop;而任务可以作为实体传来传去,能交给子组件、能被组合器吞进去、能跨页面复用。一句话概括:useAsyncTask 决定“做什么”,useAsyncRun 决定“何时做”,组合器决定“多个事之间的关系”。三个问题拆开,业务代码就不需要再把取消逻辑糊在组件渲染里。
| API | 作用 | 典型用法 |
|---|---|---|
| useAsyncTask | 包装一个接收 signal 的异步函数 | 手写 fetch 或接第三方请求库 |
| useAsyncRun | 挂载后自动运行任务 | 页面初始化加载 |
| useAsyncCombineAll | 并行组合,全部成功才结束 | 仪表盘多数据源 |
| useAsyncCombineSeq | 串行组合,上一个结束才启动下一个 | 登录后拉资料 |
| useAsyncCombineRace | 竞速组合,最快的结果生效 | 多线路容灾 |
| useAsyncTaskFetch | fetch 专用快捷任务 | 普通 JSON 接口 |
3.2 核心实现:useAsyncTask 的任务状态机
源码里最值得读的是 useAsyncTask 的核心实现。下面这个版本是简化的骨架,保留了最关键的三个部分:状态机、引用保持、abort 生命周期。它没有处理组合器的子任务清理,但思路一致。
import { useCallback, useEffect, useRef, useState } from 'react'; function useAsyncTask(asyncFn, deps) { const [state, setState] = useState({ loading: false, data: undefined, error: undefined, }); const controllerRef = useRef(null); const start = useCallback((...args) => { // 先掐断上一次未完成的请求 controllerRef.current?.abort(); const controller = new AbortController(); controllerRef.current = controller; setState({ loading: true, data: undefined, error: undefined }); return Promise.resolve(asyncFn(...args, controller.signal)) .then((data) => { setState({ loading: false, data }); }) .catch((error) => { // 主动中止的取消不视为错误 if (error.name === 'AbortError') return; setState({ loading: false, error }); }); }, deps); const abort = useCallback(() => { controllerRef.current?.abort(); }, []); useEffect(() => { return () => { controllerRef.current?.abort(); }; }, []); return { ...state, start, abort }; }这里有三处细节值得细看。第一,start 第一行调用 controllerRef.current?.abort(),实现了“新请求顶掉旧请求”——对同一任务连续调用两次 start,第一次的网络请求立即中止,第二次正常执行。第二,catch 里判断 AbortError 并提前返回,保证取消不会污染 error 状态,用户点取消后界面不应该弹红色报错。第三,useEffect 返回清理函数,依赖数组为空意味着只在组件卸载时执行,防止组件销毁后还有来自旧实例的 setState 落下来。
依赖数组的问题也要注意:useCallback 的 deps 直接透传给 useAsyncTask,如果业务函数引用了某个 prop 或 state,却没放进 deps,start 闭包里持有的就是旧值。这跟 useEffect 丢依赖是同一类 bug。我一般会把用到的外部变量都列进去,宁可触发一次 start 重建,也不要让数据停留在旧引用上。
状态机本身是经典的“空闲/加载/成功/失败”三态,空闲不单独存字段,用 loading 为 false 且 data 为空来表示。把状态和操作放进同一个 task 对象返回,组件端就只需要面向这个对象编程:读 task.loading、task.data、task.error,调 task.start、task.abort。内部怎么维护 AbortController、怎么处理竞态,调用方不需要知道,这正是把 abort 作为一等公民抽象的意义。
3.3 useAsyncRun 的触发逻辑与内置 fetch 任务
useAsyncRun 可以理解成“挂载后副作用”的封装:
function useAsyncRun(task, ...args) { useEffect(() => { if (task && typeof task.start === 'function') { return task.start(...args); } }, [task, ...args]); }它把 task 和参数都放进依赖数组,因此任务对象被重建或参数变化时,旧 effect 的清理函数会先执行——这正是“新请求顶掉旧请求”的另一条触发路径。注意 useEffect 返回的是 task.start 的返回值,而 start 在 abort 旧请求后返回新 Promise,这样就保证清理时只清当前这一轮,不会误伤新任务。
useAsyncTaskFetch 是在 useAsyncTask 之上包的一层默认实现:
function useAsyncTaskFetch(url, init) { return useAsyncTask(async (signal) => { const res = await fetch(url, { ...init, signal }); if (!res.ok) { const err = new Error(`HTTP ${res.status}`); err.status = res.status; throw err; } return res.json(); }, [url, init]); }这里把 signal 合并进 fetch 的 options,HTTP 非 2xx 转成异常抛出,再统一交给任务状态机捕获。封装方式直接提示了二次开发方向:项目里如果用 axios 或者其他请求 SDK,照这个模式写一个 useAsyncTaskAxios,把 signal 放进对应配置即可。这个库不是只能配 fetch 用,它约束的是“异步函数要能接收 signal”这个接口,而不是具体哪个请求库。
4. 运行与改造:从安装到在 Vite 工程里跑通示例
4.1 环境准备与安装
开始前需要 Node.js 环境和包管理工具。库不绑定 React 版本,但用 React 18 的工程是最常见配置。进入前端工程后执行:
npm install react-hooks-async或者用 yarn add。安装后不需要额外配置,库以 ESM 形式发布,现代打包工具能直接解析。如果是老式 CJS 工程,注意打包器配置与包的导出格式。
我拆项目时习惯先建一个最小 Vite 工程,避免被业务代码干扰:
npm create vite@latest async-demo -- --template react-js cd async-demo npm install npm run dev这样得到一个干净可运行的环境。接着用第三层的 API 搭两个页面级示例,一个验证可终止,一个验证可组合。
4.2 最小可运行示例:一个会“自我了断”的请求
在 App.jsx 里直接写:
import { useAsyncTaskFetch, useAsyncRun } from 'react-hooks-async'; function App() { const task = useAsyncTaskFetch('/api/delay?ms=3000'); useAsyncRun(task); return ( <div> {task.loading && <p>加载中…</p>} {task.error && <p>出错了:{task.error.message}</p>} {task.data && <pre>{JSON.stringify(task.data, null, 2)}</pre>} </div> ); }这个例子不做任何手动 abort,但已经具备卸载自动中止能力:数据没回来时切走组件,task.abort 会被调用,Network 面板中请求显示为 canceled,console 里也不会有卸载后组件更新状态的告警。useAsyncTaskFetch 返回的任务对象把状态直接摊开在 task 上,UI 只需读 loading、error、data 三个字段。
/api/delay?ms=3000 模拟缓慢后端,没有后端时可以在工程里用 node 起个临时接口,或直接换真实接口观察。验证方法:打开 Network 面板,请求未完成时切换路由,观察请求状态是否变 canceled。这一步能直观感受到“渲染层面取消”和“网络层面取消”的区别——前者只是不更新界面,后者请求真的没了。
4.3 组合器实战:搜索请求的竞态修复
竞态最典型的场景是搜索框。用 useEffect + fetch 手写,几乎总是遇到“旧的搜索比新的回来更晚”的问题。用这个库可以直接调用 task.start 来覆盖旧请求:
import { useState } from 'react'; import { useAsyncTask } from 'react-hooks-async'; const search = (keyword, signal) => fetch(`/api/search?q=${encodeURIComponent(keyword)}`, { signal }) .then((r) => r.json()) .then((r) => r.items); function Search() { const task = useAsyncTask(search, []); const [keyword, setKeyword] = useState(''); const handleChange = (e) => { setKeyword(e.target.value); task.start(e.target.value); }; const handleCancel = () => task.abort(); return ( <div> <input value={keyword} onChange={handleChange} /> <button onClick={handleCancel}>取消</button> {task.loading && <p>搜索中…</p>} <ul> {(task.data || []).map((item) => <li key={item.id}>{item.name}</li>)} </ul> </div> ); }这里没有组合器,但已经用上了“start 内部先 abort 上一次”的机制。每次输入即触发新任务,上一次还在飞的任务直接掐断;取消按钮则调用 task.abort,给用户一个“手动叫停”的出口。input 到 start 之间没有防抖,如果项目需要,可以在这个位置再接防抖逻辑,两者不冲突。
真正用到组合器的演示例是两段式级联:先请求城市列表,选中后再拉该城市的门店,两者有依赖关系,用 useAsyncCombineSeq;并行拉取统计指标且希望全部返回后统一渲染,用 useAsyncCombineAll 嵌套。写法如下:
const citiesTask = useAsyncTaskFetch('/api/cities'); const storesTask = useAsyncTask(async (cityId, signal) => { const res = await fetch(`/api/stores?cityId=${cityId}`, { signal }); return res.json(); }, []); const dashboardTask = useAsyncCombineAll(citiesTask, storesTask); const detailTask = useAsyncCombineSeq( dashboardTask, useAsyncTask(async (signal) => { const stores = storesTask.data; const res = await fetch('/api/store-detail', { method: 'POST', body: JSON.stringify({ ids: stores.map(s => s.id) }), signal, }); return res.json(); }, []) );这串代码确实绕,但它说明一个事实:可组合性能把复杂的异步 DAG 转成声明式的任务树,组件里只留“发起动作”和“读状态”,不再有相互穿插的 then 回调。真实页面依赖更复杂时,我优先用这种组合组织任务,因为取消链是自动维护的。嵌套每多一层,调试任务状态时就要多一层心理账——所以我的习惯是:三层以上的组合就要抽成业务专属的封装 hook,而不是在组件里直接叠宝塔。
5. 避坑指南:AbortController 与 React 生命周期相遇的五个踩坑场景
5.1 严格模式下请求被发起两次,第一次以 cancel 收场
现象:React 18 开发模式下,页面挂载时的请求在 Network 面板出现两次,第一个 canceled、第二个正常完成。
原因:StrictMode 会故意二次挂载组件,useEffect 被调用、清理、再调用,用来暴露副作用残留。这不是库的 bug,而是开发环境预设。
解决:实现上把 AbortError 当成正常分支忽略,不要把它弹成错误。生产环境不会出现两次挂载,所以不需要改业务逻辑。若团队对“开发时发两次请求”敏感,可以做环境判断只在生产执行请求,但我不建议为了省一次请求关掉 StrictMode,毕竟它抓的是别的副作用问题。
5.2 卸载时不报警告了,但定时器还在跑
现象:组件卸载后再也没有 setState 警告,但控制台仍有来自 setTimeout 的回调日志输出。
原因:abort 只中断接收了 signal 的操作,自己写的 setTimeout 没接 signal,自然不会取消。
解决:给定时器包一层可中止的 Promise 助手:
const timeout = (ms, signal) => new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')); }, { once: true }); });这样 signal 触发 abort 时会 reject 一个 AbortError,定时器被 clearTimeout 清掉。这种做法同样适用于事件监听器、动画帧等资源,原则是“所有可停止的东西都要挂到同一个 signal 上”。
5.3 依赖数组写不好,任务会自己取消自己
现象:useAsyncTask 的 deps 里包含一个每次渲染都变化的对象,任务刚 start 就断掉,loading 反复跳。
原因:deps 语义和 useEffect 一致,任何引用变化都被理解为“需要新任务”。start 被重建,effect 清理流程触发,旧任务的 abort 逻辑随之执行。
解决:把不变的 URL、静态配置写进 deps,把每次会变的参数放到 start 的实参里;如果参数必须通过闭包引用,用 useRef 或 useCallback 稳定引用。一个可参考的规则:凡是能从“调用那一刻”拿到的值,就尽量不要进 deps。
5.4 axios 请求取消后会继续跑完
现象:调用 task.abort() 后 React 状态不再更新,但 Network 面板里 axios 请求依然进行到完成。
原因:axios 的取消机制有两种:旧版 CancelToken 和新版 signal 支持。signal 要放在请求配置的 config.signal 里,而不是 query 参数上;如果用的是不完全支持 signal 的版本,abort 就只是“不再更新状态”的开关,网络层停不下来。
解决:接第三方请求库之前,先确认它对 AbortSignal 的支持程度。axios 需要把 signal 传给配置对象;不支持的项目可以在封装层用 AbortController 的 addEventListener 手动处理。除非确认后端开销可以接受,否则别把半终止当成真终止上线。
5.5 组合任务失败后,兄弟任务还在跑
现象:combineAll 的两个子任务里一个失败,另一个仍完成请求,并更新了自己的状态。
原因:组合器如果只“等待全部 settle”而不主动取消伙伴任务,就会出现这种漏网之鱼。失败后 abort 所有子任务是组合器的本职责任,但如果自己手写一个 combine,十有八九会漏。
解决:使用组合器时先确认失败路径会 abort 其余子任务;自己写组合时,在 catch 里拿到其它任务的引用逐个 abort。这也是我建议先复用现成组合器而不是手写 Promise.all 的原因——这类“失败即拆队”的细节,手写容易翻车。
注意:判断中止结果时统一看 error.name === 'AbortError',不要依赖 message 字符串,不同浏览器给的文案不统一。
6. 二次开发实战:把可终止性扩散到表单提交与轮询
6.1 表单提交与轮询的封装
把 useAsyncTask 的包装思路复制到其它场景是个不错的起点。表单提交常见问题是按钮连点会发多个请求、组件卸载后提交回调仍操作状态。包一层提交任务:
function useSubmitTask(mutationFn, deps) { const task = useAsyncTask(async (payload, signal) => { const res = await mutationFn(payload, signal); return res; }, deps); const submit = (payload) => task.start(payload); return { submit, ...task }; }这里的业务参数交给 start 的实参,submit 内部调用 task.start,重复点击时旧提交自动被 abort,loading 状态又能直接控制按钮 disabled 和文案。轮询也是同一个思路,先包一个可中止请求,再在外面接定时器:
function usePolling(fn, interval, deps = []) { const task = useAsyncTask(fn, deps); useEffect(() => { let timer; const tick = () => { task.start().finally(() => { timer = setTimeout(tick, interval); }); }; tick(); return () => clearTimeout(timer); }, [task]); return task; }tick 里先 task.start(),完成或失败后通过 finally 设置下一次定时器;组件卸载时 clearTimeout 并由任务自己的清理函数中止在飞请求。任务负责“单次请求可终止”,定时器只负责“安排下一次”,两件事不再互相纠缠,这就是把 abort 下沉到任务层带来的直接好处。
6.2 验证方法与一个习惯
改造完怎么验证?两步。第一步,切走页面看 Network 面板里请求是否 canceled,这验证卸载清理;第二步,快速连续触发多次请求,观察是否只有最后一次在飞,这验证竞态处理。我一般会在组件里临时加日志,记录 start 和 abort 的调用顺序,和面板里的请求状态对照,能很快定位是“没 abort”还是“abort 时机不对”。
我在这些改造里踩过的最实在的坑是:一开始只对 fetch 用 signal,后来在一个上传进度组件里栽了跟头,组件都卸载了,上传回调还在改状态、甚至触发二次上传。从那以后我接到任何异步函数都会先问一句:它吃 signal 吗?不吃的先包一层再进任务,能吃就直接把 signal 透传到位。如果你也经常被竞态问题困扰,就把上面的核心实现丢进本地工程跑一遍,真实请求的取消过程比任何文字描述都直观。希望帮到你。
本文还有配套的精品资源,点击获取