WASM 不是银弹:一棵百万节点 Vue 树的边界账本与虚拟滚动动画

57 阅读32分钟

把树算法搬进 WebAssembly 之后,我发现真正的瓶颈往往不在算法本身,而在每一次跨边界的调用、每一段被序列化的 JSON、每一页只增不减的线性内存。这篇文章是一次完整的取舍复盘:WASM 给了我什么,又拿走了什么;以及在一个只渲染视口的列表里,220ms 的展开动画到底是怎么做出来的。

0. 三个卡顿

如果你维护过带大树的后台系统,下面三个场景大概率不陌生:

  1. 首屏白屏。接口一次返回 10 万到 100 万行邻接表,页面要建索引、算层级、渲染第一屏。数据量再大一点,浏览器会直接失去响应。
  2. 滚动掉帧。树展开后有几万行 DOM,滚动条一拖,主线程全在布局和绘制上,输入框都开始卡。
  3. 操作卡死。点一下“全选”、展开根节点、或者输入一个搜索关键词,主线程被同步计算占满,用户以为页面挂了。

我最初的判断很直接:把树算法搬进 WASM,应该就快了。

后来发现,这个判断只对了一半。

为了不靠感觉判断“有没有变快”,我给自己定了三个可复现的验收场景:10 万节点宽树的首次建树与滚动、100 万节点的一次根节点选择、以及 4,620 节点真实页面里的展开/折叠与搜索。所有优化都围绕这三个场景做前后对照,而不是只看某个孤立 API 的微基准。

WASM 确实帮我解决了一部分问题,但它同时开了一张账单:调用边界、序列化、线程往返、线性内存、构建与调试成本。这篇文章就是这张账单。我会先给结论,再拆开讲 MPTT、WASM 边界、虚拟滚动,以及最容易被忽略的一块——虚拟滚动下的展开/收起动画。

文中所有数据都来自这个项目 vue3-giant-tree-wasm:Vue 3 + AssemblyScript/WASM,BSD-2-Clause,npm 包名 vue3-giant-tree-wasm。有归档数据的会注明环境;本文写作时新跑的对照会单独标注口径。


1. 先给结论:一份边界账本

先放一张总表,避免你看到一半才发现方向不对。

维度WASM 带来的付出的代价
计算位置把 O(N) 的建树、遍历、搜索、选择传播放进 WASM;配合 Worker 可搬出主线程每次调用都要跨 JS/WASM 边界,字符串和数组要复制
数据布局连续数组 + i32 索引,内存可预测,缓存友好不能直接复用 JS 生态的对象、Map、Set、正则;即使有实现,行为和性能也不同
内存减少 JS 堆 GC 抖动;1M 宽树实测约 1GiB 线性内存线性内存只增不减,AssemblyScript 分配器不主动归还页,项目实际撞到 2GB 上限
线程Worker + WASM 可以让主线程完全不被建树阻塞消息克隆、首次加载、快照同步,都是新的成本
工具链AssemblyScript 写起来像 TypeScript它不是 TypeScript:字符串、JSON、异常、调试体验都不一样
分发86KB 的 release.wasm 不算大要处理 wasm 内联、Worker 自包含、CSP、消费者项目加载路径
小数据无1 万节点以下,Worker 往返比主线程直接算更慢

WASM 边界账本:入口、出口、线程、内存四层成本

图 1:四层边界账本。真正决定净收益的,往往是入口调用次数、出口序列化、线程克隆和内存复用,而不是算法本身。

如果只记住一句话:

WASM 不是“更快的 JS”,它是“另一种内存模型”。收益来自数据布局和批量接口,不来自把同一个 for 循环换一种语言写。

这个项目里真正让 100 万节点可用的,是五件事的组合:

  • MPTT:把子树操作变成区间操作;
  • 紧凑数组:把对象图变成连续内存;
  • 虚拟滚动:只渲染视口;
  • Worker:把计算搬离主线程;
  • 局部动画:只给视口内的行做过渡。

WASM 是其中的一部分,不是全部。


2. MPTT:让“一棵树”变成“一段区间”

2.1 邻接表的问题

最常见的树数据是邻接表:

[
  { "id": "A", "parentId": "root" },
  { "id": "B", "parentId": "A" },
  { "id": "C", "parentId": "A" },
  { "id": "D", "parentId": "C" }
]

它很直观,但有几个操作很贵:

  • 判断一个节点是否在另一个节点的子树里:要么递归,要么沿着 parentId 一路向上找;
  • 取“当前可见的第 500 行到第 600 行”:要先算出完整可见序列;
  • 展开/折叠一个节点:要找到它的所有后代,再决定哪些行该出现。

节点少的时候无所谓,到了 10 万级以上,这些“顺着指针走”的操作会变成主要成本。

2.2 MPTT 的编号

MPTT(Modified Preorder Tree Traversal)给每个节点两个编号:leftNode 和 rightNode。对树做一次前序遍历:进入节点时分配 left,离开节点时分配 right。

MPTT 编号示意:A(1,10)、B(2,3)、C(4,7)、D(5,6)、E(8,9)

图 2:MPTT 编号。按 left 排序后,A 的子树是一段连续区间,C 的子树也是一段连续区间。

由此得到三个非常有用的性质:

  1. 子树是一段连续区间。节点 X 的后代,就是 left 在 (X.left, X.right) 之间的所有节点。
  2. 子树判定是 O(1)。判断 D 是否在 A 的子树里,只需要比较 D.left > A.left && D.right < A.right。
  3. 按 left 排序后,可见序列就是数组切片。视口要第 500 到 600 行,直接取下标区间,不需要遍历。

项目里的节点因此长这样(assembly/models.ts,简化):

export class MpttTree extends NeighborTree {
  leftNode: i32 = 0
  rightNode: i32 = 0
  deep: i32 = 0
  checked: CheckType = CheckType.UNCHECKED
  collapsed: boolean = true
  shown: boolean = false
  fullIndex: i32 = -1
}

这些字段全是 i32、bool 和小枚举,没有字符串哈希,没有指针追逐。它们可以紧凑地放在 WASM 线性内存里,这正是后面所有性能取舍的基础。

2.3 可见性:单遍遍历 + 折叠边界栈

“哪些节点在结构上可见”由折叠状态决定。最直接的做法是递归:遇到折叠节点就跳过它的子树。但深树会爆栈,而且每次递归都有函数调用成本。

项目用的是单遍遍历 + 一个栈(assembly/tree-visibility.ts):

const collapsedBoundaries: i32[] = []

for (let i = 0; i < fullTree.length; i++) {
  const node = fullTree[i]

  // 弹出已经走出的折叠边界
  while (
    collapsedBoundaries.length > 0 &&
    node.leftNode >= collapsedBoundaries[collapsedBoundaries.length - 1]
  ) {
    collapsedBoundaries.pop()
  }

  if (collapsedBoundaries.length === 0) {
    node.shown = true

    // 自己折叠且有子节点:记住它的右边界
    if (node.collapsed && node.rightNode - node.leftNode > 1) {
      collapsedBoundaries.push(node.rightNode)
    }
  }
}

栈里存的是“折叠祖先的 rightNode”。只要当前节点的 left 还没越过栈顶,它就处在一个折叠祖先内部,不可见。整个重建是 O(N),不需要递归,也不会因为树深而爆栈。

代码里省略了先把所有 shown 重置为 false 的循环。这一步不能省:搜索视图和主树共用节点,上一次搜索或折叠留下的 shown 会污染下一次重建。项目里专门有一个 resetShownFlags 路径,用来在清空搜索后从折叠状态恢复可见性,而不是相信旧的 shown 值。

展开/折叠时则走增量路径:用二分找到父节点在可见数组中的位置,再把子树区间插进去或删掉。复杂度是 O(subtree + log N)。

2.4 实测

在 release WASM + Node 的容量基准里:

  • 深树 depth 512:建树 8.29ms;
  • 深树 depth 1024:建树 14.03ms;
  • 10 万节点 wide,逐条 pushNeighborNode:build median 84.7ms / P95 101.0ms(phase0,5 轮);
  • 10 万节点复杂树,32MB JSON 一次传入:setNeighborTree 333.6ms(容量基准 --single 100000,本次)。

深树不爆栈,是显式栈的直接收益。10 万节点 wide 的纯 push 建树在 85ms 左右;同一个“10 万节点”如果换成 32MB 的复杂 JSON,一次 setNeighborTree 要 334ms。差异主要来自输入体积、字符串解析和内存分配,而不是 MPTT 编号本身。这也说明“10 万节点建树多少毫秒”这个问题,必须先问清楚数据形状和入口方式。


3. WASM 边界账本

这一节是全文的核心。WASM 的性能问题,绝大多数不是“WASM 算得慢”,而是“你在边界上花了太多钱”。

3.1 入口:调用次数比算法更早成为瓶颈

项目里有一个 A/B 脚本 scripts/benchmark-js-ab.mjs,它实现了一份功能等价的纯 JS 版本:邻接表输入、显式栈 MPTT 编号、Map 索引、children 邻接表,然后和 WASM 跑同样的数据、同样的规模。

本文写作时,我用它跑了一组 10 万节点、wide 形状、5 轮的数据(Node 26.7.0,release WASM,本机):

操作WASM纯 JS
build(WASM 逐条 pushNeighborNode)86.8 ms13.7 ms
viewport(WASM 含 getShownNodes JSON)0.1 ms0.0 ms
search0.0 ms1.2 ms
collapse / check0.0 ms0.0 ms

第一行很刺眼:WASM 的 build 比纯 JS 慢了约 6.3 倍。

但这组数据不能得出“WASM 算法慢”的结论。看脚本里的 WASM 入口就知道了:

for (const node of input) {
  wasm.pushNeighborNode(tree, node.id, node.name, node.parentId)
}

10 万个节点,就是 10 万次 JS→WASM 调用,每次还要把三个字符串复制进线性内存。它测的不是 MPTT 编号,而是最不利的边界路径。

字符串跨边界不是传指针。JS 的字符串在 JS 堆里,WASM 只能看到线性内存;每次调用都要把 id、name、parentId 编码、复制,再在 WASM 侧重新分配。10 万节点 × 3 个字符串,就是 30 万次字符串搬运。MPTT 编号本身只在这个成本之后才开始。这也是为什么“把算法改写进 WASM”经常没有想象中快:你优化的是中间那段,但用户先付的是入口那段。

同一份数据,换一种入口,结果会完全不同:

入口方式跨边界次数典型成本(10 万节点)适用场景
逐条 pushNeighborNodeN 次本次 86.8ms(wide)小数据、增量追加
一次 setNeighborTree(JSON)1 次 + WASM 侧解析138.264ms(阶段 2,wide);本次 333.6ms(复杂树 / 32MB JSON)默认整树构建
分批 UTF-8 pushNeighborNodesUtf8N/batch 次通过 inputBridgeMs 观测outputIdOnly=true 且无 filterFn

这几行的数字来自不同基准批次:86.8ms 是本文新跑的逐条 push(wide),138.264ms 是阶段 2 的 JSON 建树归档(wide),333.6ms 是容量基准的复杂树口径。它们只用于说明量级,不是严格 A/B。

这里有个反直觉的点:一次 JSON 传入并不一定最快。它把 N 次调用压成了 1 次,但 WASM 侧仍然要解析 JSON、分配字符串。项目后来做了分批 UTF-8 通道,把“调用次数”和“解析成本”一起压下去,但它只在特定输出模式下生效——chunkedBuild 要求 outputIdOnly 为 true 且没有 filterFn,否则会退回整棵 JSON 路径。getBuildMetrics().inputBridgeMs 为 0,就是你没走分批通道的信号。

结论不是“哪种入口绝对更好”,而是:

设计 WASM 接口时,批量接口不是优化项,是接口设计的一部分。 如果你提供的是“每个节点调一次”的 API,用户一定会这样用,然后得出“WASM 不快”的结论。

3.2 出口:不要每帧 JSON.parse

入口之外,出口同样收费。

早期版本滚动时调用 getShownNodes(),WASM 返回一段 JSON 字符串,主线程再 JSON.parse。视口只有几十行,看起来数据量不大,但滚动是每帧都可能发生的操作,序列化和解析会稳定地吃掉预算。

后来加了 getShownIndices():WASM 只返回视口行在完整树里的索引,主线程用已有的节点缓存去映射。

getShownIndices(): i32[] {
  const startIdx = <i32>Math.floor(this.scrollTop / this.lineHeight)
  const endIdx =
    <i32>Math.ceil((this.scrollTop + this.scrollHeight) / this.lineHeight) + 1

  const clampedStart = Math.max(
    0,
    Math.min(startIdx, this._shownNodes.length)
  )
  const clampedEnd = Math.max(
    0,
    Math.min(endIdx, this._shownNodes.length)
  )

  const result: i32[] = []
  for (let i = clampedStart; i < clampedEnd; i++) {
    const index = this._shownNodes[i].fullIndex
    if (index >= 0) result.push(index)
  }
  return result
}

10 万节点宽树的实测:

路径P50P95
getShownNodes()(JSON)0.114 ms0.165 ms
getShownIndices()(索引)0.003 ms0.006 ms

大约 38 倍的差距。但这不是免费的:主线程必须维护一份节点缓存,结构变更、字段映射、搜索视图切换时都要同步。索引桥接是“用内存和同步复杂度换边界成本”,不是单纯的优化。

getShownNodes() 仍然保留着,并且内部有按 startIdx/endIdx 的缓存:滚动位置没变时直接返回上次的 JSON。这是兼容路径,也是那些不需要索引缓存的小数据场景的退路。

3.3 线程:Worker 的收益与 1 万节点阈值

WASM 解决的是“算得快”,Worker 解决的是“不要在主线程算”。这两个问题要分开看。

项目支持 workerMode。同一份数据,主线程直接建树 vs Worker 建树的实测:

数据量workerMode: falseworkerMode: true
1 万total 22.8 msroundTrip 42.7 ms
10 万total 242 ms,主线程阻塞 249 msroundTrip 53.4 ms,主线程无阻塞
100 万total 2697 ms,主线程阻塞 2749 msroundTrip 584 ms

这张表有两个关键信息:

  1. 1 万节点以下,Worker 更慢。因为省下来的计算时间还不够付一次消息往返和首次加载的成本。项目的 playground 把自动切换阈值放在 10 万,就是这个原因。
  2. 10 万以上,Worker 的收益不只是“快”,而是“主线程不阻塞”。242ms 和 53.4ms 都是墙钟时间,但前者会让页面完全失去响应,后者不会。

Worker 也有自己的边界成本:

  • 首次加载。tree.worker.ts 静态导入 build/release,而不是动态 import()。原因是动态 import 会把 loader 拆成独立 chunk,Worker 首次启动要请求“worker 脚本 + loader chunk”两次;慢网络下每次 1~2 秒,直接体现为首次建树的 roundTrip 变长。静态导入把两者合并成一次请求。
  • 消息克隆。postMessage 传大数组、大 JSON 也要复制。项目对快照做了合并:一个短定时窗口内只发最后一次;选中结果用 48ms 延迟合并,避免连续点击复选框时反复克隆巨大的选中 ID 列表。
  • 快照版本号。Worker 是异步的,主线程必须用 revision 判断快照是否过期,否则会把旧视口数据盖到新状态上。

Worker 的生命周期也值得单独说。组件在 onMounted 里启动 Worker,等 Worker 发回 'ready' 后才发首屏数据;Worker 内部复用同一个 WASM 实例,换数据时走 clear() + 重新灌入,而不是销毁重建。README 里专门提醒:换数据请改 tree prop,不要用 :key 强制重挂载。 前者复用 Worker 和 WASM 实例,100 万节点大约 600ms;后者每次都要销毁并重建 Worker,还要重新加载 86KB 的 wasm 和 loader,成本完全不在一个量级。

这部分代码不复杂,但它是“WASM 性能”里最容易被忽略的一半:计算再快,如果结果要经过两次线程边界才能到 DOM,用户感受到的仍然是延迟。

选择传播是另一个例子。100 万节点宽树,叶节点 checkbox P95 约 0.05ms,根节点约 30.79ms,批量操作约 49.00ms。根节点的成本不是“WASM 慢”,而是它必须写整棵子树——这是算法复杂度,不是语言问题。项目后来为 RootOnly 且无 disabled 子树的场景做了 lazy 区间覆盖,100 万节点根写入从 legacy 的 P50/P95 28.982/31.732ms 降到 0.039/0.375ms;但这条路径只在特定输出模式下成立,All / LeafOnly 仍然要枚举完整结果。换语言解决不了“你本来就要做 O(subtree) 工作”这件事。

3.4 内存:线性内存只增不减

这是我踩得最深的一个坑。

WASM 的线性内存不是 JS 堆。AssemblyScript 的分配器(TLSF)不会主动把已经增长的页还给系统。项目早期每次替换数据都 newTree(),旧实例的线性内存依赖 GC 异步回收;高频替换时,分配速度超过回收速度,形成“内存棘轮”。

实测:12 次 replace 20 万节点,内存涨到 1980MB。

判断有没有“棘轮”,不能只看单次操作后的内存,而要看同一个实例连续 replace 的曲线:如果每次 replace 后线性内存都上一个台阶,GC 之后也不回落,就说明分配器没有复用,旧实例的生命周期没有被正确管理。这个判断方法比“看任务管理器”可靠得多。

修复方式不是“更勤快地 GC”,而是复用实例:

const rebuild = (next: Input[]) => {
  input = next
  // 仅当 root / lineHeight / selectType / fieldKeys 变化时才重建实例
  if (tree && !treeConfigMatches()) tree = null
  tree = ensureTree()
  wasm.clear(tree)
  wasm.setNeighborTree(tree, JSON.stringify(input))
  wasm.setCheckedOutputMode(tree, config.checkedOutputMode)
}

clear() 之后分配器原地复用,内存稳定不涨。这个改动看起来很小,但它决定了“换数据”这个操作能不能在生产环境里反复做。

代价是:内存比较不能用同进程前后差值。因为分配器不归还页,同进程里“删除数据后内存没降”不代表泄漏。项目的 lazy/legacy A/B 基准因此改成两个独立 Node 进程分别跑,再比较峰值。

1M 节点的内存构成(release,独立进程诊断口径):

项目大小
紧凑数值/状态数组37,000,008 B
重复的对象镜像17,000,000 B
字符串 payload 下界263,777,714 B
WASM linear memory1 GiB

字符串仍然是大头。这也是为什么“把对象图换成紧凑数组”能省内存,但不能省掉字符串本身——id、name、parentId 还在。项目保留了一份对象镜像用于 extendData、JSON 输出和兼容路径,这部分是明确记录下来的架构取舍,不是忘了删。

这张表是诊断口径,不是线性内存的完整账本:allocator 元数据、未归还页和其他结构也会占空间,所以 37MB + 17MB + 263MB 不会等于 1GiB。

另外,项目实际撞到的是 2GB 线性内存上限(AssemblyScript/运行时的实际上限)。深树形状在 100 万节点时会逼近这个上限,A/B 脚本因此把 deep 形状限制在 30 万节点以内。这不是“实现得不好”,是内存模型的天花板。

3.5 工具链:那些不会出现在 benchmark 里的成本

WASM 的账本里,还有几项不体现在 P50/P95 上,但会体现在维护上。

第一,AssemblyScript 不是 TypeScript。 语法像,但字符串、JSON、Map/Set、异常、闭包的行为都不同。项目早期用 JSON AST 解析器,后来改成内联字符串解析,去掉每个节点约 10 次堆分配——这类优化在 JS 里通常不会成为首要问题,但在 WASM 里,分配和字符串处理就是热路径。自定义解析器的思路很朴素:不做通用 AST,不建中间对象,直接扫描字节、按字段写入目标结构。少一次分配,就少一次线性内存增长。

第二,产物和加载路径。 release.wasm 86KB,debug 167KB,另有 WAT 和 source map。包体不算大,但 npm 分发时有两个现实问题:wasm 文件要能被消费者项目正确找到,Worker 产物要能独立加载。项目为此做了两件事:把 wasm 内联进产物、把 Worker 打成自包含单文件。这两个修复都来自真实的消费者项目故障,不是理论问题。

第三,CSP 和运行环境。 一些站点的 CSP 不允许 wasm-unsafe-eval,或者禁用了 Worker。组件需要有降级路径,至少要有清晰的错误信息。

第四,调试。 DevTools 对 WASM 的调用栈、变量查看、断点支持都不如 JS。项目的做法是把关键指标导出成 API:getBuildMetrics() 里有 inputBridgeMs、cacheAssemblyMs、layoutBridgeMs、shownIndicesMs、totalMs,Worker 侧有 commandsHandled、snapshotsSent、lastSerializeMs。没有这些指标,你很难判断慢在边界、序列化还是实际计算。

3.6 什么时候不该用 WASM

把上面的账合起来,可以给出一张“别用”清单:

  • 数据量小于 1 万:主线程直接算更快,Worker 往返反而是负收益。
  • 每帧都要取一个很小的结果:比如滚动时每帧拿几十行 JSON,边界和序列化会吃掉收益。要么批量,要么返回索引。
  • 输入无法批量:只能一个节点一个节点传,字符串复制会成为主成本。
  • 瓶颈在 DOM:项目 10 万节点浏览器 workload 里,滚动 action P95 是 0.1ms,但 render P95 是 39.2ms;前台 trace 里计算处理约 3ms,呈现等待约 28ms。这种情况下换语言没有意义,应该先减 DOM、减行组件复杂度。
  • 团队没有性能测试能力:WASM 的问题往往藏在边界和内存里,没有基准和 profile,很难定位。

4. 虚拟滚动:两条可见性线

讲动画之前,先把虚拟滚动的底子说清楚。这个项目里,“可见”其实是两件事:

4.1 结构可见

节点的所有祖先都没有折叠,它在结构上就应该出现。这个集合由 WASM 维护:shown 标志、shownCount、以及增量维护的 _shownNodes 数组。

折叠/展开时不是全量重建,而是:

  • 折叠:从可见数组里二分找到父节点,删掉它后面的子树区间;
  • 展开:把子树里新变为可见的节点插回去。

复杂度 O(subtree + log N)。搜索视图是另一棵树(searchTree),它有自己的折叠状态,清空搜索时用 resetShownFlags 从折叠状态重建,避免搜索期间被污染的 shown 残留。

4.2 几何可见

结构可见之后,还要知道“哪一段在视口里”。

组件用最经典的虚拟滚动结构:

<div class="giant-tree tree-container" @scroll="scrollEvent">
  <!-- 用总高度撑开滚动条 -->
  <div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>

  <!-- 只渲染视口行,用 transform 定位 -->
  <div class="infinite-list" :style="{ transform: transformOffset }">
    <tree-item v-for="item in currentTreeList" :key="item.id" :item="item" />
  </div>
</div>

listHeight 是“当前可见节点数 × 行高”,它撑开滚动条;currentTreeList 只包含视口内的行;transformOffset 是 translate3d(0, startOffset, 0)。

startOffset 不是原始的 scrollTop,而是 scrollTop - scrollTop % lineHeight。这样可视窗口始终对齐整行,避免顶部露出半行时行位置抖动;translate3d 则把整层交给合成器,减少滚动时的重排。

视口切片本身是 O(k),k 是视口行数:

const startIdx = Math.floor(scrollTop / lineHeight)
const endIdx = Math.ceil((scrollTop + scrollHeight) / lineHeight) + 1

10 万节点展开后,listHeight 可以到 262,340px(100k workload 里部分展开的口径),但 DOM 里的行数只和视口高度有关。滚动 action P95 0.1ms,说明切片和桥接都不是瓶颈。

但 render P95 仍然是 39.2ms。 这提醒我们:虚拟滚动解决的是 DOM 数量,不是行内容复杂度。如果每行都有复杂 slot、图标、连接线、复选框,渲染成本仍然会在。项目后续的很多优化(连接线预计算、行对象引用稳定、避免无谓重挂载)都是围绕这一层做的。


5. 虚拟滚动下,220ms 展开动画怎么做

这是我认为这个项目里最值得讲的一块,因为它是一个约束冲突:

  • 展开动画的本质,是让一个容器的高度从 0 变到“整棵子树的高度”,并让后面的行平滑下移;
  • 虚拟滚动的前提,是“整棵子树不能进 DOM”。

这两个要求直接矛盾。下面是我试过和最终采用的方案。

5.1 天真做法为什么不行

方案 A:<transition-group> + v-if 整棵子树。

这是最符合 Vue 直觉的做法:展开时把子节点全部渲染出来,让 transition-group 做进入动画。问题是,一棵 10 万节点的子树会瞬间产生 10 万个 DOM 节点。虚拟滚动白做了,内存和布局都会崩。

方案 B:只给父行加 transition。

父行高度变化不会让后面的行“让位”,因为虚拟列表的每一行是绝对定位/网格排列,父行自己变高只会盖住下面的行,或者被下面的行盖住。后面的行不会因为你给父行加了 height transition 就自动下移。

问题的本质是:动画需要的是“布局高度变化”,不是“子树 DOM”。只要能让后面的行平滑地让出 rows.length * lineHeight 的空间,视觉上就成立了。

5.2 过渡层:只动画“已经在场”的行

组件的做法是:在折叠/展开前后,各取一份视口快照,然后只把父节点在视口内的子行抽出来,放进一个临时过渡容器。

虚拟滚动下的展开动画:展开前、过渡中、展开后

图 3:过渡层只承载视口内已经在场的子行;普通行不重新挂载,后续行通过容器高度变化平滑让位。

const rowsInside = (items: TreeNodeData[], parent: TreeNodeData) =>
  items.filter(
    item =>
      item.leftNode > parent.leftNode &&
      item.leftNode < parent.rightNode
  )

这里用的是 MPTT 区间:父节点的后代,就是 leftNode 落在 (parent.leftNode, parent.rightNode) 之间的行。视口快照最多几十行,过滤成本可以忽略。

展开和折叠取不同的快照:

  • 展开:子行在 afterChange 里(折叠状态已经改完,新行出现在视口);
  • 折叠:子行在 beforeChange 里(折叠之前,它们还在视口)。

核心计划函数(lib/VueGiantTree.vue,简化):

const startTreeAnimation = async (
  direction: 'expand' | 'collapse',
  parent: TreeNodeData,
  beforeChange: TreeNodeData[],
  afterChange: TreeNodeData[]
) => {
  // 父节点必须在当前视口内,否则过渡容器无处可插
  if (!afterChange.some(item => item.id === parent.id)) return

  const rows =
    direction === 'expand'
      ? rowsInside(afterChange, parent)
      : rowsInside(beforeChange, parent)
  if (rows.length === 0) return

  treeAnimation.value = {
    direction,
    parentId: parent.id,
    rows,
    height: rows.length * props.lineHeight,
  }

  // 动画期间锁住滚动
  frozenScrollTop = scrollTop
  animationOpen.value = direction === 'collapse'

  await nextTick()

  // 先让 height: 0 的初始状态渲染一帧,下一帧再切目标高度
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      if (!isTreeAnimating()) return
      animationOpen.value = direction === 'expand'
    })
  })

  // transitionend 不保证触发(后台标签页等),用 timeout 兜底
  animationTimer = setTimeout(finishTreeAnimation, animationDuration + 80)
}

模板里,过渡容器插在父节点行之后;普通列表跳过这些行,避免同一个 key 渲染两次:

<template v-for="item in currentTreeList" :key="item.id">
  <tree-item
    v-if="!animationRowIds.has(item.id)"
    :item="item"
    ...
  />

  <div
    v-if="animationParentId === item.id"
    class="giant-tree__branch-transition"
    :class="`giant-tree__branch-transition--${transitionDirection}`"
    :style="{ height: branchTransitionHeight + 'px' }"
    @transitionend.self="finishTreeAnimation"
  >
    <tree-item
      v-for="row in transitionRows"
      :key="row.id"
      :item="row"
      ...
    />
  </div>
</template>

CSS 只做一件事:让容器高度平滑变化。

.giant-tree__branch-transition {
  display: grid;
  overflow: hidden;
  min-width: 0;
  transition: height 220ms cubic-bezier(0.22, 0.72, 0.2, 1);
  will-change: height;

  // 展开是“内容进入”,用起步更缓的曲线
  &--expand {
    transition-timing-function: cubic-bezier(0.33, 0, 0.2, 1);
  }

  @media (prefers-reduced-motion: reduce) {
    transition-duration: 1ms;
  }
}

关键点:普通行全程由 currentTreeList 渲染,不参与这次交接。 只有参与过渡的子行被抽进容器。未变化的行不会被重新挂载,它们的箭头旋转、焦点、子组件状态都能保住;靠 CSS transition 驱动的视觉(比如展开箭头)也就不会因为重挂载而丢掉动画。

为什么是 height 动画,而不是 transform?因为这里要改变的是布局高度:后面的行必须真实地下移或上移,transform 只影响绘制,不会让后续行让位。height 动画会触发布局,但过渡层只有视口内几十行,且 overflow: hidden 把影响限制在容器内部,成本可控。反过来,如果给整棵子树做 height 动画,布局成本会随子树大小爆炸——这正是我们要避免的。

5.3 三个时序坑

这段代码里最不起眼、但最容易出 bug 的是时序。

坑一:单 rAF 会让动画瞬间完成。

展开时,过渡容器第一次挂载的 height 是 0。如果我们在同一个微任务/帧里立刻把它改成目标高度,Chromium 可能把“挂载 + 改高度”合并到同一帧,浏览器只看到最终高度,动画不会发生。

所以是双 rAF:nextTick() 之后,先等一帧让 height: 0 真正绘制出来,再在下一帧切到目标高度。

坑二:transitionend 不保证触发。

后台标签页、display: none、系统 reduced-motion,都可能让 transitionend 不来。只依赖它,组件会永远停在“动画中”,滚动一直被冻结。

所以始终有一个 setTimeout(finishTreeAnimation, 220 + 80) 兜底。transitionend 是快路径,timeout 是保险。

坑三:动画期间滚动会打断布局。

过渡层占用了“布局高度”,但滚动容器仍然可以滚。用户在 220ms 内滚一下,视口切片会按新的 scrollTop 换行,过渡层的 rows 和几何位置就对不上了。

处理方式是动画期间冻结滚动:

  • handleScroll / scrollEvent 在动画中把 scrollTop 写回 frozenScrollTop;
  • wheel、touchmove、keydown 上调用 preventDefault();
  • ResizeObserver 的回调如果发现正在动画,只记录 pendingResizeHeight,等动画结束再处理;
  • 容器加 overscroll-behavior: contain,保留 overflow: auto,避免滚动条突然消失导致布局跳动。

5.4 结束时谁留下、谁离开

动画结束后,finishTreeAnimation 立即释放过渡层。这里有一个容易忽略的细节:

  • 展开:rows 本来就是 currentTreeList 里的行,释放容器后它们交回普通列表,继续常驻;
  • 折叠:rows 不在 currentTreeList 里(它们已经因为折叠被移出可见序列),所以随容器一起离开 DOM。

为什么折叠后不能把 height: 0 的容器留在 DOM 里?因为里面的行仍然可聚焦、可被屏幕阅读器读到。一个视觉上高度为 0、但 DOM 里还有内容的容器,是一个可访问性陷阱。

展开结束时则不会闪:currentTreeList 已经包含这些行,treeAnimation 释放后它们由普通列表接管;行对象的引用没有变,Vue 不会重新挂载,视觉上是连续的。

5.5 Worker 模式下的竞态

如果开了 Worker,折叠/展开不是同步的:

  1. 主线程点击折叠,发 collapse 命令给 Worker;
  2. Worker 更新 WASM 状态,返回带 revision 的 snapshot;
  3. 主线程收到 snapshot,才能知道“折叠后的视口行是什么”。

如果点击后立刻启动动画,用的是旧的 currentTreeList,rows 集合就是错的。

组件的处理是:

  • 发命令时记录 pendingWorkerCollapse = { revision, id, collapsed, before: currentTreeList };
  • 收到 snapshot.revision >= pending.revision 后,才用 before 和新的 currentTreeList 启动动画;
  • 动画期间如果用户勾选了某一行,syncWorkerAnimationRows() 用最新快照重新绑定过渡行的数据,避免复选框显示旧状态;
  • 动画进行中再次点击展开/折叠,直接 return;expandAll() / collapseAll() 也会等待动画结束。快速连点会被丢弃,这是有意的取舍;
  • 如果 snapshot 带 mutation(新增/删除节点),直接 releaseTreeAnimation():结构变更会让固定 rows 集合失效,继续动画没有意义。

最后一条值得展开。如果用户在看动画的同时,另一个操作新增或删除了节点,固定的 rows 集合不可能再代表真实子树。此时继续动画,只会让过渡层和真实列表各说各话:过渡层还在演旧结构,普通列表已经是新结构。直接释放动画、让普通虚拟列表接管,是唯一稳妥的选择。

5.6 这个方案的代价

它不是“完美动画”,而是“在虚拟滚动约束下可预测的局部动画”。代价要明说:

  • 滚动冻结约 220ms(最长由 300ms 的 timeout 兜底)。动画期间用户不能滚动,否则几何会错位。
  • 只覆盖视口内的分支。视口外的展开没有动画,直接呈现最终状态。因为你根本不知道视口外那些行的位置。
  • 快速连续操作会被丢弃。isTreeAnimating() 时新的折叠/展开直接返回。
  • 过渡行是快照。动画期间的数据变更要靠 rebind 同步,结构变更则直接取消动画。
  • reduced-motion 下降级为 1ms,尊重系统偏好。

如果让我重新选一次,我仍然会用“高度过渡层”,而不是 FLIP 或 Web Animations API。原因不是它更先进,而是它在这个架构下最便宜:

  • FLIP 需要测量每一行的首尾位置,而虚拟列表是整层 translate3d,行位置变化需要逐行测量,成本高;
  • WAAPI 可控性更好,但仍然要解决“子树不在 DOM 里”的问题;
  • 固定行高 + 只动画容器高度,把问题缩到了最小。

6. 如何做一次不自欺的基准

写到这儿,你应该能看出来:这篇文章里最容易骗人的不是代码,是数据。所以最后单独讲一下基准。

6.1 先保证双方规模一致

benchmark-js-ab.mjs 里有一条硬断言:

if (wasm.getSize(wasmTree) !== jsTree.map.size) {
  console.error('[FATAL] 规模不对等 — 数据集或实现有 bug,对比无效')
  process.exit(1)
}

这条断言来自一次真实翻车:早期数据生成器用 seed % i 生成 parentId,可能产生负数或不存在的 id。WASM 侧会静默丢弃这些孤儿节点,JS 侧不会,于是双方规模不同,对比结果完全失真。

基准的第一原则:先证明你在比同样的东西。

6.2 区分 action 和 render

浏览器 workload 里,项目把每次交互拆成两段:

  • action:事件处理 + WASM 计算 + 状态更新;
  • render:Vue 更新 DOM + 浏览器布局绘制。

4,620 节点 workload 的数据:

操作action P95render P95
滚动0.4 ms33.3 ms
折叠69.4 ms33.4 ms
复选框18.3 ms33.0 ms
搜索3.7 ms34.0 ms

如果你只看 action,会得出“滚动很快”的结论;如果你只看总时间,会以为“WASM 没优化”。两段拆开,才知道 33ms 的 render 是浏览器在画 DOM,不是算法在算。

100k workload 更极端:滚动 action P95 0.1ms,render P95 39.2ms。前台 Chrome trace 里,计算处理约 3ms,呈现等待约 28ms。瓶颈在呈现,不在计算。

这也是 Node 基准和浏览器基准不能互相换算的原因。Node 里测的是纯计算和序列化;浏览器 workload 还包含 Vue 的 patch、样式计算、布局和绘制。Node 里 0.1ms 的切片,到浏览器里可能被 30ms 的 render 完全盖住。所以项目把两类基准分开归档,而不是拿一个最漂亮的数字做宣传。

6.3 口径要写清楚

本文开头那组 79.9ms vs 14.4ms,必须带着口径读:

  • WASM 侧是逐条 pushNeighborNode,N 次跨边界;
  • JS 侧是一次性数组构建;
  • WASM 侧 viewport 含 getShownNodes 的 JSON 序列化,JS 侧只返回行数;
  • 3 轮中位数,wide 形状,Node 26.7.0,release WASM。

换一个入口、换一个出口,数字会完全不同。所以文章里我给的是“边界账本”,不是“语言排行榜”。

6.4 内存要用独立进程

前面说过,AssemblyScript 分配器不归还页。同进程里跑多个场景,前一个场景的内存会污染后一个场景。项目的 lazy/legacy A/B 因此改成两个独立 Node 进程,分别测峰值。

同理,浏览器里的堆快照要在隔离页面、预热之后取;不要把“切走数据后内存没降”直接判定为泄漏。

6.5 失败边界也要记录

100k 浏览器 workload 里,默认 JSON 输入的首次同步建树曾经超过 90 秒仍未返回,进程被主动停止。这不是“测试通过”,是容量失败边界。项目把它记录下来,而不是伪造一个通过结果。

基准的价值不只是证明你赢了,也包括明确你在哪里输了。


7. 决策表

如果你正在做一棵大树,下面这张表可以直接拿去用。它是经验值,不是定律;具体阈值取决于你的数据形状、行内容复杂度和目标设备。

场景建议原因
节点数 < 1 万纯 JS 或主线程 WASM,不上 WorkerWorker 往返和首次加载不划算
1 万 ~ 10 万主线程 WASM 可用;交互复杂就上 Worker计算开始变重,但 Worker 收益还不稳定
10 万 ~ 100 万MPTT + 紧凑数组 + Worker + 索引桥接单靠虚拟滚动或单靠 WASM 都不够
滚动 action 很快、render 很慢先减 DOM 和行组件复杂度瓶颈在呈现,换语言没用
每帧都要跨边界取小结果批量接口 / 返回索引 / 缓存边界成本会吃掉算法收益
频繁整体替换数据复用 WASM 实例 + clear()线性内存只增不减,避免棘轮
团队没有性能测试能力先补基准和 metrics,再上 WASM否则无法判断慢在边界、内存还是计算
需要 npm 分发提前处理 wasm 内联、Worker 自包含、CSP这些是消费者项目里的真实故障点

再给一个更粗的判断:

  • 如果你的瓶颈是“算”,WASM 值得考虑;
  • 如果你的瓶颈是“画”,先别碰 WASM;
  • 如果你的瓶颈是“传”,先把接口改成批量;
  • 如果你的瓶颈是“稳”,先把内存和 Worker 生命周期管好。

如果你现在是一棵纯 JS 树,建议按这个顺序迁移:

  1. 先上虚拟滚动。收益最确定,风险最低,DOM 数量立刻降下来。
  2. 再做 MPTT 和索引。把子树判定、可见序列、视口切片从“遍历对象图”变成“区间和数组下标”。
  3. 再上 Worker。当建树和选择传播开始阻塞主线程时,把计算搬出去。
  4. 最后才考虑 WASM。用它来承载紧凑布局和批量计算,而不是把它当成第一步。

每一步都用同一套 workload 验证,不要一次性重写。否则你很难知道收益来自哪一层,也很难在出问题时回滚。


8. 写在最后

回到开头那三个卡顿。

首屏白屏,最终靠的是 Worker + 批量输入 + MPTT 构建,而不是“WASM 三个字”; 滚动掉帧,最终靠的是虚拟滚动 + 索引桥接 + 行组件优化,而不是把滚动逻辑搬进 WASM; 操作卡死,最终靠的是把选择传播和搜索放进 WASM/Worker,以及局部动画,而不是给所有操作加 requestAnimationFrame。

WASM 在这个项目里的角色,是一个可以承载紧凑数据布局和批量计算的地方。它的收益是真的,边界成本也是真的。你能不能拿到净收益,取决于你有没有把接口设计成“批量”、把内存设计成“可复用”、把结果设计成“索引而不是 JSON”。

如果这篇文章只能留下一句话,我希望是:

先测量,再决定把什么搬过边界。

项目地址:github.com/jktantan/vu… 在线文档与演示:jktantan.github.io/vue3-giant-… npm:vue3-giant-tree-wasm

欢迎在评论区聊聊你在大树场景里踩过的坑,尤其是那些“基准跑得很快、用户还是觉得卡”的时刻。