针对6MB组织树大文件渲染卡顿,采用三级缓存(内存、IndexedDB、HTTP)、WebWorker解析JSON并映射数据、el-tree-v2虚拟滚动等递进优化,将10秒以上卡顿降至秒级响应,形成完整性能优化方案,显著提升用户体验。
MB组织树大文件性能优化全流程
时间:2026-07-24
浏览:131
文章目录
前言
在物料基础设置页面中,有一个“选择组织机构”弹窗,用的是 el-tree 来展示集团组织架构。接口返回的数据量相当惊人——大约 6MB 的 JSON,里面包含了上万个组织节点的嵌套树结构。在原始实现下,从点击按钮到弹窗出现,需要等待 10 秒以上,期间页面完全卡死,用户几乎以为系统崩溃了。

这篇文章会完整记录整个优化过程,从问题定位到最终方案落地,一步步把 10 秒的卡顿优化到秒开,并沉淀出一套可复用的三级缓存 + Web Worker + 虚拟滚动方案。
问题定位:组织树性能瓶颈分析
来看看原始代码的执行链条,每一步都在主线程上硬扛:
复制代码打开弹窗 → axios POST 请求 → 主线程 JSON.parse(6MB) → 主线程递归 mapOrgData → el-tree default-expand-all 全量渲染
每个环节都是重量级操作,任何一个步骤都能把浏览器卡住几秒。接口返回的原始数据结构是这样的:
复制代码{
"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 指令:
复制代码
这一步并没有提升性能,但它保证了无论后续优化到什么程度,用户都能感知到系统正在响应。
优化第二步:IndexedDB 持久化缓存减少重复请求
原始代码每次打开弹窗都会重新请求 6MB 数据。同一个用户同一天内反复操作时,这些下载是完全不必要的浪费。
由于数据量超过了 localStorage 的 5MB 限制,我们选择用 IndexedDB 来做持久化缓存。
新建 orgCache.ts (src/utils/orgCache.ts),封装原生 IndexedDB API,设置 24 小时有效期:
复制代码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 改为同步方法,并在弹窗打开时优先同步查询内存:
复制代码// 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 恢复内存缓存
// ...
}
弹窗逻辑调整:
复制代码function handleSelectOrg() {
dialogSelectOrgVisible.value = true;
const cached = getCachedOrg(); // 同步,0ms
if (cached) {
treeData.value = cached; // 弹窗秒开
return;
}
getOrg(); // 首次打开走异步链路
}
三级缓存体系升级:
复制代码弹窗打开
→ 内存缓存(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。
复制代码// 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
复制代码// orgWorker.ts
globalThis.onmessage = (event: MessageEvent ) => {
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 组件,基于虚拟滚动,只渲染可视区域内的节点,完美解决上万节点全量渲染的问题。
替换前后对比:
复制代码
配套调整:
- 搜索过滤:不再使用
computed递归过滤全树,改用filterMethod节点级过滤,虚拟列表只渲染匹配项。 - 默认展开:用
defaultExpandedKeys展开第一层,虚拟渲染下即使展开所有节点也无压力。
效果:弹窗打开几乎即时,即使上万节点也只渲染可视区域内的几十个 DOM。
完整架构总结
最终数据流
复制代码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。
- 每一步都要有明确的收益和遗留问题:这样才能推动下一步优化,最终形成体系。
如果你的项目中也遇到了类似的大数据量组织树渲染问题,希望这篇文章能提供一些思路。如果还有更好的方案,欢迎交流讨论。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。