traceId 一进 await 就断线,前端只能层层穿参——今天 TC39 用 AsyncContext 把上下文传播彻底原生化了
你写了一段带 traceId 的请求,日志打得好好的。结果代码一进 await,下游函数里 getTraceId() 返回 undefined——上下文丢了,你只能把 traceId 一层层往参数里塞,塞到最后自己都不记得哪个函数该带、哪个不该带。这件事 Node 里早就有解法,浏览器里前端追了好几年。今天 TC39 的 AsyncContext 提案,就是来把”上下文自动跟着异步流走”这件事原生化的。
先说清楚:为什么它是”丢失”的
问题不在你的代码写错,而在 JS 的运行模型本身。
同步代码靠调用栈传上下文,但 await 一到,调用栈就被拆掉了——异步恢复时是在一个全新的、空的栈上继续跑。你之前偷偷存在某个外部变量里的 currentTraceId,在事件循环转了一圈之后,早就被重置了:
let shared;
function program() {
const value = { traceId: 'abc' };
try {
shared = value;
setTimeout(implicit, 0); // 异步出去
} finally {
shared = undefined; // 同步代码一结束就清掉了
}
}
function implicit() {
// 此时 shared 已经是 undefined
console.log(shared); // 拿不到 'abc'
}
这不是能用用户态代码”绕过去”的 bug——等 implicit 被调用时,调用栈已经换过了,函数根本没有机会去捕获那个引用。async/await 语法让栈替换变得几乎不可察觉,于是这个问题被放大了。
过去前端只有两条臭名昭著的路:
- 手动穿参:每个异步函数都多一个
ctx参数,恶心且容易漏; - Zone.js 那套:靠给 Promise 打补丁拦截,但
async/await是原生语法、绕过了用户态的 Promise,Zone.js 不转译就管不住它。
Node 侧有 AsyncLocalStorage(内部就是同一套思路的雏形),所以后端做链路追踪一直比较顺;浏览器这边一直是空白。
AsyncContext 给了什么
提案的核心是两个东西:
const asyncVar = new AsyncContext.Variable();
asyncVar.run('top', async () => {
// 下面这段无论 await 多少次、setTimeout 多少次,get() 都是 'top'
await doSomething();
console.log(asyncVar.get()); // => 'top'
});
AsyncContext.Variable 是一个”跟着当前执行流走”的值容器:run(value, fn) 把值绑到这段流程上,get() 在任何深层异步调用里都能把它读回来。它的一个关键设计是值作用域按函数界定——你只能在子图里改值,改不到父级或兄弟作用域:
asyncVar.run('A', async () => {
asyncVar.get(); // 'A'
asyncVar.run('B', () => {
asyncVar.get(); // 'B',嵌套向内生效
});
asyncVar.get(); // 回到 'A',外面没被污染
});
另一个是 AsyncContext.Snapshot,给”延迟调度”场景用——比如你有一个回调队列,希望每个回调在执行时看到的是它被入队那一刻的上下文,而不是真正执行时的上下文。快照就是干这个的,不过官方也说了,普通业务开发者基本碰不到它,那是库和框架的实现细节。
最关键的一条约定:注册时快照
提案定了一条很务实的规则:浏览器内建调度器(setTimeout、addEventListener、then)在回调被传入的那一刻,记住当时的快照,之后执行这个回调时还原它。
翻译成人话:你把回调交给浏览器的时候上下文是什么,回调真正跑起来时就是什么,中间事件循环转了多少圈都不影响。这等价于你提前手动 Snapshot.wrap 了每个回调,但不需要你真去写——而且这个 wrap 是幂等的,库作者就算手贱包了一层,也不会出问题。
之所以选”注册时”而不是”执行时”,是因为注册这个动作有明确的时间点,而事件可能由用户交互触发、也可能被程序触发,来源五花八门,没有一个统一的时间基准。
现在能不能用
这是需要泼冷水的地方:它还是 TC39 提案阶段,不是 Baseline,浏览器里暂时没有稳定实现。
- 提案仓库在
tc39/proposal-async-context,还在活跃演进; - 2026 年 6 月 Igalia 的 Web Engine Hackfest 还专门开了一场 “AsyncContext: where are we now?”,讨论它和 Web API 的集成边界;
- 相关子提案(比如用
using做词法绑定的proposal-async-context-disposable)都还在探索,说明这块的 API 形状还没冻死。
所以今天别把它直接写进生产。但有三件事你现在就能做:
- 后端已经在用
AsyncLocalStorage的团队,前端可以先对齐概念——将来AsyncContext.Variable的语义和它高度一致,现在把”上下文靠隐式传播、不靠穿参”当作设计约定,迁移时几乎无痛。 - 库和框架作者现在就该关注——这个提案最大的受益者是 tracing、日志、RSC/SSR 数据获取这类框架,它们终于能不再把
ctx参数暴露成公共 API 的一部分。 - 把你项目里那些”往每个函数里塞 ctx”的代码先标记出来——它们就是将来第一批能删掉的样板。
一句话总结:AsyncContext 把”跨 await 保住一个值”从”各显神通的用户态 hack”变成了语言级能力。它还没到货,但方向已经定了——你现在按它设计,将来就是删代码,而不是改架构。
评论区
登录后可评论。