88 列 × 651 行卡死 4 秒:Vue 表格从 57288 个单元格砍到 880 个
页面加载后浏览器无响应约 4 秒。处理手段是 Web Worker + Object.freeze + Element UI 懒加载 + 算法优化;结果是首屏渲染从 57,288 个单元格降到 880 个,代码从 1,200 行精简到 200 行。代价是踩了 4 个坑,经历 3 轮返工。
一、问题现象
数据统计页面包含两个核心区域:
- 图表区:3 个 ECharts 图表(同比、环比、趋势)
- 汇总表区:Element UI 树形表格,按组织维度展示数据明细
症状:
- 页面加载后完全无响应 ≈ 4 秒,鼠标点击无反应
- 表格渲染完成后,滚动和交互仍然卡顿
- Chrome DevTools Performance 录制显示主线程阻塞累计 5.5 秒
数据规模:列数 88 列;树形行数 651 行;录制事件数 134,285 个。
二、问题定位
2.1 Performance Trace 分析
通过 Chrome DevTools Performance 录制(4.6 秒),发现 7 个长任务(>50ms),总阻塞 5,558ms:
| # | 耗时 | 任务类型 | 说明 |
|---|---|---|---|
| 1 | 2,276ms | XHRReadyStateChange | API 数据同步处理 |
| 2 | 1,942ms | TimerFire | 定时器回调重计算 |
| 3 | 475ms | RunTask | 主线程任务 |
| 4 | 402ms | RunTask | 主线程任务 |
| 5 | 251ms | RunTask | 主线程任务 |
| 6 | 151ms | RunTask | 主线程任务 |
| 7 | 62ms | RunTask | 主线程任务 |
关键发现:后两个超长任务连续执行,间隔仅 3ms,说明存在依赖关系——数据处理完成后立刻触发定时器回调。
2.2 V8 执行分析(2276ms 任务内部)
调用链:
XHRReadyStateChange
→ RunMicrotasks(2275ms, 占比 99.96%)
→ 字节码预算中断 645 次
→ StackGuard 调用 411 次
→ GC 412 次,耗时 581ms
→ Major GC (Background Marking) ×53, 273ms
→ Minor GC (Scavenger) ×359, 308ms
微任务队列积压是主因。大量临时对象触发频繁 GC,GC 又进一步阻塞主线程,形成负循环。
2.3 代码审查发现
🔴 致命级:cellStyle + formatAmount 的 510 万次判断
cellStyle 方法有 ~89 条 || 条件链,formatAmount 方法有 ~87 个 if 分支:
// cellStyle - 原始(节选)
cellStyle(row) {
if (row.column.property === 'yearTotalVal' && row.row.yearTotalVal {
this.traverseTree(merged, (node) => { // 每次递归整棵树
if (node.id === targetId) { Object.assign(node, values, {name}); }
});
});
}
每次外层循环都递归遍历整棵树,时间复杂度 O(n × m)。
🟡 严重级:全部数据一次性渲染
651 行 × 88 列的表格数据在首屏全部渲染,未利用懒加载。
三、优化方案与实施
整个优化过程遵循从内到外的层次策略:先优化算法,再优化数据,再优化架构,最后优化渲染。
3.1 第一轮:消除百万级判断
思路:不再给每个字段名硬编码判断,通过 column.property 动态读取值。
// cellStyle - 优化后:5 行
cellStyle({ row, column }) {
const value = row[column.property];
if (typeof value === 'number' && value {
const { type, data } = e.data;
if (type === 'processComplete') {
this.frozenTableData = this.deepFreeze(data.flatTree);
this.childrenMap = data.childrenMap;
this.tableReady = true;
}
};
}
⚠️ 踩坑记录 1:Worker 不能用 ES Module import
// ❌ 报错:TypeError: xxx is not a constructor
import TravelSumWorker from './travelSum.worker.js';
this.worker = new TravelSumWorker();
// ✅ 使用浏览器原生 Worker
this.worker = new Worker('/travelSum.worker.js');
⚠️ 踩坑记录 2:Worker 文件必须放在 public 目录
相对路径 ./travelSum.worker.js 导致 404(浏览器收到 HTML 页面,报错 `Unexpected token ' { childrenObj[key] = value; });
self.postMessage({ childrenMap: childrenObj });
这是「展开后子节点无数据」的关键原因之一。
### 3.3 第三轮:数据预格式化 + Object.freeze
数据预格式化:将 `formatAmount` 和 `cellStyle` 的格式化逻辑前置到 Worker;Worker 内完成所有负数转括号、百分比格式化,组件端直接渲染字符串。
```js
// Worker 端:预格式化
function formatNumber(val) {
if (val == null) return '';
if (typeof val === 'number' && val {
const value = obj[key];
if (value && typeof value === 'object') this.deepFreeze(value);
});
return Object.freeze(obj);
}
this.frozenTableData = this.deepFreeze(data.flatTree);
原理:Vue 2 初始化数据时递归遍历对象,为每个属性创建 getter/setter。651 行 × 88 列的数据产生约 57,000 个响应式属性。Object.freeze 跳过这个过程,首屏时间省 30%+。
3.4 第四轮:Element UI 树形表格懒加载
loadChildren(tree, treeNode, resolve) {
const parentIdStr = String(tree.id);
const children = this.childrenMap[parentIdStr] || [];
resolve(children.map(child => this.deepFreeze(child)));
}
Worker 端:构建懒加载树
function buildLazyTree(treeData, tableData, totalArray) {
const dataMap = new Map();
const childrenMap = new Map();
// ① 数据哈希化:O(n)
tableData.forEach(item => {
const key = String(item.id);
if (!dataMap.has(key)) dataMap.set(key, {});
Object.assign(dataMap.get(key), item);
});
// ② 构建平坦树:子节点存 childrenMap,不挂载到 parent
function buildFlatTree(nodes) {
return nodes.map(node => {
const dataItem = dataMap.get(String(node.id)) || {};
const flatNode = { id: node.id, ...dataItem };
flatNode.name = getNameByType(node);
if (node.children?.length) {
flatNode.hasChildren = true;
childrenMap.set(String(node.id), buildFlatTree(node.children));
} else {
flatNode.hasChildren = false;
}
// 关键:不设置 children 属性
return flatNode;
});
}
const flatTree = buildFlatTree(treeData);
flatTree.unshift({ ...totalArray[0], hasChildren: false });
const cObj = {};
childrenMap.forEach((v, k) => { cObj[k] = v; });
return { flatTree, childrenMap: cObj };
}
⚠️ 踩坑记录 4:Element UI lazy 的三条铁律
| 铁律 | 错误表现 | 修复 |
|---|---|---|
| 节点不能有 children 属性 | 首屏只有小计行有数据 | 所有子节点存 childrenMap |
| hasChildren 必须显式 true | 不显示展开按钮 | flatNode.hasChildren = true |
| ID 类型统一 | 展开后无数据 | 统一 String(id) 作为 map key |
三条一起触发时,出现「首屏只有小计行 → 展开后无子节点 → 展开按钮消失」的连环 Bug。
四、性能对比
核心指标对比:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 首屏渲染时间 | > 4,000ms | 硬编码枚举 |
// ❌ 硬编码 89 个字段
if (prop === 'yearTotalVal' && val nodeMap.set(node.id, node));
this.tableData.forEach(bItem => {
const node = nodeMap.get(bItem.id);
if (node) Object.assign(node, values);
});