前言
在物料基础设置页面中,有一个「选择组织机构」弹窗,使用 el-tree 展示集团组织架构。接口返回约 6MB JSON,包含上万组织节点的嵌套树结构。原始实现下,从点击按钮到弹窗出现需要等待 10 秒以上,期间页面完全卡死,用户体验极差。
本文将完整记录整个优化过程,从问题定位到最终方案落地,一步步把 10 秒的卡顿优化到秒开,并沉淀出一套可复用的三级缓存 + Web Worker + 虚拟滚动方案。
问题定位:为什么这么慢?
原始代码的核心流程如下:
scss
打开弹窗 → axios POST 请求 → 主线程 JSON.parse(6MB) → 主线程递归 mapOrgData → el-tree default-expand-all 全量渲染
每个环节都在主线程执行,任何一个步骤都能把浏览器卡死几秒。接口返回的原始数据结构如下:
json
{
"code": "0",
"message": "success",
"data": [
{
"orgCode": "60000001",
"orgFullName": "中国五矿集团有限公司",
"parentCode": null,
"children": [
{ "orgCode": "60000000", "orgFullName": "中国五矿股份有限公司", "parentCode": "60000001" }
]
}
]
}
需要映射为 el-tree 要求的 { id, label, children } 格式。这些计算同样在主线程完成,雪上加霜。
接下来的优化会按照递进式的思路:每一步解决上一步遗留的瓶颈,最终组合成一套完整方案。
第一步:添加 Loading 状态
没有任何优化之前,点击按钮后界面毫无反应,用户根本不知道是正在加载还是已经卡死。UI 上的底线反馈必须有。
我们新增了一个 loading ref 变量,请求前后切换状态,并在 el-tree 上绑定 v-loading 指令:
vue
<template>
<el-tree v-loading="loading" :data="filteredData" ... />
</template>
<script setup>
const loading = ref(false);
async function getOrg() {
try {
loading.value = true;
const res = await materialBasicSettingOrg({ keyword: '' });
if (res.code === '0') {
treeData.value = mapOrgData(res.data);
}
} finally {
loading.value = false;
}
}
</script>
这一步并没有提升性能,但它保证了无论后续优化到什么程度,用户都能感知到系统正在响应。
第二步:IndexedDB 持久化缓存
原始代码每次打开弹窗都会重新请求 6MB 数据,同一用户在同一天内反复操作时,这些下载是完全不必要的浪费。
由于数据量超过 localStorage 的 5MB 限制,我们选择 IndexedDB 来做持久化缓存。
新建 orgCache.ts (src/utils/orgCache.ts),封装原生 IndexedDB API,设置 24 小时有效期:
typescript
const DB_NAME = 'org-cache-db';
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24小时
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('org-data')) {
db.createObjectStore('org-data', { keyPath: 'key' });
}
};
});
}
export async function getCachedOrg(): Promise<TreeNode[] | null> { ... }
export async function setCachedOrg(data: TreeNode[]) { ... }
效果:第二次打开弹窗不再发请求,从 IndexedDB 直接读取。
遗留问题:IndexedDB 读取仍是异步,6MB 数据反序列化需要几十到几百毫秒;且首次加载的主线程 JSON.parse + 递归映射依然卡 UI。
第三步:内存缓存实现弹窗秒开
IndexedDB 虽解决了重复下载,但同一页面会话内,如果第一次已经加载过,第二次打开弹窗仍需异步等待,体验不够极致。我们期望第二次打开时瞬间展示数据。
在 orgCache.ts 中引入模块级别的 memoryCache 变量,将 getCachedOrg 改为同步方法,并在弹窗打开时优先同步查询内存:
typescript
// orgCache.ts
let memoryCache: TreeNode[] | null = null;
export function getCachedOrg(): TreeNode[] | null {
if (memoryCache !== null) return memoryCache;
return null;
}
export function setCachedOrg(data: TreeNode[]): void {
memoryCache = data;
persistToIndexedDB(data); // 异步持久化
}
export async function restoreFromIndexedDB(): Promise<TreeNode[] | null> {
// 页面刷新后,从 IndexedDB 恢复内存缓存
// ...
}
弹窗逻辑调整:
vue
function handleSelectOrg() {
dialogSelectOrgVisible.value = true;
const cached = getCachedOrg(); // 同步,0ms
if (cached) {
treeData.value = cached; // 弹窗秒开
return;
}
getOrg(); // 首次打开走异步链路
}
缓存体系升级为三级:
scss
弹窗打开
→ 内存缓存(0ms,同步,页面会话内有效)
→ IndexedDB 缓存 (< 100ms,异步,跨页面刷新)
→ HTTP 接口(6MB 下载,首次/过期)
同一页面会话内再次打开弹窗,秒开达成。但页面刷新后第一次打开,主线程 JSON.parse + 递归映射依然是核心瓶颈。
第四步:Web Worker 接管 JSON 解析和数据映射
这是最关键的性能瓶颈突破。原始流程中,以下三个重操作都在主线程执行:
axios自动调用JSON.parse(6MB)------ 阻塞主线程 2~5 秒- 递归
mapOrgData------ 遍历上万嵌套节点做字段映射 el-tree渲染 ------ 后续步骤解决
尝试过的失败路径
| 方案 | 失败原因 |
|---|---|
http.post({ responseType: 'arraybuffer' }) |
公司内部 @minmetals/request 的拦截器会检查 response.data.code,ArrayBuffer 没有 .code 属性,导致 Promise reject |
http.post({ responseType: 'blob' }) |
内部会包装成 { code: "0", data: { blob: _, filename: "download" } },链路复杂不稳定 |
| 把 axios 已 parse 的 JS 对象传给 Worker | 结构化克隆相当于序列化两次,主线程 JSON.parse 压力依然在,Worker 形同虚设 |
最终可行方案:fetch + ArrayBuffer + Worker
完全绕过 axios 封装,用原生 fetch 获取 ArrayBuffer(原始二进制),直接传给 Worker,在 Worker 内部完成所有重活:TextDecoder.decode → JSON.parse → mapOrgData。
typescript
// selectOrg.vue
const headers = HeaderConfig.init({
appId: 'minmetals-material-mbdm',
}).getHeaderConfig();
const response = await fetch('/minmetals-material-mbdm/api/v1/matl-basic-org', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ keyword: '' }),
});
const buffer = await response.arrayBuffer(); // 无 JSON.parse
treeData.value = await parseWithWorker(buffer); // 丢给 Worker
typescript
// orgWorker.ts
globalThis.onmessage = (event: MessageEvent<WorkerMessage>) => {
const { type, payload } = event.data;
if (type === 'arraybuffer') {
const decoder = new TextDecoder();
const jsonString = decoder.decode(payload as ArrayBuffer);
const response: any = JSON.parse(jsonString);
const rawData: OrgNode[] = response?.data || [];
const result = mapOrgData(rawData);
globalThis.postMessage(result);
}
};
数据流对比:
- 优化前:
网络 → axios JSON.parse(卡顿) → mapOrgData(卡顿) → el-tree(卡顿) - 优化后:
网络 → fetch(不阻塞) → Worker 解析、映射(不阻塞) → 主线程赋值(秒开)
至此,主线程不再被 6MB JSON 解析阻塞,但 el-tree 的 default-expand-all 仍然会全量渲染所有节点,DOM 创建开销仍可达 5~10 秒。
第五步:el-tree-v2 虚拟滚动
Element Plus 提供了 el-tree-v2 组件,基于虚拟滚动,只渲染可视区域内的节点,完美解决上万节点全量渲染的问题。
替换前后对比:
vue
<!-- 替换前 -->
<el-tree
v-loading="loading"
:data="filteredData"
:props="defaultProps"
node-key="id"
default-expand-all
highlight-current
@node-click="handleNodeClick"
/>
<!-- 替换后 -->
<el-tree-v2
ref="treeRef"
v-loading="loading"
:data="treeData"
:props="{ children: 'children', label: 'label', value: 'id' }"
:height="300"
:default-expanded-keys="defaultExpandedKeys"
highlight-current
:filter-method="filterMethod"
@node-click="handleNodeClick"
/>
配套调整:
- 搜索过滤:不再使用
computed递归过滤全树,改用filterMethod节点级过滤,虚拟列表只渲染匹配项。 - 默认展开:用
defaultExpandedKeys展开第一层,虚拟渲染下即使展开所有节点也无压力。
效果:弹窗打开几乎即时,即使上万节点也只渲染可视区域内的几十个 DOM。
完整架构总结
最终数据流
scss
handleSelectOrg()
├── getCachedOrg() → 内存命中?→ treeData = cached → 弹窗秒开 (0ms)
└── miss → getOrg()
├── restoreFromIndexedDB()? → 命中 → treeData = cached → 弹窗 (< 100ms)
└── miss → fetch(ArrayBuffer)
└── Worker: TextDecoder.decode (字节→字符串)
└── Worker: JSON.parse (字符串→JS 对象 6MB)
└── Worker: mapOrgData (递归映射)
└── postMessage Tree[]
├── setCachedOrg() (内存同步 + IndexedDB 异步)
└── treeData = result
└── el-tree-v2 虚拟渲染(只渲染可视区域DOM)
└── loading = false
核心文件职责
| 文件 | 层级 | 职责 |
|---|---|---|
orgWorker.ts |
计算层 | Worker 线程:二进制解码、JSON 解析、数据映射 |
orgCache.ts |
存储层 | 内存 + IndexedDB 两级缓存,24h 过期,同步/异步 API |
selectOrg.vue |
编排层 | UI + 缓存策略 + fetch 请求 + Worker 调度 + 虚拟滚动 |
递进关系
- Loading:解决用户感知,UI 底线
- IndexedDB:解决重复下载,持久化缓存
- 内存缓存:解决异步等待,会话内秒开
- Web Worker:解决主线程阻塞,6MB 解析移入后台
- 虚拟滚动:解决 DOM 渲染瓶颈,上万节点秒渲染
每一步解决的恰好是前一步遗留的瓶颈,形成一条完整的优化链。
写在最后
这次优化涉及了缓存策略、多线程计算和渲染层面的协同改进,最终将 10 秒以上的卡顿体验优化到秒开。关键心得:
- 不要过早优化:先解决最外层的用户体验问题(Loading),再逐层向内优化。
- 技术选型要贴合实际限制:比如绕开内部请求库改用原生 fetch,以及 IndexedDB 替代 localStorage。
- 每一步都要有明确的收益和遗留问题:这样才能推动下一步优化,最终形成体系。
如果你的项目中也遇到了类似的大数据量组织树渲染问题,希望这篇文章能提供一些思路。如果还有更好的方案,欢迎交流讨论。