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

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

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


一、27 秒加载,慢在哪?

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

flowchart LR A[&#34;① axios GET<br/>1M行 ≈ 1.3s&#34;] --> B[&#34;② postMessage → Worker<br/>约 2s&#34;] B --> C[&#34;③ Worker 聚合<br/>独立线程,不阻塞 UI&#34;] C --> D[&#34;④ 回传主线程<br/>786 行结果&#34;] D --> E[&#34;⑤ Vue 响应式&#34;] E --> F[&#34;⑥ S2 canvas 渲染&#34;] style B fill:#ffd6d6 style E fill:#ffd6d6

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

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

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

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


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

2.1 先看原始代码

js 复制代码
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[&#34;ref({ data: 30万行 })&#34;] --> B[&#34;ref 发现 value 是对象<br/>调用 reactive()&#34;] B --> C[&#34;reactive 递归遍历<br/>对每个嵌套对象/数组&#34;] C --> D[&#34;每个对象创建一个 Proxy<br/>30 万行 = 30 万个 Proxy&#34;] 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 时:

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

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

对比两种访问路径:

flowchart LR subgraph 普通对象 [&#34;Fast Path&#34;] A[&#34;obj.qty&#34;] --> B[&#34;读隐藏类指针&#34;] --> C[&#34;查 DescriptorTable<br/>找 qty 的 offset&#34;] --> D[&#34;从内存 offset 读值&#34;] B -.->|IC 缓存命中| C end subgraph Proxy [&#34;Slow Path&#34;] E[&#34;proxy.qty&#34;] --> F[&#34;读 handler&#34;] --> G[&#34;调用 handler.get<br/>(target, 'qty')&#34;] --> H[&#34;track()<br/>收集依赖&#34;] --> I[&#34;读 target 的属性&#34;] 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 的源码里有这么一段:

js 复制代码
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 / sortParams S2 筛选/排序 需要------但数据量极小

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

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

用一张图来看数据流向:

flowchart LR subgraph 需要响应式 [&#34;需要响应式(数据量小)&#34;] F1[&#34;fields<br/>几十个字段名&#34;] --> F2[&#34;filterParams&#34;] --> F3[&#34;sortParams&#34;] end subgraph 不需要响应式 [&#34;不需要响应式(数据量大)&#34;] R1[&#34;apiBaseData<br/>30 万行原始数据<br/>markRaw 纯变量&#34;] --> W1[&#34;Worker 聚合<br/>独立线程&#34;] W1 --> R2[&#34;pivotAggData<br/>786 行聚合结果<br/>markRaw&#34;] end R2 --> S2[&#34;S2 渲染<br/>命令式,自己监听变化&#34;] 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

js 复制代码
// 优化前:深响应式,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

js 复制代码
const pivotAggData = shallowRef(markRaw([]));

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

4.3 所有更新走整体替换

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

js 复制代码
const updateDataCfg = (patch) => {
  mergedDataCfg.value = { ...mergedDataCfg.value, ...patch };
};

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

4.4 优化前后的响应式对比

flowchart LR subgraph 优化前 [&#34;优化前:ref 深响应式&#34;] A1[&#34;ref({ data: 30万行 })&#34;] --> B1[&#34;reactive 递归遍历<br/>30 万个 Proxy&#34;] B1 --> C1[&#34;每次更新触发<br/>S2 deep watch<br/>深度遍历 30 万行&#34;] end subgraph 优化后 [&#34;优化后:shallowRef + markRaw&#34;] A2[&#34;shallowRef({ fields })<br/>+ markRaw(apiBaseData)&#34;] --> B2[&#34;0 个 Proxy<br/>只追踪 .value 引用变化&#34;] B2 --> C2[&#34;每次更新触发<br/>S2 shallow watch<br/>O(1) 引用比较&#34;] 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------重复劳动。

js 复制代码
// 优化前:全量数据给 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 优化前 [&#34;优化前:全量数据给 S2&#34;] P1[&#34;30 万原始行<br/>+ 786 聚合行&#34;] --> P2[&#34;S2 内部<br/>再次 groupBy<br/>30 万行&#34;] --> P3[&#34;渲染&#34;] style P2 fill:#f6c6c6 end subgraph 优化后 [&#34;优化后:只喂聚合结果&#34;] Q1[&#34;Worker 已聚合<br/>786 行&#34;] --> Q2[&#34;S2 直接铺排<br/>不再 groupBy&#34;] --> Q3[&#34;渲染&#34;] 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 签名怎么砍掉的

js 复制代码
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;用户拖拽/勾选维度<br/>queryOption 变化&#34;] --> B[&#34;计算 dimSig 签名<br/>rows+columns 排序 + values 排序&#34;] B --> C{&#34;签名与上次相同?&#34;} C -- 是(仅重排/换边) --> D[&#34;只更新 fields<br/>让 S2 重排布局<br/>不调 Worker&#34;] C -- 否(维度集合变了) --> E[&#34;防抖 50ms&#34;] E --> F[&#34;postMessage groupAndSum<br/>Worker 重算聚合&#34;] D --> G[&#34;耗时 约 0ms&#34;] F --> H[&#34;耗时 约 326ms @100万&#34;]

八、防抖 + 过期响应丢弃

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

js 复制代码
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: pointermove 触发维度变更 Note over M: 防抖 50ms<br/>合并连续高频 M->>W: groupAndSum (requestId=1) U->>M: 继续拖拽,又触发变更 Note over M: 防抖 50ms<br/>合并连续高频 M->>W: groupAndSum (requestId=2) Note over M: requestId=2 覆盖 requestId=1<br/>旧请求被丢弃 W-->>M: 返回结果 (requestId=2) Note over M: requestId=2 = lastRequestId<br/>应用结果 W-->>M: 返回结果 (requestId=1) [迟到] Note over M: requestId=1 ≠ lastRequestId<br/>丢弃过期结果

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

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

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


十、这一层没有做的事

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

下篇预告:存储层------从 132MB 对象数组到 25MB 列式 TypedArray,CPU cache 命中率怎么量化的。

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

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


一、27 秒加载,慢在哪?

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

flowchart LR A[&#34;① axios GET<br/>1M行 ≈ 1.3s&#34;] --> B[&#34;② postMessage → Worker<br/>约 2s&#34;] B --> C[&#34;③ Worker 聚合<br/>独立线程,不阻塞 UI&#34;] C --> D[&#34;④ 回传主线程<br/>786 行结果&#34;] D --> E[&#34;⑤ Vue 响应式&#34;] E --> F[&#34;⑥ S2 canvas 渲染&#34;] style B fill:#ffd6d6 style E fill:#ffd6d6

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

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

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

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


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

2.1 先看原始代码

js 复制代码
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[&#34;ref({ data: 30万行 })&#34;] --> B[&#34;ref 发现 value 是对象<br/>调用 reactive()&#34;] B --> C[&#34;reactive 递归遍历<br/>对每个嵌套对象/数组&#34;] C --> D[&#34;每个对象创建一个 Proxy<br/>30 万行 = 30 万个 Proxy&#34;] 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 时:

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

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

对比两种访问路径:

flowchart LR subgraph 普通对象 [&#34;Fast Path&#34;] A[&#34;obj.qty&#34;] --> B[&#34;读隐藏类指针&#34;] --> C[&#34;查 DescriptorTable<br/>找 qty 的 offset&#34;] --> D[&#34;从内存 offset 读值&#34;] B -.->|IC 缓存命中| C end subgraph Proxy [&#34;Slow Path&#34;] E[&#34;proxy.qty&#34;] --> F[&#34;读 handler&#34;] --> G[&#34;调用 handler.get<br/>(target, 'qty')&#34;] --> H[&#34;track()<br/>收集依赖&#34;] --> I[&#34;读 target 的属性&#34;] 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 的源码里有这么一段:

js 复制代码
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 / sortParams S2 筛选/排序 需要------但数据量极小

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

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

用一张图来看数据流向:

flowchart LR subgraph 需要响应式 [&#34;需要响应式(数据量小)&#34;] F1[&#34;fields<br/>几十个字段名&#34;] --> F2[&#34;filterParams&#34;] --> F3[&#34;sortParams&#34;] end subgraph 不需要响应式 [&#34;不需要响应式(数据量大)&#34;] R1[&#34;apiBaseData<br/>30 万行原始数据<br/>markRaw 纯变量&#34;] --> W1[&#34;Worker 聚合<br/>独立线程&#34;] W1 --> R2[&#34;pivotAggData<br/>786 行聚合结果<br/>markRaw&#34;] end R2 --> S2[&#34;S2 渲染<br/>命令式,自己监听变化&#34;] 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

js 复制代码
// 优化前:深响应式,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

js 复制代码
const pivotAggData = shallowRef(markRaw([]));

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

4.3 所有更新走整体替换

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

js 复制代码
const updateDataCfg = (patch) => {
  mergedDataCfg.value = { ...mergedDataCfg.value, ...patch };
};

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

4.4 优化前后的响应式对比

flowchart LR subgraph 优化前 [&#34;优化前:ref 深响应式&#34;] A1[&#34;ref({ data: 30万行 })&#34;] --> B1[&#34;reactive 递归遍历<br/>30 万个 Proxy&#34;] B1 --> C1[&#34;每次更新触发<br/>S2 deep watch<br/>深度遍历 30 万行&#34;] end subgraph 优化后 [&#34;优化后:shallowRef + markRaw&#34;] A2[&#34;shallowRef({ fields })<br/>+ markRaw(apiBaseData)&#34;] --> B2[&#34;0 个 Proxy<br/>只追踪 .value 引用变化&#34;] B2 --> C2[&#34;每次更新触发<br/>S2 shallow watch<br/>O(1) 引用比较&#34;] 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------重复劳动。

js 复制代码
// 优化前:全量数据给 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 优化前 [&#34;优化前:全量数据给 S2&#34;] P1[&#34;30 万原始行<br/>+ 786 聚合行&#34;] --> P2[&#34;S2 内部<br/>再次 groupBy<br/>30 万行&#34;] --> P3[&#34;渲染&#34;] style P2 fill:#f6c6c6 end subgraph 优化后 [&#34;优化后:只喂聚合结果&#34;] Q1[&#34;Worker 已聚合<br/>786 行&#34;] --> Q2[&#34;S2 直接铺排<br/>不再 groupBy&#34;] --> Q3[&#34;渲染&#34;] 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 签名怎么砍掉的

js 复制代码
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;用户拖拽/勾选维度<br/>queryOption 变化&#34;] --> B[&#34;计算 dimSig 签名<br/>rows+columns 排序 + values 排序&#34;] B --> C{&#34;签名与上次相同?&#34;} C -- 是(仅重排/换边) --> D[&#34;只更新 fields<br/>让 S2 重排布局<br/>不调 Worker&#34;] C -- 否(维度集合变了) --> E[&#34;防抖 50ms&#34;] E --> F[&#34;postMessage groupAndSum<br/>Worker 重算聚合&#34;] D --> G[&#34;耗时 约 0ms&#34;] F --> H[&#34;耗时 约 326ms @100万&#34;]

八、防抖 + 过期响应丢弃

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

js 复制代码
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: pointermove 触发维度变更 Note over M: 防抖 50ms<br/>合并连续高频 M->>W: groupAndSum (requestId=1) U->>M: 继续拖拽,又触发变更 Note over M: 防抖 50ms<br/>合并连续高频 M->>W: groupAndSum (requestId=2) Note over M: requestId=2 覆盖 requestId=1<br/>旧请求被丢弃 W-->>M: 返回结果 (requestId=2) Note over M: requestId=2 = lastRequestId<br/>应用结果 W-->>M: 返回结果 (requestId=1) [迟到] Note over M: requestId=1 ≠ lastRequestId<br/>丢弃过期结果

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

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

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


十、这一层没有做的事

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

下篇预告:存储层------从 132MB 对象数组到 25MB 列式 TypedArray,CPU cache 命中率怎么量化的。

相关推荐
xiaopang1 小时前
小红书小组件(miniwidget)开发实战:单页面viewState切换架构
前端
labixiong1 小时前
告别scroll 监听:CSS 滚动驱动动画,主线程堵死也仍跟手
前端·css·html
云析赢指标公式网41 小时前
文华WH6布林轨道均线强弱共振指标公式
前端·算法
许彰午1 小时前
34-安全复盘96个问题
前端·vue.js·安全
妙码生花2 小时前
PHP 各框架下和 Go 的性能比较
前端·后端·go
Csvn2 小时前
TypeScript 类型性能:让类型体操不再卡死编译器
前端
万少2 小时前
等不到 Apple 的折叠 iPhone,我用 DeepV4.1Flash + workBuddy 一句话自己造了一台
前端·javascript·后端
梦曦i2 小时前
uni-router v0.2.0重磅发布:全链路拦截+重复导航优化
前端·uni-app
数据知道2 小时前
AES 加密实战——模式选择(ECB/CBC/GCM)与安全陷阱
前端·网络·安全