每个节点 41 字节:把一棵百万节点树拆进 14 个 typed array

前情:《WASM 不是银弹》 算了边界账,《MPTT 不是万能编号》 讲了写操作,《100 万节点里搜 3 个字》 讲了搜索。这篇往下一层:一个节点到底占多少内存,这些字节花在哪,紧凑存储到底换来了什么。

结论先放这里:把对象拆成 14 个 typed array 之后,每个节点的数值/状态字段是 41 字节 ;但同一棵 100 万节点的树里,字符串 payload 是 254 字节/节点 ,线性内存到了 1 GiB。紧凑存储压缩的是可枚举的数值字段,压缩不了字符串,也替代不了对象镜像。

先给一组数字:

指标(100 万节点) 实测
14 个 typed array 合计 41,000,004 B(41 B/节点)
与对象字段重复的镜像数组 17,000,000 B(17 B/节点)
id/name/parentId/extendData 的 UTF-16 payload 253,777,714 B(253.8 B/节点)
WASM 线性内存 1 GiB(1,073.7 B/节点)
叶节点勾选(compact vs legacy) 0.031ms vs 5.169ms(约 167×)
根节点勾选(compact vs legacy) 3.535ms vs 3.313ms(持平)
一次 disabled 修改(最前 vs 最后) 1.297ms vs 0.024ms(约 54×)

这张表里最值得看的是第一行和第三行的对比:41 B 的紧凑数组,救不了 254 B 的字符串。 如果只记住一句话:

紧凑存储的收益来自"把树关系变成索引",不是"把对象换成数组"。

文中所有数据都来自这个项目 vue3-giant-tree-wasm:Vue 3 + AssemblyScript/WASM,BSD-2-Clause,npm 包名 vue3-giant-tree-wasm。紧凑存储主体在 assembly/compact-store.ts,对象模型在 assembly/models.ts,构建与双路径在 assembly/giant-tree.ts。


0. 对象模型的三个问题

MpttTree 长这样(省略枚举和注释):

ts 复制代码
class MpttTree extends NeighborTree {
  // 继承自 NeighborTree:id / name / parentId / extendData / disabled
  leftNode: i32
  rightNode: i32
  deep: i32
  checked: CheckType
  selected: CheckType
  collapsed: bool
  shown: bool
  fullIndex: i32
}

一棵 100 万节点的树,就是 100 万个这样的对象,外加一个 MpttTree[] 数组。它的问题不在于"对象一定慢",而在于以下三件事:

  1. 字段分散 :热循环访问 checked 时,需要跟随对象指针跳转到堆上的另一块内存,缓存命中率不可控;
  2. 对象头和对齐:每个对象都包含对象头、GC 信息和字段对齐,实际占用比"字段之和"更大;
  3. 树关系不在内存里 :parentId 是字符串,查找父节点需要查询 Map<string, MpttTree> 或进行区间扫描;判断子树范围则需要用 leftNode/rightNode 现算。

第 3 点最容易被忽略。很多"把对象换成数组"的文章只讲第 1、2 点,但真正让叶节点勾选从 5ms 降到 0.03ms 的,是第 3 点:父索引和子树边界已经被预先计算好了。


1. 14 个数组

CompactNodeStore 把可枚举的数值/状态字段拆成 14 个 typed array:

ts 复制代码
class CompactNodeStore {
  left: Int32Array
  right: Int32Array
  depth: Int32Array
  checked: Uint8Array
  selected: Uint8Array
  collapsed: Uint8Array
  shown: Uint8Array
  disabled: Uint8Array
  parent: Int32Array
  subtreeEnd: Int32Array
  disabledPrefix: Int32Array
  childTotal: Int32Array
  childChecked: Int32Array
  childHalf: Int32Array
}

字段来源可以分成两组:

组 数组 来源 用途
镜像 left、right、depth 对象字段 MPTT 区间、缩进、父栈
镜像 checked、selected、collapsed、shown、disabled 对象字段 选择、可见性、禁用
派生 parent 一次遍历算出 祖先链 O(depth)
派生 subtreeEnd 一次遍历算出 子树开区间
派生 disabledPrefix 一次遍历算出 子树 disabled 计数 O(1)
派生 childTotal、childChecked、childHalf 一次遍历算出 半选聚合

前 8 个是"对象里也有一份"的镜像,后 6 个是对象模型里根本没有的索引。

1.1 41 字节怎么算出来

text 复制代码
left + right + depth                 3 × 4 = 12 B
checked + selected + collapsed
  + shown + disabled                 5 × 1 =  5 B
parent + subtreeEnd                  2 × 4 =  8 B
disabledPrefix                       1 × 4 =  4 B
childTotal + childChecked
  + childHalf                        3 × 4 = 12 B
-----------------------------------------------
合计                                          41 B / 节点

disabledPrefix 是 Int32Array(size + 1),多一个哨兵,所以 100 万节点的实测是 41,000,004 B,不是整整齐齐的 41,000,000 B。

mirrorBytes() 只统计前 8 个(和对象字段重复的部分):3×4 + 5×1 = 17 B/节点,100 万节点正好 17,000,000 B。memoryBytes() 统计 14 个全部:41 B/节点。这两个口径经常被混用,文章后面会反复区分:

  • 17 B:对象字段的重复量,用来判断"镜像本身有多贵";
  • 41 B:整个紧凑存储的数值/状态字段,用来判断"这套布局本身有多大";
  • 两者都不含字符串、对象头、Map、allocator 元数据。

1.2 为什么用 Uint8Array 存状态

checked、selected、collapsed、shown、disabled 都只有几个取值:

  • checked:0/1/2(未选/半选/全选);
  • selected:0/2(未选/选中);
  • collapsed、shown、disabled:0/1。

一个字节足够。5 个状态字段合计 5 B/节点,100 万节点 5 MB。如果也用 Int32Array,会变成 20 MB------在 100 万节点规模上这不是小数。

代价是读写时要自己做类型转换:

ts 复制代码
const checked = <u8>node.checked
store<u8>(checkedBase + offset, checked)

AssemblyScript 的 u8 和 JS 的 number 之间需要显式转换,写起来不如对象字段自然。这是"省 15 MB"换来的代码噪声。


14 个数组按来源分成镜像与派生两层,可以这样理解:
#mermaid-svg-qd5OrmEMLbF0yvZO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qd5OrmEMLbF0yvZO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qd5OrmEMLbF0yvZO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qd5OrmEMLbF0yvZO .error-icon{fill:#552222;}#mermaid-svg-qd5OrmEMLbF0yvZO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qd5OrmEMLbF0yvZO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qd5OrmEMLbF0yvZO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qd5OrmEMLbF0yvZO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qd5OrmEMLbF0yvZO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qd5OrmEMLbF0yvZO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qd5OrmEMLbF0yvZO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qd5OrmEMLbF0yvZO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qd5OrmEMLbF0yvZO .marker.cross{stroke:#333333;}#mermaid-svg-qd5OrmEMLbF0yvZO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qd5OrmEMLbF0yvZO p{margin:0;}#mermaid-svg-qd5OrmEMLbF0yvZO .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-qd5OrmEMLbF0yvZO .cluster-label text{fill:#333;}#mermaid-svg-qd5OrmEMLbF0yvZO .cluster-label span{color:#333;}#mermaid-svg-qd5OrmEMLbF0yvZO .cluster-label span p{background-color:transparent;}#mermaid-svg-qd5OrmEMLbF0yvZO .label text,#mermaid-svg-qd5OrmEMLbF0yvZO span{fill:#333;color:#333;}#mermaid-svg-qd5OrmEMLbF0yvZO .node rect,#mermaid-svg-qd5OrmEMLbF0yvZO .node circle,#mermaid-svg-qd5OrmEMLbF0yvZO .node ellipse,#mermaid-svg-qd5OrmEMLbF0yvZO .node polygon,#mermaid-svg-qd5OrmEMLbF0yvZO .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qd5OrmEMLbF0yvZO .rough-node .label text,#mermaid-svg-qd5OrmEMLbF0yvZO .node .label text,#mermaid-svg-qd5OrmEMLbF0yvZO .image-shape .label,#mermaid-svg-qd5OrmEMLbF0yvZO .icon-shape .label{text-anchor:middle;}#mermaid-svg-qd5OrmEMLbF0yvZO .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qd5OrmEMLbF0yvZO .rough-node .label,#mermaid-svg-qd5OrmEMLbF0yvZO .node .label,#mermaid-svg-qd5OrmEMLbF0yvZO .image-shape .label,#mermaid-svg-qd5OrmEMLbF0yvZO .icon-shape .label{text-align:center;}#mermaid-svg-qd5OrmEMLbF0yvZO .node.clickable{cursor:pointer;}#mermaid-svg-qd5OrmEMLbF0yvZO .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qd5OrmEMLbF0yvZO .arrowheadPath{fill:#333333;}#mermaid-svg-qd5OrmEMLbF0yvZO .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qd5OrmEMLbF0yvZO .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qd5OrmEMLbF0yvZO .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qd5OrmEMLbF0yvZO .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qd5OrmEMLbF0yvZO .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qd5OrmEMLbF0yvZO .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qd5OrmEMLbF0yvZO .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qd5OrmEMLbF0yvZO .cluster text{fill:#333;}#mermaid-svg-qd5OrmEMLbF0yvZO .cluster span{color:#333;}#mermaid-svg-qd5OrmEMLbF0yvZO div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qd5OrmEMLbF0yvZO .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qd5OrmEMLbF0yvZO rect.text{fill:none;stroke-width:0;}#mermaid-svg-qd5OrmEMLbF0yvZO .icon-shape,#mermaid-svg-qd5OrmEMLbF0yvZO .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qd5OrmEMLbF0yvZO .icon-shape p,#mermaid-svg-qd5OrmEMLbF0yvZO .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qd5OrmEMLbF0yvZO .icon-shape .label rect,#mermaid-svg-qd5OrmEMLbF0yvZO .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qd5OrmEMLbF0yvZO .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qd5OrmEMLbF0yvZO .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qd5OrmEMLbF0yvZO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 派生层(对象模型里没有,24 B/节点)
parent

Int32Array
subtreeEnd

Int32Array
disabledPrefix

Int32Array(size+1)
childTotal / childChecked

childHalf

Int32Array
镜像层(对象里也有一份,17 B/节点)
left / right / depth

Int32Array
checked / selected / collapsed

shown / disabled

Uint8Array
合计 41 B/节点

2. 一次遍历,建 6 个派生索引

load() 是整篇文章最核心的一段:它遍历一次 fullTree,同时写完 14 个数组,并算出 6 个派生索引。

它不是"先拷贝字段,再遍历建 parent,再遍历建 subtreeEnd"------那样就是 3 次 O(N)。它用两个栈,在同一个循环里把所有事做完。下面是为了讲清结构而简化过的伪代码,实际每个循环都通过 dataStart + load/store 访问数组:

ts 复制代码
const stack: i32[] = []   // 当前路径上的节点,栈顶是父
const open: i32[] = []    // 还没结束的子树,栈顶是最内层

for (let i = 0; i < size; i++) {
  const node = tree[i]
  const offset = <usize>i

  // 1) 写 8 个镜像字段
  store<i32>(leftBase + (offset << 2), node.leftNode)
  store<i32>(rightBase + (offset << 2), node.rightNode)
  store<i32>(depthBase + (offset << 2), node.deep)
  store<u8>(checkedBase + offset, <u8>node.checked)
  // ... selected / collapsed / shown / disabled

  // 2) disabled 前缀和
  store<i32>(
    disabledPrefixBase + ((offset + 1) << 2),
    load<i32>(disabledPrefixBase + (offset << 2)) + disabled
  )

  // 3) 用 MPTT 右边界关闭已完成的子树
  while (
    open.length > 0 &&
    load<i32>(rightBase + (<usize>open[open.length - 1] << 2)) <= node.leftNode
  )
    store<i32>(subtreeEndBase + (<usize>open.pop() << 2), i)

  // 4) 用深度栈找父节点
  while (
    stack.length > 0 &&
    load<i32>(depthBase + (<usize>stack[stack.length - 1] << 2)) >= node.deep
  )
    stack.pop()
  if (stack.length > 0) {
    const parentIndex = stack[stack.length - 1]
    store<i32>(parentBase + (offset << 2), parentIndex)

    // 5) 直接子节点计数:disabled 子节点不参与聚合
    if (disabled === 0) {
      childTotal[parentIndex]++
      if (checked === 2) childChecked[parentIndex]++
      else if (checked === 1) childHalf[parentIndex]++
    }
  }

  stack.push(i)
  open.push(i)
}

四个细节值得单独说。

2.1 parent:深度栈

fullTree 是按 leftNode 排序的前序序列。前序遍历里,当前节点的父节点一定是"路径上深度比它小、且最近的那个节点"。

所以维护一个 stack,进节点前把"深度 >= 当前深度"的节点弹掉,栈顶就是父。每个节点最多进栈一次、出栈一次,均摊 O(1)。

2.2 subtreeEnd:右边界栈

leftNode/rightNode 是 MPTT 的进入/离开时间戳。当前节点的 leftNode 一旦大于等于某个未完成子树的 rightNode,那棵子树就结束了,结束位置就是当前索引 i。

subtreeEnd[i] 是开区间右端 (不含),所以判断"index 的子树里有没有 disabled 节点"只需要:

ts 复制代码
hasDisabledInSubtree(index: i32): bool {
  if (index < 0 || index >= this.subtreeEnd.length) return true
  return (
    this.disabledPrefix[this.subtreeEnd[index]] !== this.disabledPrefix[index]
  )
}

两次数组读、一次比较,O(1)。如果没有这个前缀和,每次都要遍历子树,最坏 O(N)。

2.3 disabledPrefix:前缀和的代价

disabledPrefix[i] 是"前 i 个节点里 disabled 的数量"。构建时一次累加,查询时做差。

代价在更新:修改一个节点的 disabled 时,它后面的所有前缀都要加减。下面是简化后的伪代码,实际通过 dataStart + load/store 更新:

ts 复制代码
updateDisabled(index: i32, disabled: bool): i32 {
  const value: u8 = disabled ? 1 : 0
  if (this.disabled[index] === value) return this.parent[index]
  this.disabled[index] = value
  const delta: i32 = disabled ? 1 : -1
  for (let i: i32 = index + 1; i < this.disabledPrefix.length; i++)
    this.disabledPrefix[i] += delta
  // ...
}

这是一个明确的取舍:查询 O(1),更新 O(N - index)。

100 万节点实测(重复 6 轮,每轮改一次再改回来):

修改位置 P50 P95
索引 1(最前) 1.297ms 3.646ms
索引 999,999(最后) 0.024ms 0.090ms

差约 54 倍。改第一个节点要重写整个后缀;改最后一个节点只动一个元素。如果业务频繁"禁用/启用部门",前缀和不是最佳结构------Fenwick 树、分块或者延迟重建都更合适。当前项目选择前缀和,是因为"禁用"远少于"查询子树有没有禁用节点"。

2.4 childTotal/childChecked/childHalf:半选聚合

三组计数器只统计直接子节点,而且 disabled 子节点不参与:

  • childTotal:可参与聚合的直接子节点数;
  • childChecked:其中 CHECKED 的数量;
  • childHalf:其中 HALF_CHECKED 的数量。

父节点状态只需要三行:

ts 复制代码
getAggregateState(index: i32): u8 {
  if (this.childChecked[index] === this.childTotal[index]) return 2
  return this.childChecked[index] === 0 && this.childHalf[index] === 0 ? 0 : 1
}

这三组计数器是"半选"能做成增量更新的关键。文章《复选框的半选状态》已经拆过递归 → 计数器 → 区间覆盖的三代实现,这里只从存储角度补一句:它们全是 Int32Array,所以"半选"不需要任何对象字段,也不需要遍历子节点。


整个 load() 的流程可以概括为一次遍历、两个栈、六路输出:
#mermaid-svg-qFcoLd8a0WxqWoSN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qFcoLd8a0WxqWoSN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qFcoLd8a0WxqWoSN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qFcoLd8a0WxqWoSN .error-icon{fill:#552222;}#mermaid-svg-qFcoLd8a0WxqWoSN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qFcoLd8a0WxqWoSN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qFcoLd8a0WxqWoSN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qFcoLd8a0WxqWoSN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qFcoLd8a0WxqWoSN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qFcoLd8a0WxqWoSN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qFcoLd8a0WxqWoSN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qFcoLd8a0WxqWoSN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qFcoLd8a0WxqWoSN .marker.cross{stroke:#333333;}#mermaid-svg-qFcoLd8a0WxqWoSN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qFcoLd8a0WxqWoSN p{margin:0;}#mermaid-svg-qFcoLd8a0WxqWoSN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-qFcoLd8a0WxqWoSN .cluster-label text{fill:#333;}#mermaid-svg-qFcoLd8a0WxqWoSN .cluster-label span{color:#333;}#mermaid-svg-qFcoLd8a0WxqWoSN .cluster-label span p{background-color:transparent;}#mermaid-svg-qFcoLd8a0WxqWoSN .label text,#mermaid-svg-qFcoLd8a0WxqWoSN span{fill:#333;color:#333;}#mermaid-svg-qFcoLd8a0WxqWoSN .node rect,#mermaid-svg-qFcoLd8a0WxqWoSN .node circle,#mermaid-svg-qFcoLd8a0WxqWoSN .node ellipse,#mermaid-svg-qFcoLd8a0WxqWoSN .node polygon,#mermaid-svg-qFcoLd8a0WxqWoSN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qFcoLd8a0WxqWoSN .rough-node .label text,#mermaid-svg-qFcoLd8a0WxqWoSN .node .label text,#mermaid-svg-qFcoLd8a0WxqWoSN .image-shape .label,#mermaid-svg-qFcoLd8a0WxqWoSN .icon-shape .label{text-anchor:middle;}#mermaid-svg-qFcoLd8a0WxqWoSN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qFcoLd8a0WxqWoSN .rough-node .label,#mermaid-svg-qFcoLd8a0WxqWoSN .node .label,#mermaid-svg-qFcoLd8a0WxqWoSN .image-shape .label,#mermaid-svg-qFcoLd8a0WxqWoSN .icon-shape .label{text-align:center;}#mermaid-svg-qFcoLd8a0WxqWoSN .node.clickable{cursor:pointer;}#mermaid-svg-qFcoLd8a0WxqWoSN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qFcoLd8a0WxqWoSN .arrowheadPath{fill:#333333;}#mermaid-svg-qFcoLd8a0WxqWoSN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qFcoLd8a0WxqWoSN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qFcoLd8a0WxqWoSN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qFcoLd8a0WxqWoSN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qFcoLd8a0WxqWoSN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qFcoLd8a0WxqWoSN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qFcoLd8a0WxqWoSN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qFcoLd8a0WxqWoSN .cluster text{fill:#333;}#mermaid-svg-qFcoLd8a0WxqWoSN .cluster span{color:#333;}#mermaid-svg-qFcoLd8a0WxqWoSN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qFcoLd8a0WxqWoSN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qFcoLd8a0WxqWoSN rect.text{fill:none;stroke-width:0;}#mermaid-svg-qFcoLd8a0WxqWoSN .icon-shape,#mermaid-svg-qFcoLd8a0WxqWoSN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qFcoLd8a0WxqWoSN .icon-shape p,#mermaid-svg-qFcoLd8a0WxqWoSN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qFcoLd8a0WxqWoSN .icon-shape .label rect,#mermaid-svg-qFcoLd8a0WxqWoSN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qFcoLd8a0WxqWoSN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qFcoLd8a0WxqWoSN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qFcoLd8a0WxqWoSN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
是
否
遍历 fullTreei
写 8 个镜像字段
累加 disabledPrefixi+1
open 栈顶的 rightNode

是否 <= 当前 leftNode
弹出并写 subtreeEnd
stack 栈顶的 depth

是否 >= 当前 depth
弹出 stack
stack 非空
写 parenti

累加 childTotal/Checked/Half
根节点,跳过
stack.push(i)

open.push(i)

3. dataStart + load/store:热循环的另一半

load() 和 checkCheckbox() 里大量出现这样的代码:

ts 复制代码
const checkedBase = this.checked.dataStart
const offset = <usize>i
const previous = load<u8>(checkedBase + offset)
store<u8>(checkedBase + offset, value)

而不是:

ts 复制代码
const previous = this.checked[i]
this.checked[i] = value

区别在于 AssemblyScript 的运行时:

  • arr[i] 会做边界检查,并在 shadow stack 上维护对 arr 的引用,保证 GC 不会在访问期间回收它;
  • load<T>(arr.dataStart + i * sizeof<T>()) 直接按指针读写,省掉每次元素的边界检查和引用记录。

代价是你自己负责不越界 。dataStart 只在数组存活期间有效;一旦 load() 重新分配了数组,旧的 base 就失效了。所以这些 base 都在函数开头取一次,循环里只做指针算术。

这不是"WASM 比 JS 快"的证明,而是"在已经决定用连续内存之后,把最后一层运行时开销也去掉"。如果索引写错,load/store 不会像 arr[i] 那样抛越界,而是静默读写错误位置------这也是为什么紧凑存储必须有对拍测试。


4. 热路径:叶节点快了 167 倍,根节点没变

先看 100 万节点的 compact/legacy 对照。两次独立复测,每次两个模式各起一个独立进程,预热 3 轮、采样 12 轮;下表是两次复测的 P50 范围:

操作 legacy compact 变化
根节点勾选 3.313--3.769ms 3.535--3.699ms ≈1×
叶节点勾选 5.169--5.544ms 0.031--0.038ms 约 150--167×
批量设置 3 个分散节点 13.16--14.64ms 4.25--4.51ms 约 3×
展开根节点 14.55--14.82ms 14.39--15.46ms ≈1×
折叠根节点 7.69--8.30ms 7.77--8.21ms ≈1×

这组数字比"compact 全面更快"更有意思:同一个数据结构,在不同操作上一个快 167 倍,一个持平。

4.1 叶节点为什么快

legacy 的 checkNodeInTree 做两件事:

ts 复制代码
node.checked = checked
setSubTreeChecked(fullTree, node, checked, i + 1)

// 从 i-1 向前扫,找所有祖先
for (let j = i - 1; j >= 0; j--) {
  const prevNode = fullTree[j]
  if (prevNode.leftNode <= node.leftNode && prevNode.rightNode >= node.rightNode) {
    prevNode.checked = getParentNodeCheckType(fullTree, prevNode, j + 1)
  }
}

叶节点本身只写一个对象,但"找祖先"是从 i-1 一直向前扫到 0;每找到一个祖先,getParentNodeCheckType 还要再扫它的直接子节点。叶节点越靠后,这段越贵。

compact 的 checkCheckbox 只做三件事:

  1. 目标节点 + 子树写 checked(叶节点就是 1 个);
  2. 每个节点更新父节点的 childChecked/childHalf(叶节点只更新 1 个父节点);
  3. 沿 parent 链向上重算祖先(深度约 6,6 次循环)。

O(1) 的父索引 + O(1) 的子树边界,把"扫全表找祖先"换成了"沿指针向上走"。167 倍不是数组布局的魔法,是索引的收益。

两条路径的差异可以画成一张对比图:
#mermaid-svg-OstRUuPp6piUUvrr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OstRUuPp6piUUvrr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OstRUuPp6piUUvrr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OstRUuPp6piUUvrr .error-icon{fill:#552222;}#mermaid-svg-OstRUuPp6piUUvrr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OstRUuPp6piUUvrr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OstRUuPp6piUUvrr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OstRUuPp6piUUvrr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OstRUuPp6piUUvrr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OstRUuPp6piUUvrr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OstRUuPp6piUUvrr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OstRUuPp6piUUvrr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OstRUuPp6piUUvrr .marker.cross{stroke:#333333;}#mermaid-svg-OstRUuPp6piUUvrr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OstRUuPp6piUUvrr p{margin:0;}#mermaid-svg-OstRUuPp6piUUvrr .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OstRUuPp6piUUvrr .cluster-label text{fill:#333;}#mermaid-svg-OstRUuPp6piUUvrr .cluster-label span{color:#333;}#mermaid-svg-OstRUuPp6piUUvrr .cluster-label span p{background-color:transparent;}#mermaid-svg-OstRUuPp6piUUvrr .label text,#mermaid-svg-OstRUuPp6piUUvrr span{fill:#333;color:#333;}#mermaid-svg-OstRUuPp6piUUvrr .node rect,#mermaid-svg-OstRUuPp6piUUvrr .node circle,#mermaid-svg-OstRUuPp6piUUvrr .node ellipse,#mermaid-svg-OstRUuPp6piUUvrr .node polygon,#mermaid-svg-OstRUuPp6piUUvrr .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OstRUuPp6piUUvrr .rough-node .label text,#mermaid-svg-OstRUuPp6piUUvrr .node .label text,#mermaid-svg-OstRUuPp6piUUvrr .image-shape .label,#mermaid-svg-OstRUuPp6piUUvrr .icon-shape .label{text-anchor:middle;}#mermaid-svg-OstRUuPp6piUUvrr .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OstRUuPp6piUUvrr .rough-node .label,#mermaid-svg-OstRUuPp6piUUvrr .node .label,#mermaid-svg-OstRUuPp6piUUvrr .image-shape .label,#mermaid-svg-OstRUuPp6piUUvrr .icon-shape .label{text-align:center;}#mermaid-svg-OstRUuPp6piUUvrr .node.clickable{cursor:pointer;}#mermaid-svg-OstRUuPp6piUUvrr .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OstRUuPp6piUUvrr .arrowheadPath{fill:#333333;}#mermaid-svg-OstRUuPp6piUUvrr .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OstRUuPp6piUUvrr .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OstRUuPp6piUUvrr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OstRUuPp6piUUvrr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OstRUuPp6piUUvrr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OstRUuPp6piUUvrr .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OstRUuPp6piUUvrr .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OstRUuPp6piUUvrr .cluster text{fill:#333;}#mermaid-svg-OstRUuPp6piUUvrr .cluster span{color:#333;}#mermaid-svg-OstRUuPp6piUUvrr div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OstRUuPp6piUUvrr .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OstRUuPp6piUUvrr rect.text{fill:none;stroke-width:0;}#mermaid-svg-OstRUuPp6piUUvrr .icon-shape,#mermaid-svg-OstRUuPp6piUUvrr .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OstRUuPp6piUUvrr .icon-shape p,#mermaid-svg-OstRUuPp6piUUvrr .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OstRUuPp6piUUvrr .icon-shape .label rect,#mermaid-svg-OstRUuPp6piUUvrr .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OstRUuPp6piUUvrr .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OstRUuPp6piUUvrr .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OstRUuPp6piUUvrr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} compact:叶节点勾选
写 checkedi(叶节点 1 次)
更新 1 个父节点的 child 计数
沿 parent 链向上重算祖先
深度约 6,6 次循环
O(depth),约 0.031ms
legacy:叶节点勾选
写 node.checked
setSubTreeChecked 遍历子树
从 i-1 向前扫到 0 找祖先
每个祖先再扫其直接子节点
O(N) 级扫描,约 5.2ms

4.2 根节点为什么持平

根节点勾选需要写整棵子树:100 万个节点。legacy 和 compact 都必须做这 100 万次写:

  • legacy:setSubTreeChecked 遍历子树,写 node.checked;
  • compact:遍历子树,写 checked[i],更新每个节点的父计数,同时还要 tree[i].checked = value(对象双写)。

compact 每个节点多了几次 typed array 读写和聚合计数,但少了一些对象字段访问;两边都是 O(N),常数接近。实测 3.5ms 左右,没有数量级差异。

根节点不是紧凑存储的收益场景。 任何"全选 100 万节点"的操作,成本下限就是 100 万次状态写,换什么布局都一样。

4.3 批量为什么快 3 倍

Legacy 的 setCheckedNodesInTree 先重置全部,再对每个目标节点重复"子树 + 祖先扫描"。目标越分散,重复扫描越多。

Compact 的批量路径先做一次 resetCheckboxSelection(两遍 O(N):清状态、重建父计数),然后对每个目标调用 checkCheckbox,只做局部传播。3 个分散目标时,4.5 ms vs 14.6 ms。

4.4 展开/折叠为什么没有收益

_shownNodes 仍然是 MpttTree[]。展开/折叠要重建这个对象数组,插入/删除的是对象引用,不是 typed array。compact 的 shown 数组只是同步副本,帮不上主导成本。

当前版本 P50 已经和 legacy 持平(14.4ms vs 14.6ms),但 P95 有波动:一次跑到 25.2ms,另一次 15.4ms,legacy 稳定在 14.8--15.7ms。原因是双写:shown 数组要更新,_shownNodes 对象数组也要更新。

项目里曾经尝试把 shown 的增量更新合并到 compact,减少第二次对象扫描。结果是 1M 展开/折叠 P95 从约 16/7ms 回退到 34/22ms、30/25ms,最终撤回。这条记录很重要:如果热路径仍然操作对象数组,把状态搬进数组只会增加一次写,不会减少主导成本。


5. 内存账本:41 B 不是全部

5.1 100 万节点的实测构成

项目 字节 每节点
14 个 typed array 41,000,004 41.0 B
其中与对象重复的镜像 17,000,000 17.0 B
id/name/parentId/extendData 的 UTF-16 payload 253,777,714 253.8 B
WASM 线性内存 1,073,741,824 1,073.7 B

10 万节点的同一套口径:

项目 字节 每节点
14 个 typed array 4,100,004 41.0 B
镜像 1,700,000 17.0 B
字符串 payload 25,177,714 251.8 B
WASM 线性内存 67,108,864(64 MiB) 671.1 B

紧凑数组随节点数线性增长(41 B/节点),字符串也近似线性(252--254 B/节点)。线性内存则不是:100k 为 64 MiB,1M 为 1 GiB,增长了 16 倍而不是 10 倍。

5.2 为什么 41 MB 装不下 1M 节点

因为另外 254 MB 是字符串。id、name、parentId、extendData 仍然在对象模型里,getObjectStringPayloadBytes() 只统计它们的 UTF-16 payload 下界:

ts 复制代码
getObjectStringPayloadBytes(): i32 {
  let chars: i32 = 0
  for (let i = 0; i < this.fullTree.length; i++) {
    const node = this.fullTree[i]
    chars +=
      node.id.length +
      node.name.length +
      node.parentId.length +
      node.extendData.length
  }
  return chars << 1   // UTF-16: 2 bytes per code unit
}

这个数字不含字符串对象头、引用、分配器元数据,所以是下界,不是精确占用。但即使按下界算,字符串也是紧凑数组的 6 倍。

线性内存的 1 GiB 里,41 MB 是 compact,254 MB 是字符串 payload,剩下的大部分(约 700--780 MB 量级,取决于按 MB 还是 MiB 计)是:

  • 100 万个 MpttTree 对象头、字段、对齐;
  • fullTree 数组的 100 万个引用;
  • idToIndex: Map<string, MpttTree> 的哈希表;
  • 字符串对象头、引用、分配器的空闲页和元数据。

这就是为什么"把对象换成数组"不能把 1 GiB 变成 41 MB。 41 MB 只是数值/状态字段那一层。

5.3 两个临时栈与 512 MB → 1 GB

load() 里有这么一段注释:

Growable stacks are intentionally sized by actual depth/width. Allocating two size-length Int32Arrays pushed a 1M-node build from 512MB to the allocator's next arena (1GB), even though wide trees only need a handful of entries here.

翻译过来:两个临时栈如果按 size 分配,各 4 MB,合计 8 MB;但对一棵宽树来说,实际深度可能只有几层,栈里只需要几个元素。8 MB 本身不多,但 AssemblyScript 的 TLSF 分配器按 arena 增长,512 MB 已经接近边界,再加 8 MB 就跳到 1 GB。所以代码用 i32[] 动态数组,按实际深度/宽度增长。这个细节说明:内存优化不能只算字节数,还要看分配器的 arena 边界。 省 8 MB 可能省下 512 MB,多用 8 MB 也可能多花 512 MB。

5.4 512 MiB 和 1 GiB 都是真的

同一棵 100 万节点树,容量基准(--compact-ab)里线性内存是 512 MiB,内存脚本里是 1 GiB。两者的构造方式略有不同(disabled 分布、名字长度、临时数组),最终落在不同的 arena。

这不是"哪个数字错了",而是分配器的行为:它不逐页归还,也不保证"多 1 MB 就多 1 MB"。文章里引用内存数字时,必须写清楚构造方式和测量点,否则 512 MiB 和 1 GiB 会看起来像矛盾。


把 1 GiB 线性内存按构成拆开,大致是这样:
#mermaid-svg-h4yzxfkXVDoVzAWy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-h4yzxfkXVDoVzAWy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-h4yzxfkXVDoVzAWy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-h4yzxfkXVDoVzAWy .error-icon{fill:#552222;}#mermaid-svg-h4yzxfkXVDoVzAWy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-h4yzxfkXVDoVzAWy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-h4yzxfkXVDoVzAWy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-h4yzxfkXVDoVzAWy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-h4yzxfkXVDoVzAWy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-h4yzxfkXVDoVzAWy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-h4yzxfkXVDoVzAWy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-h4yzxfkXVDoVzAWy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-h4yzxfkXVDoVzAWy .marker.cross{stroke:#333333;}#mermaid-svg-h4yzxfkXVDoVzAWy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-h4yzxfkXVDoVzAWy p{margin:0;}#mermaid-svg-h4yzxfkXVDoVzAWy .pieCircle{stroke:#000000;stroke-width:2px;opacity:0.7;}#mermaid-svg-h4yzxfkXVDoVzAWy .pieOuterCircle{stroke:#000000;stroke-width:1px;fill:none;}#mermaid-svg-h4yzxfkXVDoVzAWy .pieTitleText{text-anchor:middle;font-size:25px;fill:#000000;font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-h4yzxfkXVDoVzAWy .slice{font-family:"trebuchet ms",verdana,arial,sans-serif;fill:#000000;font-size:17px;}#mermaid-svg-h4yzxfkXVDoVzAWy .legend text{fill:#000000;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:17px;}#mermaid-svg-h4yzxfkXVDoVzAWy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 37% 24% 19% 17% 4% "1M 节点 WASM 线性内存构成(近似)" 14 个 typed array 41 字符串 UTF-16 payload 254 对象头/字段/对齐 400 fullTree 引用与 Map 哈希表 200 字符串对象头与 allocator 元数据 179

6. 代价:双写、O(N) 更新、对拍

6.1 双写

checkCheckbox 里每个节点都做两次写:

ts 复制代码
store<u8>(checkedBase + offset, value)
tree[i].checked = value

为什么要写对象?因为 _shownNodes、搜索结果、完整 JSON 输出、legacy 分支都还在读对象字段。紧凑数组不是唯一事实来源,对象镜像也不是唯一事实来源。

这意味着:

  • compact 的每次状态更新,成本里都包含一次对象写;
  • 如果对象字段被外部直接修改(比如宿主持有节点引用),compact 可能过期;
  • 结构编辑后要 recomputeAggregates 重新同步。

6.2 结构编辑后的 O(N) 重算

recomputeAggregates 从后往前遍历整棵树,重算每个分支的状态(简化后的伪代码):

ts 复制代码
for (let i = this.checked.length - 1; i >= 0; i--) {
  if (this.childTotal[i] === 0) continue
  const next = aggregateStateAt(...)
  this.checked[i] = next
  tree[i].checked = next
  updateChildStateAt(...)
}

单次结构编辑(追加/删除)之后,这个 O(N) 重算和 compactStore.load() 的 O(N) 重建,是文章《MPTT 不是万能编号》里"事务内单次 O(1)、事务外 O(N)"的另一半成本。

6.3 对拍是生命线

对象和数组两套状态,任何一处不一致都是静默缺陷(silent bug)。项目使用固定种子的随机操作序列进行 compact/legacy 对拍,覆盖:

  • 父节点/叶节点勾选、取消;
  • 混合 disabled 节点;
  • 批量设置、清空;
  • 搜索进入/退出;
  • 折叠后再展开;
  • All / RootOnly / LeafOnly 三种输出模式。

文档中记录了 120 步和 300 步的序列。这类测试不好写,但如果没有它,41 B 布局省下来的时间会以"偶现的勾选状态错乱"还回去。


7. 为什么删不掉对象镜像

一个自然的追问:既然 compact 已经有了所有数值/状态,为什么不把对象里的 checked/selected/collapsed/shown/disabled 删掉?

因为还有四类读者:

读者 读什么 为什么不能改
_shownNodes MpttTree[] 对象引用 虚拟滚动模板按对象渲染,搜索结果也是对象数组
完整 JSON 输出 id/name/parentId/extendData 字符串不在 compact 里
Worker / 组件缓存 对象字段 主线程和 Worker 的兼容边界
useCompactSelection=false 对象状态 legacy 回滚与对拍基线

nodeToJsonCompact 是一个典型例子:数值/状态从 compact 读,字符串仍从对象读。

ts 复制代码
s += escapeString(node.id)
s += '","name":"'
s += escapeString(node.name)
s += '","leftNode":'
s += itoa(store.left[index])
s += ',"checked":'
s += itoa(store.checked[index])

这不是"没做完",而是"字符串和对象引用本来就不该进 typed array"。除非把虚拟列表也改成索引驱动、把字符串池化、把搜索结果改成索引数组,否则删对象字段只会让每次渲染多一次 decode。

项目文档把这件事定性为"已量化并接受的架构保留项":保留这 17 B/节点的重复字段(已包含在 41 B 内),换取对象 API、字符串输出和 legacy 回滚。紧凑存储不是一次"替换",而是一次"分层"。


8. 什么时候值得

场景 建议 理由
叶节点/局部勾选频繁 值得 父索引把全表祖先扫描降到 O(depth),约 150×
批量设置分散节点 值得 一次重置 + 局部传播,约 3×
根节点/全选频繁 不指望加速 两者都要写 N 个节点,O(N) 不变
展开/折叠频繁 先改可见性结构 _shownNodes 还是对象数组,compact 帮不上
内存敏感 先看字符串 41 B/节点 vs 254 B/节点
disabled 频繁变更 慎用前缀和 更新 O(N),1.3ms/次(1M)
树小于 10 万 可能不值 全表扫描本身只有几毫秒,复杂度换不来体感
需要对象 API/字符串输出 接受双写 17 B/节点的重复字段(含在 41 B 内)换兼容性

8.1 一个简单的判断流程

text 复制代码
热路径是"逐节点扫描/聚合"吗?
  ├─ 否 → 先别做紧凑存储
  └─ 是 → 字段是定长数值/枚举吗?
       ├─ 否 → 先做字符串池化/对象池
       └─ 是 → 能接受一次 O(N) 构建 + 双写吗?
            ├─ 否 → 用索引/缓存,而不是换布局
            └─ 是 → 先做 parent/subtreeEnd/prefix 三个索引

注意最后一步:先做索引,再做数组。 如果只把字段搬进 typed array、不建 parent 和 subtreeEnd,叶节点勾选仍然要扫全表,41 B 只会变成额外的双写成本。。


把上面的判断流程画成决策图:
#mermaid-svg-zcmcBoJVV7GFlsXx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zcmcBoJVV7GFlsXx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zcmcBoJVV7GFlsXx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zcmcBoJVV7GFlsXx .error-icon{fill:#552222;}#mermaid-svg-zcmcBoJVV7GFlsXx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zcmcBoJVV7GFlsXx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zcmcBoJVV7GFlsXx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zcmcBoJVV7GFlsXx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zcmcBoJVV7GFlsXx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zcmcBoJVV7GFlsXx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zcmcBoJVV7GFlsXx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zcmcBoJVV7GFlsXx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zcmcBoJVV7GFlsXx .marker.cross{stroke:#333333;}#mermaid-svg-zcmcBoJVV7GFlsXx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zcmcBoJVV7GFlsXx p{margin:0;}#mermaid-svg-zcmcBoJVV7GFlsXx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zcmcBoJVV7GFlsXx .cluster-label text{fill:#333;}#mermaid-svg-zcmcBoJVV7GFlsXx .cluster-label span{color:#333;}#mermaid-svg-zcmcBoJVV7GFlsXx .cluster-label span p{background-color:transparent;}#mermaid-svg-zcmcBoJVV7GFlsXx .label text,#mermaid-svg-zcmcBoJVV7GFlsXx span{fill:#333;color:#333;}#mermaid-svg-zcmcBoJVV7GFlsXx .node rect,#mermaid-svg-zcmcBoJVV7GFlsXx .node circle,#mermaid-svg-zcmcBoJVV7GFlsXx .node ellipse,#mermaid-svg-zcmcBoJVV7GFlsXx .node polygon,#mermaid-svg-zcmcBoJVV7GFlsXx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zcmcBoJVV7GFlsXx .rough-node .label text,#mermaid-svg-zcmcBoJVV7GFlsXx .node .label text,#mermaid-svg-zcmcBoJVV7GFlsXx .image-shape .label,#mermaid-svg-zcmcBoJVV7GFlsXx .icon-shape .label{text-anchor:middle;}#mermaid-svg-zcmcBoJVV7GFlsXx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zcmcBoJVV7GFlsXx .rough-node .label,#mermaid-svg-zcmcBoJVV7GFlsXx .node .label,#mermaid-svg-zcmcBoJVV7GFlsXx .image-shape .label,#mermaid-svg-zcmcBoJVV7GFlsXx .icon-shape .label{text-align:center;}#mermaid-svg-zcmcBoJVV7GFlsXx .node.clickable{cursor:pointer;}#mermaid-svg-zcmcBoJVV7GFlsXx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zcmcBoJVV7GFlsXx .arrowheadPath{fill:#333333;}#mermaid-svg-zcmcBoJVV7GFlsXx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zcmcBoJVV7GFlsXx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zcmcBoJVV7GFlsXx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zcmcBoJVV7GFlsXx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zcmcBoJVV7GFlsXx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zcmcBoJVV7GFlsXx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zcmcBoJVV7GFlsXx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zcmcBoJVV7GFlsXx .cluster text{fill:#333;}#mermaid-svg-zcmcBoJVV7GFlsXx .cluster span{color:#333;}#mermaid-svg-zcmcBoJVV7GFlsXx div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zcmcBoJVV7GFlsXx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zcmcBoJVV7GFlsXx rect.text{fill:none;stroke-width:0;}#mermaid-svg-zcmcBoJVV7GFlsXx .icon-shape,#mermaid-svg-zcmcBoJVV7GFlsXx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zcmcBoJVV7GFlsXx .icon-shape p,#mermaid-svg-zcmcBoJVV7GFlsXx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zcmcBoJVV7GFlsXx .icon-shape .label rect,#mermaid-svg-zcmcBoJVV7GFlsXx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zcmcBoJVV7GFlsXx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zcmcBoJVV7GFlsXx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zcmcBoJVV7GFlsXx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
否
是
热路径是逐节点

扫描/聚合吗
先别做紧凑存储
字段是定长

数值/枚举吗
先做字符串池化/对象池
能接受一次 O(N) 构建

  • 双写吗
    用索引/缓存,而不是换布局
    先做 parent / subtreeEnd / prefix 三个索引
    再把字段搬进 typed array

9. 基准口径

  • 环境 :Node v26.7.0,release 版 WASM,macOS arm64,独立进程,--expose-gc;
  • 树形:100 万节点,10 叉树(每个节点最多 10 个子节点),id 约 37 个字符,name 约 55 个字符;
  • compact/legacy :scripts/benchmark-capacity.mjs --compact-ab 1000000,每个模式独立进程,预热 3 轮,采样 12 轮;
  • 内存 :getCompactMemoryBytes()、getCompactMirrorBytes()、getObjectStringPayloadBytes()、memory.buffer.byteLength;
  • 构建 :1M 次逐节点 pushNeighborNode,push 1164ms + popNeighbor 732ms;这是观察 compact 加载成本的口径,不是组件实际建树耗时(组件走 JSON/批量路径约 0.8 s);
  • disabled 更新:索引 1 与索引 999,999 各修改 6 次(改完再改回),取 P50/P95;
  • 字符串 payload:只统计 UTF-16 code unit × 2,不含对象头、引用、allocator 元数据,是下界。

两个容易误读的数字:

  1. 41 B/节点 只含 14 个数组,不含字符串、对象、Map、allocator;
  2. 1 GiB 线性内存 不是 compact 数组的大小,而是整棵树的运行时占用;同一规模可能落在 512 MiB 或 1 GiB,取决于构造方式和 arena。

10. 写在最后

把这次改造拆完后,最有用的不是"41 字节"这个数字,而是三件事的分工:

  • 数组布局:让数值/状态字段连续、可预测,省掉对象头和指针追踪;
  • 派生索引 :parent、subtreeEnd、disabledPrefix、三组 child 计数,把树关系变成 O(1)/O(depth) 查询;
  • 对象镜像:字符串、扩展数据、虚拟列表引用、legacy 回滚,仍然必须保留。

如果只记一句话:

紧凑存储不是把对象"变没",而是把可枚举的数值字段变成可预测的数组;它换来的是叶节点 150 倍和批量 3 倍的提升,不是根节点的加速,也不是字符串的节省。

如果你的瓶颈是字符串、渲染或展开动画,先把精力花在那边。41 B 的数组很漂亮,但它只是这棵树的一层。

项目地址:github.com/jktantan/vue3-giant-tree-wasm。紧凑存储的代码在 assembly/compact-store.ts,内存诊断 API 在 getCompactMemoryBytes() / getCompactMirrorBytes() / getObjectStringPayloadBytes()。


附:文中涉及的文件

文件 内容
assembly/compact-store.ts 14 个 typed array、load()、聚合、前缀和、checkCheckbox
assembly/models.ts MpttTree 对象字段
assembly/giant-tree.ts 双路径、compactStore.load()、内存诊断、结构编辑后的重算
assembly/tree-builder.ts buildIdIndex()、fullIndex 回填
assembly/tree-serializer.ts nodeToJsonCompact():数值来自 compact,字符串来自对象
scripts/benchmark-capacity.mjs compact/legacy 独立进程基准与内存诊断
docs/performance-refactor-plan.md 阶段 3 的 compact 记录、回退与架构保留决策
相关推荐
揽星河..1 小时前
【C语言】猜数字游戏
c语言·算法·游戏
计算机魔术师1 小时前
Anthropic官方插件被「寄生」,背后是同一类攻击又来了
前端
我命由我123452 小时前
访客系统形式(有用户概念、无用户概念)
java·开发语言·前端·javascript·python·产品运营·产品经理
LabVIEW开发2 小时前
CTM-3 红外测温仪 + LabVIEW 温度怎么测?
算法·labview·测试·labview程序
key_3_feng2 小时前
评估与对齐:Agent 自我进化的护栏
前端·数据库·agent
纪念 2292 小时前
C++算法(六)
开发语言·c++·算法
程序员大阳2 小时前
副队长JavaScript教程(65)-详解JSON:轻量级数据交换格式
javascript·json·序列化·反序列化·stringify·parse
LaughingZhu3 小时前
Product Hunt 热榜 2026-10-30:Empacer、Scape、Floot in ChatGPT
java·前端·数据库·人工智能·chatgpt·notion
Lank_M3 小时前
抠图结果缩放后边缘发脏,该怪的不是插值
前端