资讯动态

消除异步传染性:用执行器把异步污染收拢到小盒子

发布时间:2026/10/15 22:31:01 来源:尧图企业网站定制
不想让 async 污染整条调用链聊聊 JS 里怎么消除异步传染性先说个我最近真实遇到的场景。某天在写一个数据加载函数最初它是一个完全同步的纯函数内部从一个缓存对象里直接取值逻辑很简单function getUser() { return userCache.get(current); }后来因为要接入登录态校验我不得不把它改成异步先从服务端拿最新的用户信息再更新本地缓存。于是getUser变成了async function。从这一刻起噩梦开始了。调用getUser的地方都得加await而加了await的函数必须声明为async声明为 async 之后这个函数的调用方又得继续await……就这么一路传染到最上层的组件挂载逻辑里。原本七八个纯函数和两个组件的改动最后牵扯进去二十多个文件。更恶心的是有些工具函数只是顺手用了getUser()一下它本身压根不该关心数据是同步还是异步来的却被强行改成了异步签名。这就是所谓的异步传染性——一个内部异步操作被迫把整条调用链都变成异步。这篇就聊聊我后来是怎么处理的以及业界常见的几种思路各自的适用边界。1. 传染到底是怎么发生的从一次函数签名变更说起先别急着聊方案把传染这个现象拆开看明白后面所有的取舍都好理解。1.1 async 不是私有实现细节而是对外承诺很多刚入行的人会有一个错觉async只是一个函数内部可以用 await的标志。但实际上函数一旦声明为async它对外界传递了两条硬性信息第一返回值一定是一个 Promise第二调用方必须用await或.then()才能拿到真正的值。这里有一个关键点调用方可以不用await但你不用就拿不到数据。也就是说async 是强制改变调用方式的它不是内部细节而是对外 API 签名的一部分。这就是传染性的根源——你不光改变了自己还逼着上下游一起变。看个最简单的传染链// 原始版本三层同步 function getConfig() { return 1024; } function loadPort() { return getConfig(); // 同步返回直接用 } function main() { console.log(loadPort()); // 1024零成本 }我把getConfig改成从配置文件里读取注意只要它是异步读取async function getConfig() { const raw await fs.readFile(config.txt, utf-8); return Number(raw.trim()); } function loadPort() { return getConfig(); // 返回值变成 Promise不再是数字 }loadPort明明一行没改但它的返回值从数字悄悄变成了Promise 对象。接下来所有消费loadPort()的地方全部要加await——哪怕它们只是把端口号拼进一个字符串里。这还没完任何一个async函数内部调用了另一个async函数一旦用await调用方也变成async。于是从叶子节点开始一层层往上污染整棵调用树。实际项目里这个传染链通常比这个例子严重得多一改就是二三十个文件的联调改动还有一堆单元测试需要同步改 mock 方式。1.2 被传染的代价不只是多写一个 await很多人觉得多写一个await有啥大不了的实际上牵连远不止于此所有中介函数被迫改变签名调用方全部需要感知异步哪怕它们对异步结果既无感知也无需求。函数一旦 async它内部的同步异常机制就失效了throw会被包装成 rejected Promisetry...catch 的写法全部要评估是否需要同步改掉。批量并发的代码结构会扭曲比如Promise.all、竞态处理这些逻辑很容易被一层层await拆得七零八落。单元测试的 mock 难度翻倍原来同步返回一个值的地方现在要 mock 一个 Promise 或 async 函数。React 类框架里更麻烦render 函数本身不能是 async这让很多数据准备逻辑在组件挂载初期无从下手。因此消除异步传染性不是一个洁癖问题它是真实开发效率问题——函数本来就该纯粹地表达取一个配置算一个结果却被 I/O 的时序硬生生改写了结构。2. 先把希望放低完全消除是不存在的目标是把污染范围压到可控聊方案之前我必须先把话说透在 JavaScript 这门单线程 事件循环的语言里同步代码就是拿不到异步数据这是语言层面的硬限制。没有任何魔法能让fs.readFile的结果在下一行就同步可用——除非像同步语言那样真正阻塞线程那又回到多线程模型跟 JS 的设计背道而驰。所以我们要做的事情本质上是把异步带来的代码结构污染控制在一个范围内而不是消灭异步这个事实本身。这个认知很重要否则你会被各种黑科技带偏。我自己评估方案时有四个标准业务代码的入侵度原有同步调用方要不要改异步函数本身能不能保持逻辑直白能不能继续用 await 写学习成本团队新成员看到这个方案会不会一头雾水边界风险它遇到并发、取消、缓存穿透这些情况时会不会崩下面几个方案其实是在这四个维度上做取舍。2.1 最常规的思路封装 Promise 到全局状态里让调用方拿存储结果既然同步拿不到异步结果那就先让异步任务跑起来把结果存到某个全局可访问的地方后续所有同步代码都从那里取值。这就是很多缓存 预热方案的内在逻辑。const userStore { status: pending, // pending | ready | failed data: null, promise: null, }; export function fetchUserSync() { if (userStore.status ready) return userStore.data; if (userStore.status failed) throw userStore.error; if (!userStore.promise) { userStore.promise loadUserFromServer() .then(user { userStore.status ready; userStore.data user; return user; }) .catch(err { userStore.status failed; userStore.error err; throw err; }); } // 注意这里没有数据可返回但函数签名是同步的 // 状态为 pending 时必须决定怎么办 throw new Error(数据还没就绪请先触发预热); }这个思路的问题很明显如果调用方在 pending 阶段同步调用根本拿不到东西。你只能在初始化阶段等数据回来后再执行业务逻辑或者同步读取时必须保证已有缓存这两种前提下使用。它适合那种启动时预热、运行时只读的场景比如应用配置加载完成后后面所有逻辑都同步读配置。但一旦你的业务天然就是用户点了按钮才去拉数据这就没法用了。所以全局状态缓存只能解决一部分数据必然已就绪的情况它不能真正让第一次异步获取对调用方透明。2.2 把一个怪想法聊透throw 一个 Promise靠捕获机制唤醒这一步我要认真展开因为它是社区里消除异步传染性最有名的骚操作也是很多框架内部休眠/唤醒机制的雏形。如果你第一次听说肯定觉得像巫术但它确实是可运行的。核心思路就一句话让同步调用方在数据未就绪时把正在进行的 Promise 作为异常抛给上层上层感知到抛出来的是 Promise而不是 Error 时就先等它完成完成后再重新执行一遍原函数。第二次调用的时候由于内部数据已经 ready它就能像普通同步函数一样返回了。这个方案有一个知名的 React 先例组件渲染是同步函数但数据在异步获取中怎么办React 的内部机制允许你在渲染函数里throw一个 PromiseReact 捕获后等待它 resolve然后重新渲染。这被称作 Suspense 模式。我们的思路就是把这个机制抽离出来做成一个通用的执行器。我先给个简化版的可运行实现注意这里用的是最直白的写法方便逐行理解// 一个模拟异步资源 function asyncFetchUser() { return new Promise(resolve { setTimeout(() resolve({ name: 张三, age: 28 }), 500); }); } // 同步签名但内部数据未就绪时 throw 一个 Promise function syncGetUser(context) { if (context.user) return context.user; // 关键这边把一个 Promise 抛出去 throw asyncFetchUser().then(user { context.user user; }); } // 最上层的防传染执行器 function runSync(executor) { const context {}; function safeRun() { try { return executor(context); } catch (err) { if (err instanceof Promise) { return err.then(() safeRun()); } throw err; } } return safeRun(); } // 使用方式业务代码全都同步 const user runSync(syncGetUser); console.log(user); // { name: 张三, age: 28 }看一眼执行流程第一次safeRun执行syncGetUser发现context.user不存在于是throw一个 Promise。外层 catch 到之后发现这是个 Promise就用.then等它 resolve。Promise 内部干的事情就是往 context 塞数据。then 回调触发后再次safeRun这次context.user存在了于是直接 return 用户对象。到这里你会发现业务函数syncGetUser的签名是同步的内部逻辑也几乎没有异步痕迹整个异步等待被收敛到了runSync这个执行器里面。传染性像被一道墙隔断了一样。2.3 为什么这个throw Promise思路天然适合做无感数据层抛 Promise 这个方案真正的价值是它可以做数据层的透明化访问。我再展开一下它的几个独特性质对于调用方它不需要区分数据是否已经就绪——没就绪我没收到数据而是收到一个 Promise 异常执行器帮我等就绪了直接拿值。对于业务逻辑它保持同步写法几乎完全无侵入尤其适合那种我其实只想要一个配置项来源是文件还是网络我真不在乎的代码。对于框架层它天然支持依赖广度一个执行器可以不管内部有多少个异步资源反正你一次跑不完我就被 throw 几次每次 throw 都能唤醒一次重跑直到所有数据准备完毕。我在实际给团队演示时他们几乎都会问同一个问题这不会导致死循环吗答案是只要每次 throw 的 Promise 完成之后context 里确实新增了数据第二次执行一定不会 throw 同一个 Promise循环就结束了。你要小心的是数据永远无法就绪的极端情况比如网络请求一直 pending——那执行器确实会一直等待但这跟普通 async 函数等待网络请求是等价行为不算异常。接着我把它稍微扩展一下做成一个自动收集依赖的异步数据访问层。核心思想是在执行过程中每个异步资源自己没有直接往 context 塞全局而是通过一个统一的load()辅助函数注册 key那么整个执行器就能知道本次运行依赖了哪些资源function createLazyContext() { const cache new Map(); const pending new Map(); return { load(key, asyncFn) { if (cache.has(key)) return cache.get(key); if (!pending.has(key)) { const promise Promise.resolve(asyncFn()).then(value { cache.set(key, value); pending.delete(key); }); pending.set(key, promise); } // 数据未就绪抛出等待 Promise throw pending.get(key); }, getCache() { return cache; }, }; }业务逻辑就能写得很干净function calcTotal(context) { const price context.load(price, () fetchPrice()); const count context.load(count, () fetchCount()); return price * count; }第一次执行时load(price)抛出了 fetchPrice 的 Promise执行器等待它fetchPrice 完成第二次执行load(price)直接命中 cache然后走到load(count)如果 count 还没有又抛一个 Promise……直到两个都准备齐了最终 return price * count。所有业务代码的调用方比如runSync(calcTotal)都感知不到异步过程。这个模式最大的工程价值在于它把我依赖的数据从哪来、何时来彻底从业务代码中剥离了。对业务代码来说它就是在访问一个同步的 key-value 池子对执行器来说它只有在读取缺失 key 的瞬间才感知异步。这套结构如果做成框架可以非常优雅地支撑 SSR、测试 mock 和复杂依赖编排。3. 用最小实现说清自动重执行的完整链路上一节的代码是最核心的骨架但真正要落地还需要补上并发、失败重试、嵌套执行这几个细节。这块不少照着抄的人会写崩。3.1 并发场景最容易被写崩多个异步依赖同时缺失如果一次执行里同时缺少两个 key比如上面的 price 和 count 都没有第一次calcTotal执行的时候会发生什么最粗糙的执行器第一次执行到load(price)就被 throw 出来外层等待 price 完成然后重跑第二次执行时 price 命中但 count 又缺失再被 throw 出去等待 count……最后是第二次重跑后才算出结果。也就是说如果函数依赖 N 个异步资源就会重跑 N 次。效率有损但功能正确。有些场景问题不大但如果你内部依赖了 10 个资源重跑 10 次每次都可能重复执行一些有副作用的逻辑这就让人很不安了。要改进可以让执行器在第一次运行就收集所有缺失的 Promise等全部就绪后一次性重跑。做法是捕获 phase 与恢复 phase 分离捕获 phase 里把 Promise 异常集中收集到一个数组不用立刻 await等函数跑完或抛出的 Promise 不是缺失资源的异常时再统一Promise.all(收集到的缺失 promise)然后执行恢复 phase。async function runSyncImproved(executor) { const context createLazyContext(); while (true) { const missing []; let result; let error; try { result executor(context); } catch (e) { if (e instanceof Promise !missing.includes(e)) { missing.push(e); // 不急着结束继续执行收集更多 Promise continue; // 注意这里 actually 已经跳出函数了 } throw e; } // 如果 missing 为空说明同步执行完成 if (missing.length 0) { return result; } await Promise.all(missing); } }上面这个写法有个自欺欺人的地方那次执行在 catch 之后就真的结束了后面的业务代码不会跑所以不可能一个执行周期内收集所有缺失。要真正实现一次收集全部就不能用 throw 中断执行流得换成context.load内部自己收集到当前缺失集合然后静默跳过本次剩余业务代码——但同步代码根本没法跳过去继续跑。这就是 JavaScript 的硬限制所在。所以现实的取舍是如果执行器采用 throw 中断的模型依赖 N 个资源就是 N 次重跑。想减少重跑次数可以把同一次执行中需要的数据批量加载比如后端提供一个 batch 接口load 多个 key 时合并请求而不是在单次体积上做文章。因此对一般精度的项目我明确建议接受重跑 N 次但要保证业务函数是纯的幂等、无副作用。如果你确实有一个带副作用的场景比如写日志或发埋点那它不适合用这个模式老老实实用 async 链路反而是更稳的选择。3.2 失败与重试的边界Promise 被 reject 了怎么办当执行器捕获到一个 Promise然后await Promise.all(missing)时任何一个 Promise reject 都会导致整体 reject。此时外部拿到的是一个 rejected Promise。在业务侧你自然而然会想到能不能在再次调用前做一次清空缓存这是必须的。否则 Promise 失败后context 里的 pending Map 没有清理下一次执行如果又访问同一个 key会继续拿到同一个 rejected 的 Promise导致永久失败。正确做法是在失败分支里做这两步把对应的 pending 和 cache 清掉然后抛出一个可读性更好的业务异常同时执行器层面要给一个强制重新执行的控制信号比如resetContext()。下面是一个补救版本async function runSyncWithRetry(executor, { maxAttempts 3 } {}) { let context createLazyContext(); for (let attempt 0; attempt maxAttempts; attempt) { try { // 重新执行不会用旧的 context而是新建一个 context createLazyContext(); return await runOnce(executor, context); } catch (err) { const isResourcePromise err err.then; if (isResourcePromise) { await Promise.all([err]); // 等待它解析/拒绝 // 如果 reject循环到下一次尝试 continue; } throw err; } } throw new Error(重试次数耗尽); }实际项目里重试策略还要区分资源 loading 失败和业务逻辑运行失败。前者是网络/IO问题值得重试后者是代码 bug重试没意义直接抛给上层即可。所以我在团队里给执行器的定位是它负责数据就绪等待而业务连续性由调用方的错误策略负责两者要解耦。3.3 执行器嵌套父执行器套子执行器状态怎么隔离还有一类容易踩坑的点执行器内部如果又调用了另一个执行器可能互相干扰 context。最保险的规则是每个执行器创建自己独立的 context 实例不做全局共享。这也是我上面一直用createLazyContext()实例化而非模块级单例的原因。模块级单例在测试和并发场景里会出各种诡异问题两个请求同时到达共享同一份 cache结果 A 请求的异步数据可能污染 B 请求。业务数据的隔离性应该由业务方根据需要决定框架层不要替它做主。所以我的建议是执行器函数签名尽量显式传入 context没有特殊需求不要让执行器偷偷在模块环境新建共享状态。共享应该发生在更高层的带状态业务对象上比如 store 或 dataSource让业务代码显式感知。4. 更稳的落地路线编译期把 async 调用改造成同步桩用 throw Promise 做执行器属于运行时方案还有一个更工程化的方向编译期代码转换。思路是写代码时用 async但发布前把一个必然返回数据的同步 stub 塞进去或者把 async 函数编译成 generator 配合执行器。这个方向特别适合大型团队因为它把“心智负担”压到了工具链而不是压在业务开发者头上。4.1 把 async 函数转成 generator 执行器的思路从语言机制上看async 函数等价于一个自动驱动的 generator只不过驱动流程被固化了。如果我们能拿到一个 generator 的迭代器手动控制什么时候执行、什么时候暂停就能在等待异步结果时把控制权交回外部执行器——这就是用 generator 消除传染性的底层原理。具体手法大致是写代码的时候异步函数内部照常写await但标注某个工具函数会把它编译成 generator或用 babel 插件转换运行时执行器拿到迭代器每次调用iterator.next()拿到一个{ value, done }如果value是 Promise就等待它 resolve 后再把结果塞回iterator.next(resolvedValue)相当于把 await 的手动调度包了一层。例如业务代码function fetchPrice() { return Promise.resolve(100); } function* calcTotal() { const price yield fetchPrice(); return price * 3; } function runGenerator(genFn) { const iterator genFn(); function step(value) { const result iterator.next(value); if (result.done) return Promise.resolve(result.value); return Promise.resolve(result.value).then(step); } return step(); }这样业务函数虽然写了 yield但调用方永远只需要runGenerator(calcTotal)这一层包装不关心它内部有几个异步。可以说generator 版本的传染性比 async 函数弱很多——因为 generator 本身只是可以被暂停的函数而不是自动返回 Promise 的函数调用方可以在同步环境里有策略地驱动它。不过局部看这是把传染性换成了显式的迭代器驱动并没有凭空消失。工程上的优点在于所有驱动逻辑都集中在 runGenerator 里而不是散落在每个函数签名上这本身就达到了把污染收敛到执行器的目标。4.2 用 babel 插件把 async 转成 generator 的现实工作流这条思路完全可以自动化开发阶段你直接写awaitbabel 插件会把它转成yield并把 wrapper 引进去。这是真实工程里常见的手法。不过这里我不建议一开始就上这种重工具原因有三点大型项目的构建链改造往往牵一发动全身调试源码映射容易出问题团队里不是每个人都理解我的 async 会被编译成什么编译后的产物栈信息不直观遇到问题排查成本高。所以我个人对多数项目的建议是先用运行时执行器团队跑顺以后再考虑要不要上编译期方案。编译期更适合那种业务以数据编排为核心的基建性质项目它可以把异步无感固化为团队范式彻底杜绝 async 传染。4.3 对比这三种路线的选型视角做一个直观对比方便你按自己的场景拍板路线调用方感知业务代码风格实现成本适合场景全局缓存/预热同步但需要数据先就绪同步低启动加载配置类数据throw Promise 执行器同步同步但内部可能重跑多次中数据层透明化、组件渲染、轻量状态流generator 或编译期转换同步需要写 yield / async编译后较高大型数据编排、框架基建表格仅仅是一个指引实际项目里经常混用启动配置用全局缓存页面数据用 throw Promise 执行器核心链路的数据编排用 generator 或编译方案。互为补充不互斥。5. 实战中我踩过的坑哪些消除做了比不做好更糟的决策方案讲完了最后说说我在真实项目落地时遇到的教训。有些坑非常隐蔽写文档的人通常不会提。第一个坑业务函数不纯导致重复执行产生重复副作用。上面提过throw Promise 执行器在多个依赖缺失时会重跑多次。如果业务函数内部有console.log、埋点、自增计数器或发起新请求不是经过 context 缓存的请求重跑 N 次就会造成 N 次副作用。这个很致命。我在一个内部数据面板项目里就踩过一个计算函数每次执行都向审计服务上报一次上线后一小时内报了 6 倍数据。修复方式是把所有带副作用的操作移到执行器外层比如在runSync的完成回调里统一打点。第二个坑把 context 缓存放到了模块级导致并发请求互相串数据。测试环境没并发所以一切正常生产环境两个用户同时访问A 用户等数据时B 用户触发了同一 key 的加载之后 A、B 拿到的可能是同一份 person 数据。这个问题特别容易在先跑通再说的阶段被忽略。我的建议是执行器一定为每次执行创建独立 context如果能命中全局缓存可以通过传入一个共享 cache 的 key 做受控复用而不是把整个 context 全局化。第三个坑Promise reject 后没有清缓存导致请求永远失败。写执行器的第一版我只处理了 pending 状态没处理 failed 状态。某次后端返回 500promise reject但 context 里还留着失败的 pending 引用后面所有同步访问都拿到同一个 rejected Promise页面白屏一整天。后来规定任何一个资源 promise reject执行器必须把它从 pending 和 cache 中剔除并且对外抛出一个描述清晰的资源加载失败xxx错误方便前端定位。第四个坑把执行器用在了本身不稳定的场景上然后怪工具不成熟。比如依赖组件内部状态做条件判断这个状态又是异步更新的那么执行器重跑时状态可能不一致导致运行结果飘忽不定。它不是执行器的问题是你用它去编排了跨帧 UI 状态。记住执行器只负责依赖数据的等待不管 UI 交互状态。第五个坑执行器重跑和 React Hooks 的搭配问题。如果你在 React 组件里用执行器包渲染函数要非常小心组件的渲染副作用。React 的严格模式本身就会双调用渲染函数再叠加执行器的重跑很可能产生你意想不到的调用次数。处理方法是把执行器放在数据层组件本身尽量只消费数据层对外暴露的同步接口不要把执行器直接包在 render 函数里。这五个坑任何一个都能让消除异步传染性从一个好点子变成线上事故。我每次跟团队分享这个方案讲完原理后一定会把这五个坑原原本本讲一遍然后补一句先小范围用用顺了再铺开别一上来就改造核心链路。回到最开始那个场景。我后来把getUser改成了基于 context 的同步读取再配合执行器调用方的函数签名一个都没变所有测试也从 mock Promise 恢复成了 mock 返回值。异步没有被消灭它只是被收拢到了一个小盒子里而这个盒子我再也不想打开了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑