把树算法搬进 WebAssembly 之后,我发现真正的瓶颈往往不在算法本身,而在每一次跨边界的调用、每一段被序列化的 JSON、每一页只增不减的线性内存。这篇文章是一次完整的取舍复盘:WASM 给了我什么,又拿走了什么;以及在一个只渲染视口的列表里,220ms 的展开动画到底是怎么做出来的。
0. 三个卡顿
如果你维护过带大树的后台系统,下面三个场景大概率不陌生:
- 首屏白屏。接口一次返回 10 万到 100 万行邻接表,页面要建索引、算层级、渲染第一屏。数据量再大一点,浏览器会直接失去响应。
- 滚动掉帧。树展开后有几万行 DOM,滚动条一拖,主线程全在布局和绘制上,输入框都开始卡。
- 操作卡死。点一下“全选”、展开根节点、或者输入一个搜索关键词,主线程被同步计算占满,用户以为页面挂了。
我最初的判断很直接:把树算法搬进 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 往返比主线程直接算更慢 |
图 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。
图 2:MPTT 编号。按 left 排序后,A 的子树是一段连续区间,C 的子树也是一段连续区间。
由此得到三个非常有用的性质:
- 子树是一段连续区间。节点 X 的后代,就是
left在(X.left, X.right)之间的所有节点。 - 子树判定是 O(1)。判断 D 是否在 A 的子树里,只需要比较
D.left > A.left && D.right < A.right。 - 按
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 一次传入:
setNeighborTree333.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 ms | 13.7 ms |
viewport(WASM 含 getShownNodes JSON) | 0.1 ms | 0.0 ms |
| search | 0.0 ms | 1.2 ms |
| collapse / check | 0.0 ms | 0.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 万节点) | 适用场景 |
|---|---|---|---|
逐条 pushNeighborNode | N 次 | 本次 86.8ms(wide) | 小数据、增量追加 |
一次 setNeighborTree(JSON) | 1 次 + WASM 侧解析 | 138.264ms(阶段 2,wide);本次 333.6ms(复杂树 / 32MB JSON) | 默认整树构建 |
分批 UTF-8 pushNeighborNodesUtf8 | N/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 万节点宽树的实测:
| 路径 | P50 | P95 |
|---|---|---|
getShownNodes()(JSON) | 0.114 ms | 0.165 ms |
getShownIndices()(索引) | 0.003 ms | 0.006 ms |
大约 38 倍的差距。但这不是免费的:主线程必须维护一份节点缓存,结构变更、字段映射、搜索视图切换时都要同步。索引桥接是“用内存和同步复杂度换边界成本”,不是单纯的优化。
getShownNodes() 仍然保留着,并且内部有按 startIdx/endIdx 的缓存:滚动位置没变时直接返回上次的 JSON。这是兼容路径,也是那些不需要索引缓存的小数据场景的退路。
3.3 线程:Worker 的收益与 1 万节点阈值
WASM 解决的是“算得快”,Worker 解决的是“不要在主线程算”。这两个问题要分开看。
项目支持 workerMode。同一份数据,主线程直接建树 vs Worker 建树的实测:
| 数据量 | workerMode: false | workerMode: true |
|---|---|---|
| 1 万 | total 22.8 ms | roundTrip 42.7 ms |
| 10 万 | total 242 ms,主线程阻塞 249 ms | roundTrip 53.4 ms,主线程无阻塞 |
| 100 万 | total 2697 ms,主线程阻塞 2749 ms | roundTrip 584 ms |
这张表有两个关键信息:
- 1 万节点以下,Worker 更慢。因为省下来的计算时间还不够付一次消息往返和首次加载的成本。项目的 playground 把自动切换阈值放在 10 万,就是这个原因。
- 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 memory | 1 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,折叠/展开不是同步的:
- 主线程点击折叠,发
collapse命令给 Worker; - Worker 更新 WASM 状态,返回带 revision 的 snapshot;
- 主线程收到 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 P95 | render P95 |
|---|---|---|
| 滚动 | 0.4 ms | 33.3 ms |
| 折叠 | 69.4 ms | 33.4 ms |
| 复选框 | 18.3 ms | 33.0 ms |
| 搜索 | 3.7 ms | 34.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,不上 Worker | Worker 往返和首次加载不划算 |
| 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 树,建议按这个顺序迁移:
- 先上虚拟滚动。收益最确定,风险最低,DOM 数量立刻降下来。
- 再做 MPTT 和索引。把子树判定、可见序列、视口切片从“遍历对象图”变成“区间和数组下标”。
- 再上 Worker。当建树和选择传播开始阻塞主线程时,把计算搬出去。
- 最后才考虑 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
欢迎在评论区聊聊你在大树场景里踩过的坑,尤其是那些“基准跑得很快、用户还是觉得卡”的时刻。