先交代一下背景。我去年接了一个中后台项目列表页筛选条件特别多用户手速一快接口返回顺序就是乱的。最离谱的一次有人快速切换三个查询条件结果最后一条响应反而是第一次请求的数据整个表格数据和筛选条件对不上。排查到最后问题就出在异步副作用上没有做清理。后来我把项目里所有类似逻辑统一改成onCleanup处理这类竞态问题才算彻底绝迹。这篇文章我会把onCleanup处理异步副作用的完整经验拆开讲从原理、常规写法和常见误区到请求竞态、轮询、订阅、防抖这些实际场景的代码方案最后再分享一些排查内存泄漏的调试套路。不光是给出代码我更想把每一个设计背后的为什么说清楚。1. 为什么异步副作用一定要被“收拾干净”先理解一个概念副作用就是组件在渲染之外做的额外动作发请求、开定时器、挂监听、建订阅全是副作用。而异步副作用指的是这些动作的结果不会立刻回来要等网络、等时间、等事件。等的过程里组件可能已经卸载了或者依赖已经变了这时候结果才回来就会出问题。1.1 异步副作用失控的三个典型症状我归纳了三个最常见的失控症状你大概率也踩过。第一个是竞态覆盖。快速触发多次操作旧请求比新请求后返回把新数据覆盖掉了。最常见的就是搜索框用户连续输入几个关键词前面的慢请求最后到达把后面的正确结果顶掉。第二个是内存泄漏。组件卸载之后定时器还在走监听器还挂着订阅还在收数据。短时间看不出问题页面切来切去几十次之后内存曲线一路往上爬最后页面卡顿到无法操作。第三个是资源无效占用。比如一个加载动画组件已经卸载了异步任务还在飞虽然现代框架不会因为更新已卸载组件的状态而直接崩溃但控制台警告、网络请求白跑、loading 状态卡住不消失都是它造成的。1.2 Cleanup 的本质一个倒计时回收机制如果你用过装修或者搬家服务大概能理解“退租前要把房子恢复原样”这个规则。onCleanup就是给副作用定的这个规矩你在某个生命周期阶段里开了什么东西就必须在对应结束阶段把它关掉。以常见的组件化前端框架为例组件渲染时注册副作用组件卸载前执行清理函数做对称的回收。如果副作用所在的渲染函数因为依赖变化而重新执行旧副作用也要先被清理再创建新的副作用。这个“先清理、后重建”的顺序是理解onCleanup执行时机的关键。我习惯把它理解成一个“每次任务开始前先把上一个任务没做完的事撤销掉”的机制。注意是每次不是只在最后卸载时执行一次。1.3 onCleanup 与依赖重跑的联动关系很多人的误区是以为onCleanup只在组件卸载时触发。实际上依赖变化导致渲染函数重新执行之前上一次注册的清理函数就会先跑一遍。也就是说同一份逻辑里清理函数可能被调用很多次每次重跑前一次最终卸载前一次。这个设计非常聪明。它把“撤离现场”的频率和副作用本身的频率对齐了。副作用重跑一次旧现场就清理一次副作用彻底结束现场也彻底清理。这样既能避免累积又能保证每次重跑都是从干净状态开始的。理解这个联动关系后面解决竞态问题就会顺手很多。竞态的根源其实就是旧副作用还在飞新副作用已经开始了而清理机制给了我们一个机会在重跑之前把旧异步任务标记为无效。2. onCleanup 核心 API 用法与 3 个常见误区写代码之前先把 API 本尊看清楚尤其是执行时机和注册方式这两个点最容易出问题。2.1 基本写法与执行时机onCleanup的用法非常直白在副作用函数体内同步调用传入一个清理函数。举个最常见的例子一个组件在挂载后发请求卸载时取消请求import { createEffect, onCleanup } from solid-js; createEffect(() { const controller new AbortController(); fetch(/api/user/profile, { signal: controller.signal }) .then((res) res.json()) .then((data) { // 正常更新组件状态 setProfile(data); }) .catch((err) { // 如果是取消导致的错误这里会被触发需要注意区分 if (err.name ! AbortError) { console.error(err); } }); // 在同步代码中注册清理函数 onCleanup(() controller.abort()); });执行时机有两个依赖变化导致此渲染函数重新执行前先调用上一次注册的 cleanup组件卸载时调用最后一次注册的 cleanup。所以这里controller.abort()会调用两次一次是依赖变了把旧请求取消掉给新请求腾位置一次是组件卸载把还没完成的请求取消掉。2.2 误区一以为 cleanup 只在卸载时执行这是最普遍的一个误解。只把onCleanup当成“卸载收尾”工具忽略了它在依赖重跑前也会触发于是写出的代码在单次挂载卸载场景下没问题一旦依赖频繁变化老请求并不被取消竞态照样出现。实际项目里依赖变化触发清理的频率可能远高于卸载。处理数据筛选、搜索、分页这些场景时effect 因为查询参数变化而重跑是家常便饭。正确理解是onCleanup的每一次调用都是在为接下来可能发生的一切变化做准备。2.3 误区二在 effect 外层注册 cleanup有些人会把onCleanup写在副作用函数外面试图用一次注册通吃所有情况。这在许多框架里是不被允许的要么直接警告要么注册的清理函数永远不会和正确的作用域绑定。正确做法是每次创建副作用时同步地、在副作用函数体内注册对应的清理函数。一次副作用对应一次清理彼此配对。这就像租了一间房退租时只清理自己那间不会去清理整栋楼。2.4 误区三在 cleanup 里回调异步函数在清理函数里做异步操作要非常小心。因为 cleanup 函数本身是同步调用的它启动一个异步任务但没人会等这个异步任务完成再继续执行后续清理。如果清理逻辑依赖异步结果顺序就乱了。举个例子如果有人在 cleanup 里想等一个动画结束再移除节点onCleanup(async () { await playExitAnimation(); node.remove(); });这个动画确实启动了但调用onCleanup的引擎不会等待它结束框架可能紧接着就处理卸载流程动画还没放完节点已经不见了甚至可能在异步回调里触碰组件状态导致警告。推荐的替代方案用 transition group 这类专门的动画封装处理退出动画而不是在 cleanup 里执行异步逻辑。cleanup 里只做同步撤销取消请求、清定时器、移除监听器、关闭连接。3. 异步副作用的 5 类实战场景与代码方案原理讲再多不如真实场景里过一遍。我挑出项目里最常遇到的五类异步副作用逐个给出可直接抄的写法。3.1 请求竞态用一个标志位守住 UI先看这段有问题的代码createEffect(() { let cancelled false; fetch(/api/list?keyword${keyword()}) .then((res) res.json()) .then((data) { setList(data); }); onCleanup(() { cancelled true; }); });注意上面的代码并不完整里面少了一个关键判断在更新状态前要检查cancelled标志。完整版应该是这样createEffect(() { let cancelled false; fetch(/api/list?keyword${keyword()}) .then((res) res.json()) .then((data) { // 只有在请求仍然“有效”时才更新状态 if (!cancelled) { setList(data); } }); onCleanup(() { cancelled true; }); });逻辑很清晰effect 重跑时旧 effect 的 cleanup 先把cancelled置为 true等旧请求回来一看自己被取消了就放弃更新状态。新请求自然也就不会被旧请求覆盖。用标志位的好处是通用无论你用的是 fetch、axios 还是别的请求库都能套。坏处是请求本身还在网络层跑着只是结果被丢弃了。如果还想从源头把请求取消掉就需要结合下一节的AbortController。注意cancelled变量的生命周期是绑定在本次 effect 执行上的。每次 effect 执行它都是一个新的变量旧 effect 的cancelled和旧 effect 的请求闭包共享新 effect 也有自己的一份互不干扰。这种闭包隔离是它能够工作的底层原因。3.2 轮询任务让定时器随组件生命周期收放轮询是最典型的定时器副作用。写不好就是页面切走之后定时器还在后台疯狂发请求白白消耗带宽和服务器资源。createEffect(() { // 轮询逻辑 const fetchStatus () { fetch(/api/task/status) .then((res) res.json()) .then((data) setTaskStatus(data)) .catch((err) console.error(err)); }; fetchStatus(); // 先立即执行一次 const timer setInterval(fetchStatus, 5000); // 然后每5秒轮询 onCleanup(() { clearInterval(timer); }); });依赖变化或者组件卸载时定时器都会被清掉。这里有个小细节先执行一次立即请求再开定时器。如果漏了立即执行就得等第一个5秒周期结束后才会看到数据体验会慢半拍。还有一个进阶需求轮询间隔动态调整。比如后台任务执行中5秒轮询一次任务快结束时希望变成1秒轮询一次。可以这样处理createEffect(() { const intervalMs taskProgress() 80 ? 1000 : 5000; const timer setInterval(poll, intervalMs); onCleanup(() clearInterval(timer)); });每次依赖变化旧定时器被清理新间隔的定时器被创建。实现原理就是前面说的“先清理、后重建”。如果不用onCleanup旧定时器和新定时器会同时存在轮询频率会越来越快最终彻底失控。3.3 外部订阅监听器的添加与拆除订阅外部事件源是前端的常规操作比如窗口尺寸变化、鼠标位置、WebSocket 消息、store 变更。这类副作用的通病是只记得往全局对象上挂监听忘记在 cleanup 里拆除。正确的写法长这样createEffect(() { const handleResize () { setWindowWidth(window.innerWidth); }; window.addEventListener(resize, handleResize); onCleanup(() { window.removeEventListener(resize, handleResize); }); });这里有一个常见坑addEventListener和removeEventListener必须传同一个函数引用。如果写成箭头函数的内联形式每次注册的都是一个新函数remove 时按引用找找不到监听器就永远清理不掉。再看一个带数据的订阅以某个实时消息通道为例createEffect(() { const connection createConnection(roomId()); const unsubscribe connection.subscribe((message) { setMessages((prev) [...prev, message]); }); // 假设 createConnection 返回的连接对象也有 close 方法 onCleanup(() { unsubscribe(); connection.close(); }); });这里把订阅和连接关闭一起放进 cleanup确保切换房间或者退出页面时不会继续接收上一个房间的消息。如果你发现自己重复地写“订阅-清理”这套模板可以考虑封装成工具函数。补充一点订阅的场景里如果unsubscribe之后还收到了最后一条消息属于正常情况因为消息可能已经在事件队列里了。关键是保证 unsubscribe 之后不再触发状态更新即可。3.4 防抖输入清理上一次的等待窗口防抖是搜索框的标配用户停止输入一段短暂时间后才发请求减少无效请求数量。实现核心其实就是用一个定时器每次输入都清掉上一个定时器重新计时。createEffect(() { const timer setTimeout(() { fetch(/api/search?q${keyword()}) .then((res) res.json()) .then((data) setSearchResults(data)); }, 300); onCleanup(() { clearTimeout(timer); // 注意这里不能只是 clearTimeout还需要把正在进行的请求取消 }); });这段代码的问题在于即使定时器被清了如果上一次的请求已经发出去并且还在路上结果回来时还是会更新状态。所以防抖场景需要把 3.1 里的标志位或者AbortController也一起用上。组合版createEffect(() { const controller new AbortController(); const timer setTimeout(() { fetch(/api/search?q${keyword()}, { signal: controller.signal }) .then((res) res.json()) .then((data) setSearchResults(data)) .catch((err) { if (err.name ! AbortError) { console.error(err); } }); }, 300); onCleanup(() { clearTimeout(timer); controller.abort(); }); });这样每次输入变化上一次的定时器被清掉请求被取消等用户真正停下来300毫秒后才发出一个有效请求。用户输入得越快被取消的请求越多服务器压力就越小。3.5 大对象/媒体资源释放避免内存只增不减这一条容易被忽略因为不像请求竞态那样立刻可见但影响很大。比如动态创建的Blob URL、ImageBitmap、Web Worker、媒体流这些对象如果不释放内存占用会一路飙升。createEffect(() { const imageUrl URL.createObjectURL(blobData()); // 假设在界面上绑定这个 URL 给某个 img 标签 setPreviewUrl(imageUrl); onCleanup(() { URL.revokeObjectURL(imageUrl); }); });类似的还有创建 WorkercreateEffect(() { const worker new Worker(/worker.js); worker.postMessage({ type: start }); onCleanup(() { worker.terminate(); }); });每次依赖变化或者组件卸载时旧资源被释放不会堆积。这类清理尤其要养成习惯因为浏览器开发者工具的内存面板里这些资源占用通常显示为 JS heap 之外的部分普通性能分析不容易察觉等到察觉时页面往往已经拖不动了。4. 把 onCleanup 用出工程化味道封装与组合如果项目里到处都是上面那几段模板代码维护起来会有点疲惫。我建议顺着自己的技术栈封装几个实用工具把onCleanup的使用模式收敛起来减少出错率。4.1 一个实用的请求执行器以某个组件化框架为例我们可以写一个自定义的原语把“发请求 取消 竞态防护”打包在一起import { createEffect, onCleanup } from solid-js; function useAsyncAction(action, immediate true) { const [state, setState] createSignal({ loading: false, data: null, error: null, }); const run (...args) { setState({ loading: true, data: null, error: null }); const controller new AbortController(); action(...args, controller.signal) .then((data) { setState({ loading: false, data }); }) .catch((error) { if (error.name ! AbortError) { setState({ loading: false, error }); } }); // 返回一个可以取消本次请求的函数可选 return () controller.abort(); }; createEffect(() { if (!immediate) return; // 约定 action 第一个参数是执行参数 const cancel run(...args()); onCleanup(cancel); }); return { state, run }; }这个封装有两个受益点既把竞态防护内置了又把取消逻辑和生命周期绑在了一起。实际使用时只需要传入真正干活的函数其余细节全部隐藏。这里算是一个示例设计不同的技术栈有各自的写法但思路是通用的把“取消”和“清理”作为一等公民纳入抽象接口层。4.2 管理多个异步任务统一 AbortController一个页面里经常同时有多个异步任务。每个任务单独注册一个 cleanup 是可以的但如果你希望在一个清理函数里统一收尾可以使用一个共享的AbortController或者维护一个取消函数集合。createEffect(() { const controllers []; const request (url, options) { const controller new AbortController(); controllers.push(controller); return fetch(url, { ...options, signal: controller.signal }); }; request(/api/data); request(/api/config); request(/api/permission); onCleanup(() { controllers.forEach((controller) controller.abort()); controllers.length 0; }); });这是一种朴素但有效的模式。当多个请求彼此独立但生命周期绑定时集中式管理比分散注册更直观。不过也要小心如果controllers.length 0之后再注册新的请求这个位置的 effect 已经不在 active 状态了新请求就没法被同一个 cleanup 管理。规范的做法是只把同步创建出来的异步任务交给同一个 effect 管理异步回调里再创建的任务要另想办法。4.3 与主流框架的 Cleanup 机制对照onCleanup的命名主要来自某个主打细粒度响应式的前端框架。在生态更庞大的 React 体系里类似的机制是useEffect返回的清理函数。两者思路一致但细节有差异。// React 的写法 useEffect(() { const controller new AbortController(); fetch(/api/data, { signal: controller.signal }); return () { controller.abort(); }; }, [deps]);React 要求手动声明依赖数组依赖变化时先执行上次的返回清理函数再运行本轮 effect。而onCleanup所在的框架依赖自动追踪不需要手动声明依赖数组。至于 Vue 的onScopeDispose又是在组合式 API 的 effect scope 层面提供清理能力。这几种思路没有绝对的优劣核心都是同一件事把副作用的生命周期对齐到组件的生命周期。理解onCleanup的原理之后换到任何框架写 cleanup 都能快速上手。5. 排查与调试像排雷一样定位泄漏写代码时讲究“防患于未然”但线上问题排查才是检验功夫的地方。这套排查方法我自己用了很多次给我省下了大量时间。5.1 DevTools 里看内存与网络内存泄漏首选的检查工具是浏览器开发者工具的 Performance 面板和 Memory 面板。操作流程是这样打开页面先做一两次操作比如切换路由、打开关闭弹窗然后强制触发一次垃圾回收拍一张内存快照。多做几轮同样操作再强制 GC再拍一张。对比前后快照如果内存不降反升、并且持续攀升说明存在未释放的对象。配合 Memory 面板里的 allocation instrumentation on timeline可以录制一段时间的内存分配过程定位到具体是哪类对象不断增长。常见的结果就是 Detached DOM 节点、事件监听器、闭包引用的对象。网络层面的排查更直接打开 Network 面板切换路由后观察有没有“幽灵请求”——页面已经离开了请求还在发。这类请求基本就是没有清理干净的异步副作用。5.2 给 onCleanup 加日志的调试套路定位到某个组件有问题之后最快的确认方式是在 cleanup 里加一行日志验证执行时机是否符合预期。createEffect(() { console.log([ChatRoom] effect 执行roomId , roomId()); const connection createConnection(roomId()); const unsubscribe connection.subscribe(callback); onCleanup(() { console.log([ChatRoom] cleanup 执行释放连接); unsubscribe(); connection.close(); }); });在控制台里观察日志顺序每次依赖变化时应该先看到旧 effect 的 cleanup 日志再看到新 effect 的执行日志。如果顺序反了说明清理时机没有生效如果切换多次页面cleanup 日志数量不等于执行数量说明有分支漏掉了注册。这个方法工作量最小见效最快。排查完记得把日志移除别把调试日志带上线。5.3 线上问题的预防清单与其等线上出问题再去排查不如在代码评审阶段就按清单过一遍。以下是我在团队里推行的检查项每个createEffect、useEffect里创建的副作用是否都有对应的 cleanup异步回调里更新状态前是否检查了取消标志位addEventListener和removeEventListener是否使用同一个函数引用setInterval、setTimeout是否在 cleanup 中清理是否有创建了Blob URL、Worker、WebSocket而没有释放依赖变化时旧请求是否从源头取消了还是仅仅忽略了结果是否有异步回调在组件卸载后仍然执行状态更新如果这些检查项都能通过绝大多数异步副作用相关的问题都能在发布前拦截住。最后说一点个人体会。我在刚开始用onCleanup的时候总觉得它就是“卸载时该干的事”后来被一次线上竞态问题打击过之后才真正理解它的价值。它不是一个可有可无的收尾动作而是组织异步逻辑的骨架。每一次创建副作用时我都会先在脑子里问一句如果这个 effect 只活了一毫秒就重跑了我的清理逻辑能不能兜住底每次答“能”的时候就是对系统健壮性的一次加固。如果你也经常被异步竞态、定时器泄漏或者订阅残留坑到不妨从今天开始给项目里每一个副作用都配上一个onCleanup。改完八成代码之后你会明显感觉到页面切换清爽很多控制台也不会再莫名其妙地刷警告了。