不想让 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 Promise,try...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一个 Promise,React 捕获后等待它 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 的现实工作流
这条思路完全可以自动化:开发阶段你直接写await,babel 插件会把它转成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 状态。某次后端返回 500,promise reject,但 context 里还留着失败的 pending 引用,后面所有同步访问都拿到同一个 rejected Promise,页面白屏一整天。后来规定:任何一个资源 promise reject,执行器必须把它从 pending 和 cache 中剔除,并且对外抛出一个描述清晰的"资源加载失败:xxx"错误,方便前端定位。
第四个坑:把执行器用在了本身不稳定的场景上,然后怪工具不成熟。比如依赖组件内部状态做条件判断,这个状态又是异步更新的,那么执行器重跑时状态可能不一致,导致运行结果飘忽不定。它不是执行器的问题,是你用它去编排了"跨帧 UI 状态"。记住:执行器只负责"依赖数据"的等待,不管 UI 交互状态。
第五个坑:执行器重跑和 React Hooks 的搭配问题。如果你在 React 组件里用执行器包渲染函数,要非常小心组件的渲染副作用。React 的严格模式本身就会双调用渲染函数,再叠加执行器的重跑,很可能产生你意想不到的调用次数。处理方法是把执行器放在"数据层",组件本身尽量只消费数据层对外暴露的同步接口,不要把执行器直接包在 render 函数里。
这五个坑,任何一个都能让"消除异步传染性"从一个好点子变成线上事故。我每次跟团队分享这个方案,讲完原理后,一定会把这五个坑原原本本讲一遍,然后补一句:先小范围用,用顺了再铺开,别一上来就改造核心链路。
回到最开始那个场景。我后来把getUser改成了基于 context 的同步读取,再配合执行器,调用方的函数签名一个都没变,所有测试也从 mock Promise 恢复成了 mock 返回值。异步没有被消灭,它只是被收拢到了一个小盒子里,而这个盒子我再也不想打开了。