说实话,第一眼看到“使用 onCleanup 处理异步副作用”这个标题,我脑子里立刻蹦出了去年在某项目里排查过的一个线上问题:用户快速切换筛选条件,列表数据偶尔会“跳”回上一次的状态,刷新页面又恢复正常。当时我和 A 同学查了大半天,最后定位到根因就是 useEffect 里的异步请求没有正确处理清理逻辑。那是我第一次意识到,onCleanup(也就是 useEffect 返回的那个清理函数)不只是“卸载组件时做点收尾”这么简单,它是前端异步世界秩序感的来源。这篇文章我想把这些经验完整地梳理一遍。内容会覆盖:onCleanup 的底层执行机制、六种高频异步副作用的清理写法、竞态条件的完整成因与解法、用 AbortController 真正取消网络请求,以及我在实践中反复踩过的坑。适合刚接触 React Hooks 不久、对 useEffect 清理逻辑一知半解的前端开发,也适合已经写过不少组件但偶尔被“幽灵更新”困扰的朋友。1. 线上诡异 Bug:切个筛选条件,列表却“跳到”了旧状态1.1 复现路径与表象某运营后台的订单列表页,顶部有三个筛选条件:时间范围、订单状态、支付渠道。用户选择任意条件后,前端会发请求拉取新数据并渲染表格。表面上看功能一切正常,但只要用户操作够快——比如在 500 毫秒内连续切换两次“订单状态”,表格内容就有概率出现“错乱”:明明选的是“已退款”,表格里却先显示了“已支付”的数据,过一两秒才被新数据覆盖,甚至有时候直接被旧数据顶掉,一直不刷新。这个 Bug 最恶心的地方在于:它不固定复现,只在网络环境差、接口响应慢的时候频繁出现。本地开发环境几乎看不出来,因为 localhost 的响应太快,旧请求早就返回完了。1.2 根因:没有清理的异步副作用把代码简化后,当时的问题长这样:useEffect(() => { // 这里省略了参数拼接逻辑 fetchOrderList({ status, dateRange, channel }).then((data) => { // 不管请求是不是当前组件发起的,只要返回就更新状态 setOrders(data.list); }); }, [status, dateRange, channel]);这段代码看似合理:依赖项变了就重新请求。但它漏了一个关键问题——当依赖项从 A 变成 B 时,A 对应的那个请求并没有被中断或忽略。它仍然在网络上“飞着”,等它返回时,.then回调照样执行,照样调用setOrders。于是时间线上出现了这样的交错:用户先选“已支付”,请求 1 发出,响应较慢。用户紧接着改选“已退款”,请求 2 发出,响应较快。请求 2 先返回,表格渲染“已退款”的数据,正确。请求 1 后返回,表格被覆盖渲染成“已支付”的数据,错误。这就是典型的异步竞态问题(Race Condition)。组件本身没有被卸载,useEffect 也没有报任何警告,但副作用的结果已经不属于当前状态了。要解决它,就得让“旧副作用”在“新副作用”开始之前被清理掉——这正是 onCleanup 的用武之地。2. onCleanup 到底是什么:React 的“退场机制”拆解2.1 useEffect 的返回值:不是可选项很多人刚开始学 React Hooks 时,会下意识忽略useEffect的返回值。实际上,useEffect的第一个参数(那个函数)可以返回另一个函数,这个返回的函数就是清理函数(cleanup function),在 React 社区里也常被叫做 onCleanup。useEffect(() => { // 副作用逻辑 return () => { // 清理逻辑 }; }, [deps]);关键在于理解:清理函数不是只在组件卸载时才执行。它还会在每一次依赖项变化、下一次副作用重新执行之前执行。这一点极其重要。2.2 清理函数的执行时机:卸载、依赖变化、严格模式我把 React 官方文档和实际运行表现结合,整理出清理函数的四个执行时机:时机场景发生的阶段依赖项变化前[status]从"已支付"变成"已退款"新的副作用执行前,React 先调用上一次的清理函数组件卸载时if (show) return <List />变成return null组件从页面移除前严格模式开发环境组件初次挂载React 故意模拟“挂载→清理→再挂载”页面隐藏或组件被缓存配合某些路由动画或缓存方案取决于具体库的实现大多数时候我们只关注前两种,但这两种已经覆盖了 95% 的异步副作用问题。任何持续性的资源——定时器、事件监听、网络请求、WebSocket 连接——都应该能在这些时机被“回收”。2.3 一个生活中的类比如果把 React 组件比作一个租出去的房间,那 useEffect 就是租客入住时签的“设备使用协议”。每次依赖项变了,相当于协议到期、重新续签,React 会先让上一任租客把东西收拾好(清掉旧定时器、断开旧监听、取消旧请求),然后才让新租客入住。如果不收拾,房间里就会堆满上一任留下的垃圾——旧请求的回调就是其中最脏的那部分。3. 六种高频异步副作用的清理姿势3.1 定时器与延时任务setTimeout和setInterval是最典型的“持续性副作用”。不清除的话,轻则多打一次日志,重则定时器持有组件引用导致内存泄漏。useEffect(() => { const timer = setInterval(() => { refreshData(); }, 5000); return () => { clearInterval(timer); }; }, []);这里有个细节:setInterval返回的 timer id 在浏览器里是数字,在 Node 环境里是对象,但clearInterval都能正确识别。清理动作必须在清理函数内部直接调用,不要在外面封装一层“可能被忘记调用”的函数。3.2 DOM 事件与全局监听通过addEventListener挂在window、document、body上的监听器,不会因为组件卸载而自动移除。如果不手动清理,回调会在组件销毁后继续触发,甚至多次触发。useEffect(() => { const handleResize = () => { setViewportWidth(window.innerWidth); }; window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); }; }, []);一个很容易被忽视的点是:addEventListener和removeEventListener必须传入同一个函数引用。如果在清理函数里重新写一个匿名函数,是移除不掉的。所以监听函数名要么声明在 effect 内部用常量持有,要么抽成组件外部的稳定函数。3.3 网络请求(基础版:忽略过期响应)如果不想上 AbortController(后面会讲),最基础的做法是用一个布尔标志位。useEffect(() => { let cancelled = false; fetchOrderList(params).then((data) => { if (cancelled) return; setOrders(data.list); }); return () => { cancelled = true; }; }, [params]);这段代码的核心思想是:请求本身无法撤回,但我们可以撤回“响应的处理权”。cancelled变成true之后,哪怕旧请求回来了,.then里的setOrders也不会执行。这个方案兼容所有请求库,包括自己封装的axios实例、request函数等。3.4 订阅与连接(WebSocket、消息通道)实时场景里的 WebSocket 连接如果不清理,切路由之后连接仍然存在,回调里再setState就会触发“对已卸载组件的状态更新”警告(React 18 里这个警告弱化了,但问题依然存在)。useEffect(() => { const ws = new WebSocket("wss://example.com/socket"); ws.onmessage = (e) => { setLiveData(JSON.parse(e.data)); }; return () => { ws.close(); }; }, []);类似的还有EventSource(SSE 连接)、BroadcastChannel、postMessage监听等。凡是“创建了连接/订阅了消息”的副作用,清理函数里一定要做对应的“断开/退订”操作。3.5 动画帧与滚动计算requestAnimationFrame在动画场景里非常常见。如果不取消,动画回调会一直循环执行,而且动画循环不会自然结束,只能通过cancelAnimationFrame主动中断。useEffect(() => { let rafId; const loop = () => { // 计算并设置元素位置 rafId = requestAnimationFrame(loop); }; rafId = requestAnimationFrame(loop); return () => { cancelAnimationFrame(rafId); }; }, []);3.6 异步初始化与防抖/节流还有一种容易忽略的情况:副作用内部还有嵌套的异步逻辑。比如进入页面先拿用户配置,再用配置去初始化一个图表;或者输入框防抖搜索。useEffect(() => { let cancelled = false; let timer; const runSearch = (keyword) => { clearTimeout(timer); timer = setTimeout(() => { if (cancelled) return; fetchSearchResult(keyword).then((data) => { if (cancelled) return; setResults(data); }); }, 300); }; runSearch(keyword); return () => { cancelled = true; clearTimeout(timer); }; }, [keyword]);防抖里的clearTimeout和异步标志cancelled最好一起用,因为它们负责不同层次:clearTimeout阻止“尚未执行的延时回调”,cancelled阻止“已经发出但尚未返回的请求”。4. 竞态条件:异步世界最棘手的对手4.1 竞态是怎么产生的竞态条件(Race Condition)指多个异步任务的执行顺序不确定,最终结果取决于哪个任务“恰好”先完成。前端最常见的竞态就是请求乱序:后发出的请求先返回,先发出的请求后返回,最终状态被旧数据覆盖。我在第一节里说的订单列表 Bug,就是竞态的教科书案例。除了请求乱序,竞态还有两种变体:连续触发场景:搜索框每敲一个字发一次请求,第 5 次请求比第 3 次先返回,列表显示第 3 次的结果。页面切换场景:从列表页跳到详情页,列表页的请求才返回,触发已卸载组件的状态更新。4.2 常见场景对照表场景竞态表现清理策略筛选条件切换旧条件数据覆盖新条件数据cancelled标志或 AbortController搜索防抖旧关键字结果覆盖新关键字结果清理定时器 + 忽略过期响应分页加载第 1 页数据晚返回,覆盖第 2 页请求前取消上一次请求详情页跳转列表页请求返回后调用 setState组件卸载时中止所有请求图片懒加载快速滚动时早期请求阻塞资源滚动节流 + 取消不可见图片请求4.3 cancelled 标志:最朴素但有效的方案useEffect(() => { let cancelled = false; loadData(params).then((data) => { if (cancelled) return; setData(data); }); return () => { cancelled = true; }; }, [params]);这个方案的理解成本几乎为零,而且它适用于一切异步 API,不管底层是 fetch、axios、XMLHttpRequest 还是自己写的回调。它的局限也很明显:请求本身还在消耗网络资源,只是响应结果被忽略。如果请求很重、量很大,最好还是配合真正的请求取消机制。4.4 为什么“请求发出去了”不等于“响应能安全落地”从 JS 引擎的角度看,fetch 发起以后,.then回调何时执行完全由网络和事件循环决定。React 不知道也不关心这个回调是否还应该被处理。onCleanup 的核心价值,就是在 React 的“组件世界”和“外部异步世界”之间搭一座可控的桥:当组件这个“接收方”已经退出时,通知异步任务“你的结果不再需要了”。这才是“处理异步副作用”这句话的本质——不是让异步操作不发生,而是让异步操作的结果永远不落到错误的地方。5. 进阶:用 AbortController 真正“取消”请求5.1 AbortController 的基本用法AbortController是 Web 平台的标准 API,浏览器内置,专门用来“取消”基于 fetch 的网络请求。Node.js 从 15 版本左右也开始支持。const controller = new AbortController(); fetch("/api/orders", { signal: controller.signal, }).catch((err) => { if (err.name === "AbortError") { console.log("请求被取消,忽略这个错误"); return; } throw err; }); // 取消请求 controller.abort();注意.catch里的判断:请求被abort()后,fetch 返回的 Promise 会 reject 一个DOMException,其name属性是"AbortError"。如果不加这个判断,这个取消动作会被当成正常错误处理,可能触发全局错误上报或用户提示。5.2 在 onCleanup 中联动:取消与标记双保险useEffect(() => { const controller = new AbortController(); fetchOrderList(params, { signal: controller.signal }) .then((data) => { setOrders(data.list); }) .catch((err) => { if (err.name === "AbortError") return; // 其他真实错误处理 }); return () => { controller.abort(); }; }, [params]);这一段代码基本就是我在项目里最常用的写法。controller.abort()会同时做两件事:让 fetch 的 Promise 立即 reject(.catch里被AbortError分支过滤掉),并且让浏览器真正断开网络连接,释放资源。如果项目中用 axios,它的 CancelToken(旧版)和signal(新版)也支持类似模式。其他请求库请查阅各自的取消机制文档,思路完全一致。5.3 结合请求封装库的注意事项在真实项目中,请求往往被封装成统一的request函数。封装时如果忽略透传signal,onCleanup 里的controller.abort()就起不到作用。我在某公司项目里就遇到过这个坑:封装层用fetch但只透传了 url、method、data,把 signal 丢掉了,导致所有请求的取消都是“假取消”。正确的封装姿势很简单:export function request(url, options = {}) { return fetch(url, options).then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }); } // 使用时 useEffect(() => { const controller = new AbortController(); request("/api/orders", { signal: controller.signal }) .then((data) => setOrders(data)) .catch((err) => { if (err.name === "AbortError") return; toast.error("加载失败"); }); return () => controller.abort(); }, [params]);只要封装层支持透传options,调用方就能自由地往里面塞signal。6. 实践中反复踩过的四个坑与对应解法6.1 依赖数组漏掉关键变量如果 effect 内部使用了某个变量,但它不在依赖数组里,清理函数和副作用都可能在“旧值”上运行。// 错误示例 const userId = getUserId(); useEffect(() => { let cancelled = false; fetchUserInfo(userId).then(() => { if (cancelled) return; setUserInfo(data); }); return () => { cancelled = true; }; }, []); // userId 漏了这个问题的隐蔽之处在于:userId变了,effect 不会重新执行,旧的请求可能返回并设置了错误用户的信息。排查方法很简单——打开 ESLint 的react-hooks/exhaustive-deps规则,编辑器会直接标黄。强烈建议这条规则落地到团队规范里,它救过我多次。6.2 清理函数里的“过期闭包”有一种写法容易踩:把清理函数单独抽出来,或者在里面访问了外部已经改变的值。useEffect(() => { const doFetch = () => { fetch("/api/data").then((data) => { if (!cancelledRef.current) setData(data); }); }; doFetch(); return () => { // 假设这里想读取最新的 keyword console.log(keyword); // 可能拿到的是旧值 }; }, [keyword]);React 官方文档明确说过:每个 effect 和它对应的清理函数,都在同一次渲染中生成,它们共享那一次渲染的 props 和 state。如果清理函数需要“最新”的值,应该用ref来持有可变值,而不是直接在清理函数里读取外部变量。const keywordRef = useRef(keyword); keywordRef.current = keyword; useEffect(() => { let cancelled = false; fetchData(keywordRef.current).then((data) => { if (cancelled) return; setData(data); }); return () => { cancelled = true; console.log("清理时最新值:", keywordRef.current); }; }, [keyword]);6.3 async 函数直接写进 useEffect// 错误示例 useEffect(async () => { const data = await fetchData(); setData(data); }, []);这样写的问题很明显:useEffect期待第一个参数返回“函数或 undefined”,但async函数永远返回 Promise。虽然 React 不会立刻报错,但这个 Promise 会被忽略,你根本没有地方放清理函数。同时,回调内await之后的代码不管组件是否卸载都会执行,等于完全放弃了清理能力。正确做法是:外层普通函数,内部再声明 async 调用。useEffect(() => { let cancelled = false; const load = async () => { const data = await fetchData(); if (cancelled) return; setData(data); }; load(); return () => { cancelled = true; }; }, []);6.4 StrictMode 双执行让请求发两次React 18 在开发环境下默认开启StrictMode,组件初次挂载时 effect 会执行两次:挂载 → 清理 → 再挂载。这不是 Bug,是 React 故意帮你暴露问题。如果你发现控制台里同一个请求出现了两次网络请求,原因很有可能是:你确实写了正常的清理逻辑但没生效(检查 cleanup 是否真的执行)。你的请求本身放在了 effect 外面(比如模块顶层、组件函数体内直接调用)。你在依赖数组里塞了一个每次渲染都变化的对象(每次渲染都触发 effect,叠加 StrictMode 更明显)。比如:const params = { status, dateRange }; // 每次渲染都是新对象 useEffect(() => { fetchData(params); }, [params]); // 每次渲染依赖都不同,effect 每次都跑解法是把依赖项拆成原始值,或者用useMemo稳定引用。这个坑在真实项目里出现频率极高,排查时先拿掉 StrictMode 试一次,如果请求次数恢复正常,那就说明 effect 的依赖设计有问题。6.5 一个容易被忽略的“隐藏注册”最后补一个冷门但真实的场景:某些第三方 SDK 的回调注册,不会返回值对应注销函数。比如地图 SDK 的map.on("click", handler),虽然提供了map.off("click", handler),但必须保证 handler 引用一致。组件复用时如果 handler 是内联箭头函数,每次渲染都是新引用,off移除不了旧监听。解决方式有两种:把 handler 用useCallback稳定化,或者在 effect 内部只注册一次。const handleMapClick = useCallback((e) => { setSelectedMarker(...); }, []); useEffect(() => { map.on("click", handleMapClick); return () => { map.off("click", handleMapClick); }; }, [handleMapClick]);7. 我现在的习惯与最后一点体会写了这么多,总结一下我现在写 useEffect 时几乎固定的动作:先问自己这个副作用“什么时候结束”和“结束时要恢复什么”;然后无脑给 effect 配一个清理函数,哪怕暂时为空——至少把cancelled标志写好;网络请求一律挂 AbortController,.catch里永远先判断是不是AbortError;依赖数组交给 ESLint 插件自动检查,坚决不手动关闭这个规则。再分享一个小技巧,适用于接口特别多的页面:把“请求 + 取消”封装成一个自定义 Hook,比如useFetchData,内部统一处理取消逻辑,业务组件就再也看不到AbortError的干扰了。说实话,onCleanup 这个概念本身不难,难的是从“会写”到“记得用”。很多线上 Bug 排查到最后都是同一个问题——某个 effect 忘了清理,某个请求忘了忽略过期响应。我现在拿到任何一段 useEffect 代码,第一反应都是:它的清理函数写了吗?写对了吗?执行时机对吗?这三个问题想清楚,异步副作用带来的十之八九的问题都能提前拦住。
说实话,第一眼看到“使用 onCleanup 处理异步副作用”这个标题,我脑子里立刻蹦出了去年在某项目里排查过的一个线上问题:用户快速切换筛选条件,列表数据偶尔会“跳”回上一次的状态,刷新页面又恢复正常。当时我和 A 同学查了大半天,最后定位到根因就是 useEffect 里的异步请求没有正确处理清理逻辑。那是我第一次意识到,onCleanup(也就是 useEffect 返回的那个清理函数)不只是“卸载组件时做点收尾”这么简单,它是前端异步世界秩序感的来源。这篇文章我想把这些经验完整地梳理一遍。内容会覆盖:onCleanup 的底层执行机制、六种高频异步副作用的清理写法、竞态条件的完整成因与解法、用 AbortController 真正取消网络请求,以及我在实践中反复踩过的坑。适合刚接触 React Hooks 不久、对 useEffect 清理逻辑一知半解的前端开发,也适合已经写过不少组件但偶尔被“幽灵更新”困扰的朋友。1. 线上诡异 Bug:切个筛选条件,列表却“跳到”了旧状态1.1 复现路径与表象某运营后台的订单列表页,顶部有三个筛选条件:时间范围、订单状态、支付渠道。用户选择任意条件后,前端会发请求拉取新数据并渲染表格。表面上看功能一切正常,但只要用户操作够快——比如在 500 毫秒内连续切换两次“订单状态”,表格内容就有概率出现“错乱”:明明选的是“已退款”,表格里却先显示了“已支付”的数据,过一两秒才被新数据覆盖,甚至有时候直接被旧数据顶掉,一直不刷新。这个 Bug 最恶心的地方在于:它不固定复现,只在网络环境差、接口响应慢的时候频繁出现。本地开发环境几乎看不出来,因为 localhost 的响应太快,旧请求早就返回完了。1.2 根因:没有清理的异步副作用把代码简化后,当时的问题长这样:useEffect(() => { // 这里省略了参数拼接逻辑 fetchOrderList({ status, dateRange, channel }).then((data) => { // 不管请求是不是当前组件发起的,只要返回就更新状态 setOrders(data.list); }); }, [status, dateRange, channel]);这段代码看似合理:依赖项变了就重新请求。但它漏了一个关键问题——当依赖项从 A 变成 B 时,A 对应的那个请求并没有被中断或忽略。它仍然在网络上“飞着”,等它返回时,.then回调照样执行,照样调用setOrders。于是时间线上出现了这样的交错:用户先选“已支付”,请求 1 发出,响应较慢。用户紧接着改选“已退款”,请求 2 发出,响应较快。请求 2 先返回,表格渲染“已退款”的数据,正确。请求 1 后返回,表格被覆盖渲染成“已支付”的数据,错误。这就是典型的异步竞态问题(Race Condition)。组件本身没有被卸载,useEffect 也没有报任何警告,但副作用的结果已经不属于当前状态了。要解决它,就得让“旧副作用”在“新副作用”开始之前被清理掉——这正是 onCleanup 的用武之地。2. onCleanup 到底是什么:React 的“退场机制”拆解2.1 useEffect 的返回值:不是可选项很多人刚开始学 React Hooks 时,会下意识忽略useEffect的返回值。实际上,useEffect的第一个参数(那个函数)可以返回另一个函数,这个返回的函数就是清理函数(cleanup function),在 React 社区里也常被叫做 onCleanup。useEffect(() => { // 副作用逻辑 return () => { // 清理逻辑 }; }, [deps]);关键在于理解:清理函数不是只在组件卸载时才执行。它还会在每一次依赖项变化、下一次副作用重新执行之前执行。这一点极其重要。2.2 清理函数的执行时机:卸载、依赖变化、严格模式我把 React 官方文档和实际运行表现结合,整理出清理函数的四个执行时机:时机场景发生的阶段依赖项变化前[status]从"已支付"变成"已退款"新的副作用执行前,React 先调用上一次的清理函数组件卸载时if (show) return <List />变成return null组件从页面移除前严格模式开发环境组件初次挂载React 故意模拟“挂载→清理→再挂载”页面隐藏或组件被缓存配合某些路由动画或缓存方案取决于具体库的实现大多数时候我们只关注前两种,但这两种已经覆盖了 95% 的异步副作用问题。任何持续性的资源——定时器、事件监听、网络请求、WebSocket 连接——都应该能在这些时机被“回收”。2.3 一个生活中的类比如果把 React 组件比作一个租出去的房间,那 useEffect 就是租客入住时签的“设备使用协议”。每次依赖项变了,相当于协议到期、重新续签,React 会先让上一任租客把东西收拾好(清掉旧定时器、断开旧监听、取消旧请求),然后才让新租客入住。如果不收拾,房间里就会堆满上一任留下的垃圾——旧请求的回调就是其中最脏的那部分。3. 六种高频异步副作用的清理姿势3.1 定时器与延时任务setTimeout和setInterval是最典型的“持续性副作用”。不清除的话,轻则多打一次日志,重则定时器持有组件引用导致内存泄漏。useEffect(() => { const timer = setInterval(() => { refreshData(); }, 5000); return () => { clearInterval(timer); }; }, []);这里有个细节:setInterval返回的 timer id 在浏览器里是数字,在 Node 环境里是对象,但clearInterval都能正确识别。清理动作必须在清理函数内部直接调用,不要在外面封装一层“可能被忘记调用”的函数。3.2 DOM 事件与全局监听通过addEventListener挂在window、document、body上的监听器,不会因为组件卸载而自动移除。如果不手动清理,回调会在组件销毁后继续触发,甚至多次触发。useEffect(() => { const handleResize = () => { setViewportWidth(window.innerWidth); }; window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); }; }, []);一个很容易被忽视的点是:addEventListener和removeEventListener必须传入同一个函数引用。如果在清理函数里重新写一个匿名函数,是移除不掉的。所以监听函数名要么声明在 effect 内部用常量持有,要么抽成组件外部的稳定函数。3.3 网络请求(基础版:忽略过期响应)如果不想上 AbortController(后面会讲),最基础的做法是用一个布尔标志位。useEffect(() => { let cancelled = false; fetchOrderList(params).then((data) => { if (cancelled) return; setOrders(data.list); }); return () => { cancelled = true; }; }, [params]);这段代码的核心思想是:请求本身无法撤回,但我们可以撤回“响应的处理权”。cancelled变成true之后,哪怕旧请求回来了,.then里的setOrders也不会执行。这个方案兼容所有请求库,包括自己封装的axios实例、request函数等。3.4 订阅与连接(WebSocket、消息通道)实时场景里的 WebSocket 连接如果不清理,切路由之后连接仍然存在,回调里再setState就会触发“对已卸载组件的状态更新”警告(React 18 里这个警告弱化了,但问题依然存在)。useEffect(() => { const ws = new WebSocket("wss://example.com/socket"); ws.onmessage = (e) => { setLiveData(JSON.parse(e.data)); }; return () => { ws.close(); }; }, []);类似的还有EventSource(SSE 连接)、BroadcastChannel、postMessage监听等。凡是“创建了连接/订阅了消息”的副作用,清理函数里一定要做对应的“断开/退订”操作。3.5 动画帧与滚动计算requestAnimationFrame在动画场景里非常常见。如果不取消,动画回调会一直循环执行,而且动画循环不会自然结束,只能通过cancelAnimationFrame主动中断。useEffect(() => { let rafId; const loop = () => { // 计算并设置元素位置 rafId = requestAnimationFrame(loop); }; rafId = requestAnimationFrame(loop); return () => { cancelAnimationFrame(rafId); }; }, []);3.6 异步初始化与防抖/节流还有一种容易忽略的情况:副作用内部还有嵌套的异步逻辑。比如进入页面先拿用户配置,再用配置去初始化一个图表;或者输入框防抖搜索。useEffect(() => { let cancelled = false; let timer; const runSearch = (keyword) => { clearTimeout(timer); timer = setTimeout(() => { if (cancelled) return; fetchSearchResult(keyword).then((data) => { if (cancelled) return; setResults(data); }); }, 300); }; runSearch(keyword); return () => { cancelled = true; clearTimeout(timer); }; }, [keyword]);防抖里的clearTimeout和异步标志cancelled最好一起用,因为它们负责不同层次:clearTimeout阻止“尚未执行的延时回调”,cancelled阻止“已经发出但尚未返回的请求”。4. 竞态条件:异步世界最棘手的对手4.1 竞态是怎么产生的竞态条件(Race Condition)指多个异步任务的执行顺序不确定,最终结果取决于哪个任务“恰好”先完成。前端最常见的竞态就是请求乱序:后发出的请求先返回,先发出的请求后返回,最终状态被旧数据覆盖。我在第一节里说的订单列表 Bug,就是竞态的教科书案例。除了请求乱序,竞态还有两种变体:连续触发场景:搜索框每敲一个字发一次请求,第 5 次请求比第 3 次先返回,列表显示第 3 次的结果。页面切换场景:从列表页跳到详情页,列表页的请求才返回,触发已卸载组件的状态更新。4.2 常见场景对照表场景竞态表现清理策略筛选条件切换旧条件数据覆盖新条件数据cancelled标志或 AbortController搜索防抖旧关键字结果覆盖新关键字结果清理定时器 + 忽略过期响应分页加载第 1 页数据晚返回,覆盖第 2 页请求前取消上一次请求详情页跳转列表页请求返回后调用 setState组件卸载时中止所有请求图片懒加载快速滚动时早期请求阻塞资源滚动节流 + 取消不可见图片请求4.3 cancelled 标志:最朴素但有效的方案useEffect(() => { let cancelled = false; loadData(params).then((data) => { if (cancelled) return; setData(data); }); return () => { cancelled = true; }; }, [params]);这个方案的理解成本几乎为零,而且它适用于一切异步 API,不管底层是 fetch、axios、XMLHttpRequest 还是自己写的回调。它的局限也很明显:请求本身还在消耗网络资源,只是响应结果被忽略。如果请求很重、量很大,最好还是配合真正的请求取消机制。4.4 为什么“请求发出去了”不等于“响应能安全落地”从 JS 引擎的角度看,fetch 发起以后,.then回调何时执行完全由网络和事件循环决定。React 不知道也不关心这个回调是否还应该被处理。onCleanup 的核心价值,就是在 React 的“组件世界”和“外部异步世界”之间搭一座可控的桥:当组件这个“接收方”已经退出时,通知异步任务“你的结果不再需要了”。这才是“处理异步副作用”这句话的本质——不是让异步操作不发生,而是让异步操作的结果永远不落到错误的地方。5. 进阶:用 AbortController 真正“取消”请求5.1 AbortController 的基本用法AbortController是 Web 平台的标准 API,浏览器内置,专门用来“取消”基于 fetch 的网络请求。Node.js 从 15 版本左右也开始支持。const controller = new AbortController(); fetch("/api/orders", { signal: controller.signal, }).catch((err) => { if (err.name === "AbortError") { console.log("请求被取消,忽略这个错误"); return; } throw err; }); // 取消请求 controller.abort();注意.catch里的判断:请求被abort()后,fetch 返回的 Promise 会 reject 一个DOMException,其name属性是"AbortError"。如果不加这个判断,这个取消动作会被当成正常错误处理,可能触发全局错误上报或用户提示。5.2 在 onCleanup 中联动:取消与标记双保险useEffect(() => { const controller = new AbortController(); fetchOrderList(params, { signal: controller.signal }) .then((data) => { setOrders(data.list); }) .catch((err) => { if (err.name === "AbortError") return; // 其他真实错误处理 }); return () => { controller.abort(); }; }, [params]);这一段代码基本就是我在项目里最常用的写法。controller.abort()会同时做两件事:让 fetch 的 Promise 立即 reject(.catch里被AbortError分支过滤掉),并且让浏览器真正断开网络连接,释放资源。如果项目中用 axios,它的 CancelToken(旧版)和signal(新版)也支持类似模式。其他请求库请查阅各自的取消机制文档,思路完全一致。5.3 结合请求封装库的注意事项在真实项目中,请求往往被封装成统一的request函数。封装时如果忽略透传signal,onCleanup 里的controller.abort()就起不到作用。我在某公司项目里就遇到过这个坑:封装层用fetch但只透传了 url、method、data,把 signal 丢掉了,导致所有请求的取消都是“假取消”。正确的封装姿势很简单:export function request(url, options = {}) { return fetch(url, options).then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }); } // 使用时 useEffect(() => { const controller = new AbortController(); request("/api/orders", { signal: controller.signal }) .then((data) => setOrders(data)) .catch((err) => { if (err.name === "AbortError") return; toast.error("加载失败"); }); return () => controller.abort(); }, [params]);只要封装层支持透传options,调用方就能自由地往里面塞signal。6. 实践中反复踩过的四个坑与对应解法6.1 依赖数组漏掉关键变量如果 effect 内部使用了某个变量,但它不在依赖数组里,清理函数和副作用都可能在“旧值”上运行。// 错误示例 const userId = getUserId(); useEffect(() => { let cancelled = false; fetchUserInfo(userId).then(() => { if (cancelled) return; setUserInfo(data); }); return () => { cancelled = true; }; }, []); // userId 漏了这个问题的隐蔽之处在于:userId变了,effect 不会重新执行,旧的请求可能返回并设置了错误用户的信息。排查方法很简单——打开 ESLint 的react-hooks/exhaustive-deps规则,编辑器会直接标黄。强烈建议这条规则落地到团队规范里,它救过我多次。6.2 清理函数里的“过期闭包”有一种写法容易踩:把清理函数单独抽出来,或者在里面访问了外部已经改变的值。useEffect(() => { const doFetch = () => { fetch("/api/data").then((data) => { if (!cancelledRef.current) setData(data); }); }; doFetch(); return () => { // 假设这里想读取最新的 keyword console.log(keyword); // 可能拿到的是旧值 }; }, [keyword]);React 官方文档明确说过:每个 effect 和它对应的清理函数,都在同一次渲染中生成,它们共享那一次渲染的 props 和 state。如果清理函数需要“最新”的值,应该用ref来持有可变值,而不是直接在清理函数里读取外部变量。const keywordRef = useRef(keyword); keywordRef.current = keyword; useEffect(() => { let cancelled = false; fetchData(keywordRef.current).then((data) => { if (cancelled) return; setData(data); }); return () => { cancelled = true; console.log("清理时最新值:", keywordRef.current); }; }, [keyword]);6.3 async 函数直接写进 useEffect// 错误示例 useEffect(async () => { const data = await fetchData(); setData(data); }, []);这样写的问题很明显:useEffect期待第一个参数返回“函数或 undefined”,但async函数永远返回 Promise。虽然 React 不会立刻报错,但这个 Promise 会被忽略,你根本没有地方放清理函数。同时,回调内await之后的代码不管组件是否卸载都会执行,等于完全放弃了清理能力。正确做法是:外层普通函数,内部再声明 async 调用。useEffect(() => { let cancelled = false; const load = async () => { const data = await fetchData(); if (cancelled) return; setData(data); }; load(); return () => { cancelled = true; }; }, []);6.4 StrictMode 双执行让请求发两次React 18 在开发环境下默认开启StrictMode,组件初次挂载时 effect 会执行两次:挂载 → 清理 → 再挂载。这不是 Bug,是 React 故意帮你暴露问题。如果你发现控制台里同一个请求出现了两次网络请求,原因很有可能是:你确实写了正常的清理逻辑但没生效(检查 cleanup 是否真的执行)。你的请求本身放在了 effect 外面(比如模块顶层、组件函数体内直接调用)。你在依赖数组里塞了一个每次渲染都变化的对象(每次渲染都触发 effect,叠加 StrictMode 更明显)。比如:const params = { status, dateRange }; // 每次渲染都是新对象 useEffect(() => { fetchData(params); }, [params]); // 每次渲染依赖都不同,effect 每次都跑解法是把依赖项拆成原始值,或者用useMemo稳定引用。这个坑在真实项目里出现频率极高,排查时先拿掉 StrictMode 试一次,如果请求次数恢复正常,那就说明 effect 的依赖设计有问题。6.5 一个容易被忽略的“隐藏注册”最后补一个冷门但真实的场景:某些第三方 SDK 的回调注册,不会返回值对应注销函数。比如地图 SDK 的map.on("click", handler),虽然提供了map.off("click", handler),但必须保证 handler 引用一致。组件复用时如果 handler 是内联箭头函数,每次渲染都是新引用,off移除不了旧监听。解决方式有两种:把 handler 用useCallback稳定化,或者在 effect 内部只注册一次。const handleMapClick = useCallback((e) => { setSelectedMarker(...); }, []); useEffect(() => { map.on("click", handleMapClick); return () => { map.off("click", handleMapClick); }; }, [handleMapClick]);7. 我现在的习惯与最后一点体会写了这么多,总结一下我现在写 useEffect 时几乎固定的动作:先问自己这个副作用“什么时候结束”和“结束时要恢复什么”;然后无脑给 effect 配一个清理函数,哪怕暂时为空——至少把cancelled标志写好;网络请求一律挂 AbortController,.catch里永远先判断是不是AbortError;依赖数组交给 ESLint 插件自动检查,坚决不手动关闭这个规则。再分享一个小技巧,适用于接口特别多的页面:把“请求 + 取消”封装成一个自定义 Hook,比如useFetchData,内部统一处理取消逻辑,业务组件就再也看不到AbortError的干扰了。说实话,onCleanup 这个概念本身不难,难的是从“会写”到“记得用”。很多线上 Bug 排查到最后都是同一个问题——某个 effect 忘了清理,某个请求忘了忽略过期响应。我现在拿到任何一段 useEffect 代码,第一反应都是:它的清理函数写了吗?写对了吗?执行时机对吗?这三个问题想清楚,异步副作用带来的十之八九的问题都能提前拦住。