百万行数据透视表,我是怎么把 Vue 响应式开销砍到零的

42 阅读10分钟

百万行数据透视表,我是怎么把 Vue 响应式开销砍到零的

背景:透视表组件嵌在生产系统里,后端一次性下发 30 万~100 万行数据,汇总/分组/聚合全在前端完成。原始实现加载要 27 秒,切换维度时页面卡死,拖拽行头掉帧。

本文不讲"换个写法就莫名变快"。每一项优化都对应砍掉一个具体的开销,会展开到 V8 引擎和浏览器这一层。

这是这个系列的四篇里的第一篇,讲的是应用层——Vue 响应式。后面还有存储层、线程层、布局层各一篇,每篇讲一个独立环节,可以从任意一篇单独看。


一、27 秒加载,慢在哪?

数据从后端到屏幕,要经过六个阶段:

flowchart LR
    A["① axios"] --> B["② postMessage"]
    B --> C["③ Worker 聚合"]
    C --> D["④ 回传结果"]
    D --> E["⑤ Vue 响应式"]
    E --> F["⑥ S2 渲染"]

    style B fill:#ffd6d6
    style E fill:#ffd6d6

标红的 ② 和 ⑤,加上 ⑥ 里 S2 自己的内部分组,都在主线程同步执行。卡顿的根源就在这三段。

这一篇专讲 ⑤:Vue 响应式。它制造了两个问题:

  1. 空间:30 万行数据,每行一个 Proxy 代理对象,30 万个代理
  2. 时间:每次数据更新,S2 的 deep watch 深度遍历 30 万行

两个问题叠加,初始化阶段光响应式就吃掉好几秒。


二、Vue 3 的响应式,到底贵在哪?

2.1 先看原始代码

const mergedDataCfg = ref({
  data: apiBaseData,        // 30 万行对象数组
  fields: { rows: [], columns: [], values: [] },
});

ref({ data: 30万行 }) 的内部过程:

  1. ref 的 value 是对象 → Vue 调用 reactive()
  2. reactive() 递归遍历,对每个嵌套对象/数组创建 Proxy
  3. 30 万行 × 每行 17 个字段 = 30 万个 Proxy 对象

整个过程可以用一张图表示:

flowchart TD
    A["ref 包 data"] --> B["调用 reactive"]
    B --> C["递归遍历"]
    C --> D["每对象一个 Proxy"]

    style D fill:#f6c6c6

2.2 为什么 Proxy 这么贵

要理解为什么,先看 V8 是怎么优化普通对象访问的。

V8 给每个对象分配一个隐藏类(Hidden Class),记录这个对象有哪些属性、每个属性在内存中的位置(offset)。访问 obj.qty 时,V8 查隐藏类找到 qty 的 offset,直接从内存读值——这就是 fast path。

V8 还有一个内联缓存(Inline Cache,IC)优化:连续访问同一个字段时,IC 会记住"上次这个隐藏类的 qty 在 offset 5",下次直接跳过查表步骤。这是 V8 JIT 编译的核心优化之一。

Proxy 打破了这个优化。访问 proxy.qty 时:

读 proxy 的 handler → 调用 handler.get(target, "qty") → track() 收集依赖 → 读 target 的属性

Proxy 的 get trap 是动态的——V8 的 JIT 无法假设 offset 固定,因为 trap 里可能有任意逻辑。所以 IC 对 Proxy 完全失效,每次访问都走完整分发路径,多至少两层函数调用。

对比两种访问路径:

flowchart LR
    subgraph 普通对象 ["Fast Path"]
        A["obj.qty"] --> B["读隐藏类"] --> C["查 offset"] --> D["读值"]
        B -.->|IC 缓存| C
    end

    subgraph Proxy ["Slow Path"]
        E["proxy.qty"] --> F["读 handler"] --> G["handler.get"] --> H["track"] --> I["读属性"]
    end

    style D fill:#c6f6c6
    style I fill:#f6c6c6

普通对象访问经过 3 步,IC 命中后后两步可以跳过。Proxy 访问经过 5 步,而且每步都无法跳过——因为 trap 是动态的,JIT 不敢做假设。

打个比方:普通对象访问像是按门牌号找房间(快),Proxy 访问像是拿着名单逐个问(慢)。30 万行 × 17 字段的遍历里,累计开销是秒级。

2.3 第二个问题:S2 的 deep watch

@antv/s2-vue 的源码里有这么一段:

watch(
  () => props.dataCfg,
  (dataCfg) => {
    s2Ref.value.setDataCfg(dataCfg);
  },
  { deep: isProxy(props.dataCfg) }   // ← 关键
);

deep: isProxy(props.dataCfg) 的意思是:

  • 传入的是 reactive proxy → deep: true → 深度遍历所有嵌套属性,任何深层变化都触发
  • 传入的是 plain object → deep: false → 只追踪引用变化

ref 深响应式时,每次数据更新都会触发 S2 深度遍历 30 万行——这就是初始化慢的直接原因之一。


三、判断:哪些数据真的需要响应式?

优化的关键不是"怎么让 Proxy 更快",而是"哪些数据根本不需要响应式"。

响应式只为一件事服务:模板依赖的数据变化时自动更新 DOM

逐行审视这个场景:

数据谁消费需要响应式吗
原始 30 万行Worker 聚合、明细 table 展示不需要——从不被 Vue 模板直接读取
聚合后 786 行S2 canvas 渲染不需要——S2 是命令式渲染,自己监听变化重绘
fields(rows/columns/values)S2 布局、筛选 UI需要——但数据量极小
filterParams / sortParamsS2 筛选/排序需要——但数据量极小

结论:只有 fields、filterParams、sortParams 这些元数据需要响应式,30 万行原始数据和 786 行聚合结果都不需要。

这里有一个更深层的判断:S2 是 canvas 引擎,它的渲染是命令式的——自己调 setDataCfg() 触发内部重算再重画。S2 不读 data.value[0].qty,它读的是自己内部的数据副本。给这批数据包 Proxy,等于纯粹为 Vue 的追踪机制付费,S2 一分钱都用不到。

用一张图来看数据流向:

flowchart LR
    subgraph 需要响应式 ["需要响应式"]
        F1["fields"] --> F2["filterParams"] --> F3["sortParams"]
    end

    subgraph 不需要响应式 ["不需要响应式"]
        R1["apiBaseData"] --> W1["Worker 聚合"]
        W1 --> R2["pivotAggData"]
    end

    R2 --> S2["S2 渲染"]

    style F1 fill:#c6f6c6
    style F2 fill:#c6f6c6
    style F3 fill:#c6f6c6
    style R1 fill:#e0e0e0
    style R2 fill:#e0e0e0

四、降级方案:shallowRef + markRaw + toRaw

把响应式代理从 30 万个降到 0 个,分三步。

4.1 原始数据脱离 ref

// 优化前:深响应式,30万行全建 Proxy
const mergedDataCfg = ref({
  data: apiBaseData,        // ← 这里
  fields: {...},
});

// 优化后:data 不进 ref,用外部纯变量
const mergedDataCfg = shallowRef({
  fields: { rows: [], columns: [], values: [], valueInCols: true },
  sortParams: [],
  meta: [],
  filterParams: [],
});
// 注意:没有 data 字段了

let apiBaseData = markRaw(toRaw(Other.data)) || [];

shallowRef 只追踪 .value 的整体替换,不递归包装 value 内部的对象。markRaw 打上 __v_skip 标记,让后续的 reactive() 跳过它。toRaw 拿到原始对象(如果它被意外包过)。

三者配合:apiBaseData 是纯 JS 变量,不进 ref,不被追踪。

4.2 聚合结果 markRaw

const pivotAggData = shallowRef(markRaw([]));

Worker 聚合后的 786 行结果,用 markRaw 标记,不进响应式。

4.3 所有更新走整体替换

shallowRef 的陷阱是:深层 mutation 不触发更新。所以必须保证所有更新都整体替换 .value

const updateDataCfg = (patch) => {
  mergedDataCfg.value = { ...mergedDataCfg.value, ...patch };
};

任何需要更新 fields/filterParams/sortParams 的地方,都走 updateDataCfg(),保证引用变化被 S2 的 watch 捕获。

4.4 优化前后的响应式对比

flowchart LR
    subgraph 优化前 ["优化前"]
        A1["ref 包 data"] --> B1["30 万个 Proxy"]
        B1 --> C1["S2 deep watch"]
    end

    subgraph 优化后 ["优化后"]
        A2["shallowRef"] --> B2["0 个 Proxy"]
        B2 --> C2["S2 shallow watch"]
    end

    style B1 fill:#f6c6c6
    style C1 fill:#f6c6c6
    style B2 fill:#c6f6c6
    style C2 fill:#c6f6c6

五、S2 怎么感知数据变化的?

这里有个关键细节。

@antv/s2-vue 的 watch 用了 { deep: isProxy(props.dataCfg) }

  • 传 reactive proxy → deep: true → 深度遍历所有嵌套属性
  • 传 plain object → deep: false → 只追踪引用变化

我们传 plain object(shallowRef 持有 markRaw 对象),所以走浅 watch。每次数据更新只做引用比较(O(1)),不做深度遍历(O(n))。

这里有个容易忽略的点:isProxy 是 S2-vue 内部的判断,它决定了 watch 的深度。这意味着 shallowRef 优化能否生效,不完全由我们控制——如果 S2-vue 未来改了这个逻辑,浅 watch 的前提就没了。我们用了 patch 文件修改 S2 源码(见布局层那篇),所以这套方案的稳定性依赖于对 S2-vue 源码的控制。


六、预聚合:不给 S2 喂原始数据

响应式降级解决了"追踪开销",但 S2 本身还要处理数据。原始代码把 30 万原始行 + 786 聚合行全量喂给 S2,S2 内部再做 groupBy——重复劳动。

// 优化前:全量数据给 S2
const sheetDataCfg = {
  ...mergedDataCfg.value,
  data: apiBaseData.concat(pivotAggData),  // 30万 + 786
};

// 优化后:pivot 模式只喂聚合结果
const sheetDataCfg = computed(() => ({
  ...mergedDataCfg.value,
  data: sheetType === "pivot" ? pivotAggData.value : apiBaseData,
}));

30 万行压缩到 786 行,压缩比 382×。S2 的 facet 构建从"30 万行 groupBy"变成"786 行直接铺排"。

为什么这不算偷懒:Worker 已经在独立线程做了一次聚合,结果就是 786 行。S2 拿到这 786 行直接渲染即可,不需要再聚合一遍。这是分工,不是冗余。

S2 的 facet 构建是 O(行数) 的。喂 30 万行时,S2 要在主线程上做 groupBy 构建透视树——这跟 Worker 的聚合是重复劳动,而且发生在主线程,直接阻塞 UI。30 万行里只有 786 个唯一维度组合,S2 把它们全收下再聚合,做了 382 倍的冗余。

明细 table 仍用原始数据:table 模式(非 pivot)需要逐行展示,这时喂 apiBaseData(30 万行),但 table 不做 groupBy,只做分页/虚拟滚动,开销可控。

用一张图来看两种模式的区别:

flowchart LR
    subgraph 优化前 ["优化前"]
        P1["30 万行"] --> P2["S2 再 groupBy"] --> P3["渲染"]
        style P2 fill:#f6c6c6
    end

    subgraph 优化后 ["优化后"]
        Q1["786 行"] --> Q2["S2 直接铺排"] --> Q3["渲染"]
        style Q2 fill:#c6f6c6
    end

七、B3 签名:重排时彻底不算

维度切换时,如果只是重排(rows 内部换序、rows ↔ columns 换边),聚合结果不变——因为 groupBy(S) ∪ sum(M) 只依赖用了哪些维度,不依赖它们的排列顺序和位置。

7.1 为什么重排不需要重算

透视图的聚合 = groupBy(所有维度)+ sum(度量)。它只关心:

  • 用了哪些维度(集合)
  • 用了哪些度量(集合)

不关心维度在 rows 还是 columns(这只决定 S2 怎么画布局),也不关心维度的先后顺序(只决定排列次序)。

所以把 styleNo 从 rows 拖到 columns,聚合结果一个数都不变,变的只是 S2 的画法。

7.2 签名怎么砍掉的

const dimSig = (fields) =>
  [...(fields.rows || []), ...(fields.columns || [])].sort().join(",") +
  "|" +
  [...(fields.values || [])].sort().join(",");

// queryOption watcher
const sig = dimSig(fields);
if (workerDataLoaded) {
  if (sig === lastDimSig) {
    updateDataCfg({ fields });   // 只更新 fields,不调 Worker
  } else {
    debouncedDispatchToWorker(fields);  // 真正变化才让 Worker 重算
  }
}

签名是 O(维度数) 的字符串构造和比较,对比 Worker 重算 O(数据量)。维度数通常 < 20,可忽略。

7.3 为什么这个跳过是正确的

dimSig 把 rows 和 columns 合并后排序,消除了"位置"和"顺序"两个不影响聚合结果的维度,剩下的只有"维度集合本身"和"度量集合本身"。这两个集合不变,groupBy 的分组键集合就不变,sum 的累加对象就不变,聚合结果逐行不变。

前提是正确识别"什么情况下结果不变"——识别错就会跳过该算的(bug),识别对就是白捡的零成本。

边界情况:合计/小计开关切换不改变 dimSig,但会影响 S2 的 totals 配置。这是通过 defaultOption computed 独立响应 tooltipOption 变化来处理的,不走 B3 路径。

7.4 B3 决策流程

flowchart TD
    A[&#34;用户拖拽维度&#34;] --> B[&#34;计算签名&#34;]
    B --> C{&#34;签名相同?&#34;}
    C -- 是 --> D[&#34;只更新 fields&#34;]
    C -- 否 --> E[&#34;防抖&#34;]
    E --> F[&#34;Worker 重算&#34;]
    D --> G[&#34;约 0ms&#34;]
    F --> H[&#34;约 326ms&#34;]

八、防抖 + 过期响应丢弃

拖拽过程中高频触发维度变更。防抖合并连续高频,但跨不过一次完整 round-trip:

const debouncedDispatchToWorker = debounce((fields) => {
  if (!worker.value || !workerDataLoaded) return;
  const id = ++lastRequestId;
  worker.value.postMessage({ type: "groupAndSum", dataVersion, requestId: id, ... });
}, 50);

// 响应处理:丢弃过期
if (respVersion !== dataVersion) return;   // 新的 setData 已发出
if (requestId !== lastRequestId) return;   // 更新的 groupAndSum 已发出

版本号 + 请求 ID 是防抖之外的第二道正确性保障。只靠防抖的话,用户防抖后发了请求 A,还没回来又拖了一下发请求 B,A 先发出却可能后返回(Worker 队列 + 主线程事件循环的时序),结果就是 A 的结果覆盖了 B。

这里有一个细节:pendingFields 在 Worker 回复期间缓存 fields,与聚合结果一起更新 S2,避免"S2 用旧数据配新 fields 渲染出错误中间态"。这是把数据和配置原子化更新的机制。

用图来看防抖 + 过期丢弃怎么配合:

sequenceDiagram
    participant U as 用户
    participant M as 主线程
    participant W as Worker

    U->>M: 拖拽触发变更
    Note over M: 防抖合并
    M->>W: 请求 1
    U->>M: 继续拖拽
    Note over M: 防抖合并
    M->>W: 请求 2
    Note over M: 请求 2 覆盖请求 1
    W-->>M: 返回结果 2
    Note over M: 应用结果
    W-->>M: 返回结果 1 迟到
    Note over M: 丢弃过期

九、量化:响应式开销砍了多少

指标优化前优化后
响应式 Proxy 数量30 万个0 个
S2 watch 深度deep(O(n) 遍历)shallow(O(1) 引用比较)
初始化耗时27.4s2.9s
维度切换18.8s 卡死0.77s

其中响应式降级贡献了初始化阶段的大部分提升——30 万个 Proxy 的创建和追踪被完全消除。


十、这一层没有做的事

  • 没有用 reactive 的 shallow 模式shallowRef + 外部 markRaw 变量比 shallowReactive 更直观,数据流向更清晰
  • 没有把 fields 也 markRaw:fields 数据量小(几十个字段名),保持响应式让 UI 正常更新
  • 没有用 watchEffect 手动追踪依赖:S2 自己的 watch 机制已经够用,不需要额外一层