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,给”延迟调度”场景用——比如你有一个回调队列,希望每个回调在执行时看到的是它被入队那一刻的上下文,而不是真正执行时的上下文。快照就是干这个的,不过官方也说了,普通业务开发者基本碰不到它,那是库和框架的实现细节。

最关键的一条约定:注册时快照

提案定了一条很务实的规则:浏览器内建调度器(setTimeoutaddEventListenerthen)在回调被传入的那一刻,记住当时的快照,之后执行这个回调时还原它。

翻译成人话:你把回调交给浏览器的时候上下文是什么,回调真正跑起来时就是什么,中间事件循环转了多少圈都不影响。这等价于你提前手动 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 形状还没冻死。

所以今天别把它直接写进生产。但有三件事你现在就能做:

  1. 后端已经在用 AsyncLocalStorage 的团队,前端可以先对齐概念——将来 AsyncContext.Variable 的语义和它高度一致,现在把”上下文靠隐式传播、不靠穿参”当作设计约定,迁移时几乎无痛。
  2. 库和框架作者现在就该关注——这个提案最大的受益者是 tracing、日志、RSC/SSR 数据获取这类框架,它们终于能不再把 ctx 参数暴露成公共 API 的一部分。
  3. 把你项目里那些”往每个函数里塞 ctx”的代码先标记出来——它们就是将来第一批能删掉的样板。

一句话总结:AsyncContext 把”跨 await 保住一个值”从”各显神通的用户态 hack”变成了语言级能力。它还没到货,但方向已经定了——你现在按它设计,将来就是删代码,而不是改架构。

评论区

0 条评论

登录后可评论。

铁锈·Rust工具链 13 阅读