背景:透视表组件嵌在生产系统里,后端一次性下发 30 万~100 万行数据,汇总/分组/聚合全在前端完成。原始实现加载要 27 秒,切换维度时页面卡死,拖拽行头掉帧。
本文不讲"换个写法就莫名变快"。每一项优化都对应砍掉一个具体的开销,会展开到 V8 引擎和浏览器这一层。
一、27 秒加载,慢在哪?
数据从后端到屏幕,要经过六个阶段:
标红的 ② 和 ⑤,加上 ⑥ 里 S2 自己的内部分组,都在主线程同步执行。卡顿的根源就在这三段。
这一篇专讲 ⑤:Vue 响应式。它制造了两个问题:
- 空间:30 万行数据,每行一个 Proxy 代理对象,30 万个代理
- 时间:每次数据更新,S2 的 deep watch 深度遍历 30 万行
两个问题叠加,初始化阶段光响应式就吃掉好几秒。
二、Vue 3 的响应式,到底贵在哪?
2.1 先看原始代码
js
const mergedDataCfg = ref({
data: apiBaseData, // 30 万行对象数组
fields: { rows: [], columns: [], values: [] },
});
ref({ data: 30万行 }) 的内部过程:
ref的 value 是对象 → Vue 调用reactive()reactive()递归遍历,对每个嵌套对象/数组创建 Proxy- 30 万行 × 每行 17 个字段 = 30 万个 Proxy 对象
整个过程可以用一张图表示:
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 完全失效,每次访问都走完整分发路径,多至少两层函数调用。
对比两种访问路径:
普通对象访问经过 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 一分钱都用不到。
用一张图来看数据流向:
四、降级方案: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 优化前后的响应式对比
五、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,只做分页/虚拟滚动,开销可控。
用一张图来看两种模式的区别:
七、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 决策流程
八、防抖 + 过期响应丢弃
拖拽过程中高频触发维度变更。防抖合并连续高频,但跨不过一次完整 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 渲染出错误中间态"。这是把数据和配置原子化更新的机制。
用图来看防抖 + 过期丢弃怎么配合:
九、量化:响应式开销砍了多少
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 响应式 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 秒加载,慢在哪?
数据从后端到屏幕,要经过六个阶段:
标红的 ② 和 ⑤,加上 ⑥ 里 S2 自己的内部分组,都在主线程同步执行。卡顿的根源就在这三段。
这一篇专讲 ⑤:Vue 响应式。它制造了两个问题:
- 空间:30 万行数据,每行一个 Proxy 代理对象,30 万个代理
- 时间:每次数据更新,S2 的 deep watch 深度遍历 30 万行
两个问题叠加,初始化阶段光响应式就吃掉好几秒。
二、Vue 3 的响应式,到底贵在哪?
2.1 先看原始代码
js
const mergedDataCfg = ref({
data: apiBaseData, // 30 万行对象数组
fields: { rows: [], columns: [], values: [] },
});
ref({ data: 30万行 }) 的内部过程:
ref的 value 是对象 → Vue 调用reactive()reactive()递归遍历,对每个嵌套对象/数组创建 Proxy- 30 万行 × 每行 17 个字段 = 30 万个 Proxy 对象
整个过程可以用一张图表示:
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 完全失效,每次访问都走完整分发路径,多至少两层函数调用。
对比两种访问路径:
普通对象访问经过 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 一分钱都用不到。
用一张图来看数据流向:
四、降级方案: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 优化前后的响应式对比
五、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,只做分页/虚拟滚动,开销可控。
用一张图来看两种模式的区别:
七、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 决策流程
八、防抖 + 过期响应丢弃
拖拽过程中高频触发维度变更。防抖合并连续高频,但跨不过一次完整 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 渲染出错误中间态"。这是把数据和配置原子化更新的机制。
用图来看防抖 + 过期丢弃怎么配合:
九、量化:响应式开销砍了多少
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 响应式 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 命中率怎么量化的。