ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

React异步请求竞态与取消:react-hooks-async核心钩子详解

React异步请求竞态与取消:react-hooks-async核心钩子详解

简介:React Hooks Async 是一个面向 React 开发者的开源自定义钩子库,解决 Promise 不可中止的痛点。它基于 AbortController 机制,将异步函数与 Hooks 结合,提供 useAsyncTask、useAsyncRun、useAsyncCombineAll 等现成钩子,让请求取消、任务组合和竞态处理更可靠。资源共 92 个文件,以 28 个 JS 源码、20 个 tsx 组件、10 个 ts 类型声明、14 个 JSON 配置和 10 个 HTML 示例页面为主,覆盖实现、类型、配置与演示场景;压缩包仅 295KB,核心代码在 src 与 dist 目录,并附带 typeahead、axios、分页、进度条等示例,已有 315 人浏览学习。对于需要掌握异步任务可中止方案、或直接复用现成 Hooks 的中高级 React 工程师,资源提供完整源码、类型定义和可运行示例,便于快速落地。同时,src 与 dist 的清晰分层、examples 下的典型场景,也能作为理解 AbortController 与 Hooks 协作原理的参考范本。

1. 搜索框的最后一帧:异步请求的顺序错乱与卸载态泄漏

做前端的人几乎都碰过同一个玄学问题:用户在搜索框里快速输入「react」「react hooks」「react hooks async」,第一次请求比第三次晚返回,结果界面被旧数据覆盖。更隐蔽的是组件已经卸载,Promise resolve 之后还去 setState,控制台刷出一片 warning。这个叫react-hooks-async的钩子集合解决的核心,就是把「异步函数」的取消、竞态和组合封装成几个可复用的钩子,让 AbortController 和 React 生命周期天然衔接。适合正在写数据请求层、表单自动保存或轮询逻辑的 React 开发,也适合想搞明白「为什么 useEffect 里 fetch 要带 signal」的初学者。它不是什么框架,就是一组 Hooks API 的封装,但能直接改变你写异步的方式。

2. 核心钩子拆解:useAsync 与 useAsyncAbortable 的职责边界

2.1 useAsync:把 Promise 翻译成组件状态机

先看最简单的形态。useAsync接收一个返回 Promise 的函数,内部维护 loading、data、error 三个状态。它解决的是「重复写 useState + useEffect + try/catch」的体力活。典型用法是:

import { useAsync } from 'react-hooks-async'; const fetchUser = (id) => fetch(`/api/users/${id}`).then(res => res.json()); function UserProfile({ userId }) { const { data, loading, error } = useAsync(() => fetchUser(userId), [userId]); if (loading) return <p>加载中</p>; if (error) return <p>出错了:{error.message}</p>; return <div>{data.name}</div>; }

第一个参数是异步任务函数,第二个是依赖数组。依赖变化时,钩子内部会重跑任务,并自动把 loading 设为 true。逻辑上它像一个「受控的 Promise 容器」:你给它一个会变的依赖,它负责在依赖变化时重新发起请求并管理数据流。真正关键的是它内部对旧请求的处理——如果旧请求还没返回,新请求就发起了,旧请求该怎么处理?基础版useAsync只做忽略,不取消底层请求,所以它还解决不了资源泄漏。

2.2 useAsyncAbortable:让每一次请求都有「后悔药」

useAsyncAbortable是进阶版,它会向异步函数注入一个 AbortSignal。这样任务函数里就能监听取消事件,从根上停下网络请求或任何支持终止的操作。

import { useAsyncAbortable } from 'react-hooks-async'; const fetchUser = async ({ userId }, signal) => { const res = await fetch(`/api/users/${userId}`, { signal }); if (!res.ok) throw new Error('请求失败'); return res.json(); }; function UserProfile({ userId }) { const { data, loading, error } = useAsyncAbortable(fetchUser, { userId }); // ... }

注意任务函数签名:第一个参数是业务参数对象,第二个才是 signal。这不是乱设计,而是为了配合依赖追踪——当你传{ userId }作为第二个参数时,钩子内部会按对象值比较决定是否重新执行,而不是依赖数组下标。signal 的生命周期与本次任务绑定:依赖改变或组件卸载时,钩子自动调用controller.abort(),不仅停止 fetch,还会清理定时器、中断流式读取。这就是「可终止性」落到实处的原理:AbortController 是平台标准,钩子只是把它和 React 生命周期做了同步。

3. 手工实现可终止异步:AbortController 接入 React 生命周期的关键

3.1 为什么 useEffect 里 fetch 需要 signal

多数人写请求只用了fetch(url),没传 signal。组件卸载后请求还在飞,返回后 setState 触发警告。这属于 React 18 之前的老问题,现在不算严格错误,但会制造僵尸组件和数据竞态。加上 signal 等于给请求装了个断路器:一旦abort()被调用,fetch 底层直接抛出AbortError,连 then 都不会走成功回调。手工实现时,控制器要随 useEffect 的清理函数一起销毁:

useEffect(() => { const controller = new AbortController(); const { signal } = controller; fetch('/api/data', { signal }) .then(res => res.json()) .then(setData) .catch(err => { if (err.name === 'AbortError') return; // 主动取消,不算错误 setError(err); }); return () => controller.abort(); }, []);

清理函数执行时机有两个:组件卸载、依赖改变。同一个控制器管一整个 effect,所以依赖一变,旧控制器立刻塞掉没回来的请求。这里有个常见误用:很多人把 controller 定义在 effect 外面,导致多次 effect 共用同一个控制器,第一次清理就把后面的请求全 abort 了。控制器必须活在 effect 作用域内,这一点在useAsyncAbortable内部也是这样处理的。

3.2 参数变化的自动重取与旧请求终止

自动重取是 Hooks API 最有价值的部分。依赖对象每次变化,钩子创建新控制器,abort 掉旧控制器,然后重新执行任务函数。看下面这个带轮询的设置:

const { data, loading, error, abort } = useAsyncAbortable( async (params, signal) => { const res = await fetch(`/api/feed/${params.roomId}`, { signal }); return res.json(); }, { roomId: currentRoomId }, { autoRun: true, interval: 5000 } // 每 5 秒重新请求一次 );

第三个参数是配置项。interval开启轮询,每次轮询都会先 abort 上一次 pending 的请求。这个点特别容易踩坑:如果你自己实现轮询,很可能忘了在 setTimeout 里带上次请求的 abort,结果并发多个请求同时回来,界面来回跳。用这个钩子时,abort()返回值还能手动终止——比如用户切到后台直接取消轮询。

3.3 在自定义事件或回调里触发终止

可终止性不只能靠钩子内部驱动,业务代码里也要能手动中止。钩子会返回abort函数,直接用就行。典型场景是「取消上传」和「停止生成」:

function Uploader({ file }) { const { run, abort, loading, progress } = useAsyncAbortable( async ({ file }, signal) => { const res = await uploadWithProgress(file, (p) => setProgress(p), signal); return res; }, { file }, { autoRun: false } // 不自动执行,等用户点击 ); return ( <div> <button onClick={run} disabled={loading}>开始上传</button> <button onClick={abort} disabled={!loading}>取消</button> {loading && <p>{progress}%</p>} </div> ); }

autoRun: false关掉自动执行,run和abort就是手动开关。任务函数里的signal要一路传进底层:fetch 要传,xmlHttpRequest 要挂xhr.onabort,流式读取要监听signal.addEventListener('abort', ...)。如果中间某个步骤忽略了 signal,abort 就只能断到那一步,后面的行为和资源释放都不会发生。这也是为什么这个资源里所有工具函数都把 signal 作为第二参数显式传递,保证整条链路都能看到终止信号。

4. 可组合性实战:把两个异步钩子拼成「先验证后拉数据」流程

4.1 组合的三种姿势:顺序、并行、条件依赖

可组合性是这个资源的第二根支柱。单靠一个钩子解决不了「先校验 token,再拉用户资料」这种串行流程,也不方便做「两个请求都完成才显示」。手写的话,只能在 useEffect 里做 then 链,再手动管理两个 loading。这个资源里提供了useAsyncTask和useAsyncRun两个钩子,把异步函数拆成「任务定义」和「任务执行」两层。顺序组合:

import { useAsyncTask, useAsyncRun } from 'react-hooks-async'; const validateTask = useAsyncTask(async ({ token }) => { const res = await fetch(`${API}/validate`, { headers: { auth: token } }); if (!res.ok) throw new Error('token 无效'); return res.json(); }); const userTask = useAsyncTask(async ({ token }) => { const res = await fetch(`${API}/users/me`, { headers: { auth: token } }); return res.json(); }, [validateTask.id]); // 依赖 validateTask 的结果 const { loading, error, data } = useAsyncRun(validateTask, { token });

第二个任务的依赖参数里传入validateTask.id,就建立了一个隐式顺序:validateTask必须成功返回,userTask才会触发。去掉这个依赖就是并行执行。条件依赖更灵活——把依赖写成validateTask.result ? userTask : null,返回 null 就不跑。这种设计很像 Reducer 里的状态流,但比手写 promise chain 直观得多,因为每个任务的状态都在钩子内部维护。

4.2 用 useAsyncTask 与 useAsyncRun 实现管道式组合

管道式组合适合需要做数据透传的场景:先查用户,再拿用户的朋友列表,再取每个朋友的头像。每个任务接收上一个任务的结果。组合写法如下:

const userTask = useAsyncTask(async () => { const res = await fetch('/api/user'); return res.json(); }); const friendsTask = useAsyncTask(async ({ user }) => { const res = await fetch(`/api/users/${user.id}/friends`); return res.json(); }, [userTask.result]); const avatarsTask = useAsyncTask(async ({ friends }) => { return Promise.all(friends.map(f => fetch(`${f.avatarUrl}`).then(r => r.url))); }, [friendsTask.result]); const state = useAsyncRun( (payload) => payload, { ...userTask.result, ...friendsTask.result, } );

这里useAsyncRun的 payload 是三个任务的结果拼起来的对象,每个任务只取自己需要的字段。这种组合不关心失败后的回滚——因为任务失败时,后续任务不会触发,整个组合停在失败处,由统一 error 呈现。真要处理回滚,应该在每个任务内部做补偿,而不是依赖组合链。组合的好处在于状态透视:你能从任何任务上读取 loading、error、result,方便做局部 loading 显示,而不是只有一个最大的 loading 门闩。

5. 避坑笔记:五个最容易翻车的边界场景

5.1 现象:组件卸载后控制台报 setState 警告

原因:任务函数里 fetch 没有接 signal,或者接了但组件卸载时 abort 没有及时执行。useAsyncAbortable内部在清理阶段会调controller.abort(),但如果你在任务函数里开启了另一个没有接收 signal 的异步任务(比如 setTimeout),它照常 resolve,然后走到 setState。

解决:任务函数内所有异步分支都要接收 signal,并在回调里自行判断是否继续。setTimeout 改成signal.addEventListener('abort', () => clearTimeout(id));或者用if (signal.aborted) return做前置检查。我在代码里看到过有人把 fetch 放进了 signal 但把.then的处理忘在外面,照样崩。

5.2 现象:快速切换条件时,界面闪回旧数据

原因:依赖对象变化后,新请求发起,但旧请求还没被 abort,两个请求并发。旧请求先返回时,它可能覆盖新请求的数据。useAsync基础版只做「忽略旧结果」,靠的是内部一个递增的 requestId;而useAsyncAbortable用物理 cancel 彻底挡掉旧请求。

解决:优先用useAsyncAbortable。如果被迫用基础版,检查任务函数是否真的有 abort 支持——很多库函数只接受 URL 不接受 signal,那就要包装一层,自己监听 abort 事件并 reject 一个自定义错误。

5.3 现象:把 AbortError 当成普通错误弹给用户

原因:fetch被 abort 后会 reject 一个DOMException,name是AbortError。很多人 catch 里没有判断错误名称,直接setError(err),结果 UI 弹「请求失败」,但实际是用户自己取消的。

解决:统一错误处理模板,先查err.name === 'AbortError',如果是就直接 return,不上报不上状态。这个钩子内部实际上允许你传一个onAbort回调让你钩子外部知道「这个请求是被终止的」,但默认行为是静默。

5.4 现象:请求取消后 loading 状态卡在 true

原因:abort 触发后,钩子进不了正常的 finally 分支,因为 Promise 被 reject 了,而错误分支又因为静默被拦截。有些版本在 abort 后不会自动重置 loading,需要手动调reset。

解决:如果你用useAsyncAbortable,注意它返回的abort函数内部会同步置loading为 false。但如果你是自己封装的 hook,一定要在signal.addEventListener('abort')回调里手动setLoading(false)。我看到不少人的实现是在 catch 里 setLoading(false),但 abort 被静默拦截后,catch 根本走不到。

5.5 现象:组合模式中某个子任务失败,整体重试被卡死

原因:useAsyncTask的依赖如果写的是前一个任务的result,前一个失败后result为 null,依赖永远是 null,重试时新参数也没法触发 re-run。

解决:不要把依赖直接写成task.result,要写成函数task.runCount之类的递增计数器。或者干脆在失败时调用task.reject抛出,让useAsyncRun里的组合参数变成一个会变化的 error 对象,从而触发下一个 run。我用这个资源时,都会在所有任务节点上保留一个attempt计数器,每次失败 +1,依赖里带上它,失败重试就能跑通。

6. 进阶检查:把 TypeScript 泛型和 abort 信号一起收进钩子签名

到最后一章,放下核心逻辑,专讲一个我在项目里反复改的习惯:给这些钩子补上完整的 TypeScript 类型。原资源里的类型定义像是从 JavaScript 直接搬过来的,any到处飞,第一次用没什么问题,组件一多就暴露了。我把useAsyncAbortable重新包了一层:

import { useAsyncAbortable, type AsyncTask, type AsyncRunOptions } from 'react-hooks-async'; interface UserParams { userId: string; } function useUserProfile(params: UserParams, options?: AsyncRunOptions) { const task = useAsyncAbortable<User, UserParams>( async ({ userId }, signal) => { const res = await fetch(`/api/users/${userId}`, { signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); }, params, options ); return task; }

注意泛型顺序:第一个是返回数据类型,第二个是入参类型。任务函数内部返回Promise<User>,钩子会把它推导进data字段。这样在组件里写task.data.name时,编辑器能直接提示name存在,而不是让类型变成unknown。

第二个习惯是给 signal 做一层类型收敛。fetch 的 signal 参数类型是AbortSignal | null,业务函数里最好自己写一个校验:

const ensureSignal = (signal?: AbortSignal) => { if (!signal) throw new Error('缺少 AbortSignal 参数'); return signal; };

然后在任务函数开头调用const signal = ensureSignal(props.signal)。这一下就把「信号可能不存在」的运行时风险变成编译期错误。第三个习惯是每当组件里出现超过两个异步任务,我都会强制自己画一遍「任务依赖图」——把每个任务的输入、输出、失败点列出来,再对齐钩子的依赖参数,避免组合顺序写反。这个习惯救过我很多次,尤其是做轮询嵌套组合时,一旦依赖写错,整个组件会变成一台永不停歇的发信机,数据疯狂闪烁。

从那以后,我每接一个异步架构都会先检查三件事:所有请求有没有 signal、卸载时有没有 abort、组合状态有没有可重入的入口。把这三个习惯固化下来,异步相关的 bug 能少掉七成。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进