6MB 组织树大文件性能优化全流程

前言

在物料基础设置页面中,有一个「选择组织机构」弹窗,使用 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 解析和数据映射

这是最关键的性能瓶颈突破。原始流程中,以下三个重操作都在主线程执行:

  1. axios 自动调用 JSON.parse(6MB) ------ 阻塞主线程 2~5 秒
  2. 递归 mapOrgData ------ 遍历上万嵌套节点做字段映射
  3. 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.decodeJSON.parsemapOrgData

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-treedefault-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 调度 + 虚拟滚动

递进关系

  1. Loading:解决用户感知,UI 底线
  2. IndexedDB:解决重复下载,持久化缓存
  3. 内存缓存:解决异步等待,会话内秒开
  4. Web Worker:解决主线程阻塞,6MB 解析移入后台
  5. 虚拟滚动:解决 DOM 渲染瓶颈,上万节点秒渲染

每一步解决的恰好是前一步遗留的瓶颈,形成一条完整的优化链。


写在最后

这次优化涉及了缓存策略、多线程计算和渲染层面的协同改进,最终将 10 秒以上的卡顿体验优化到秒开。关键心得:

  • 不要过早优化:先解决最外层的用户体验问题(Loading),再逐层向内优化。
  • 技术选型要贴合实际限制:比如绕开内部请求库改用原生 fetch,以及 IndexedDB 替代 localStorage。
  • 每一步都要有明确的收益和遗留问题:这样才能推动下一步优化,最终形成体系。

如果你的项目中也遇到了类似的大数据量组织树渲染问题,希望这篇文章能提供一些思路。如果还有更好的方案,欢迎交流讨论。

相关推荐
GitLqr2 小时前
Vue 3.6 Vapor Mode vs React 19 Compiler:前端架构的两条分叉路
vue.js·react.js·vapor
武子康2 小时前
Inkling 975B 说明“开放权重“与“普通开发者本地运行“已经分离,内容重点应是部署容量和运行时边界
前端·人工智能·后端
TeamDev2 小时前
JxBrowser 9.3.1 版本发布啦!
java·前端·javascript·c#·混合应用·jxbrowser·浏览器控件
颜酱2 小时前
03 | 实现节点1 — 抽取关键词
前端·人工智能·后端
整點薯條3 小时前
Vue3+vite创建项目架构文件说明总结
前端·javascript·vue.js
掘金一周3 小时前
想问问掘友们:AI 时代,资深开发的核心价值到底在哪里?| 沸点周刊 7.23
前端·人工智能·后端
橘子星3 小时前
🚀 手把手带你写一个端侧 AI 应用的"加载进度条"与"聊天对话框"
前端·人工智能
37.2℃9954 小时前
专业的Claude Design解决方案
前端·算法
Jolyne_4 小时前
ng-zorro-table列宽resize后宽度是NaN的源码调试记录
前端