
从数据结构入手:让树形控件“轻装上阵”
很多人做树形控件时,第一反应就是用嵌套结构来存数据——根节点下面套子节点,子节点再套孙子节点,就像文件夹一层层往下叠。这种结构看起来直观,但数据量一旦超过五千,问题就会集中爆发。我之前接手过一个医院的科室管理系统,他们的树形菜单用的就是嵌套结构,每个科室下面有多个病区,病区下面还有病房和病床,总共大概八千多个节点。当时用户反馈“展开科室要等3秒,编辑节点时页面直接卡住”,我查了一下代码,发现每次展开节点都要递归遍历整个嵌套对象找子节点,数据量大的时候递归次数能到几万次,浏览器主线程被占满,自然就卡了。
嵌套结构的“隐形杀手”:递归和深拷贝
嵌套结构之所以拖累性能,核心问题出在查询效率和数据操作上。咱们用代码举个例子,嵌套结构通常长这样:
// 嵌套结构示例
const treeData = [
{
id: 1,
label: '根节点',
children: [
{ id: 2, label: '子节点1', children: [...] },
{ id: 3, label: '子节点2', children: [...] }
]
}
]
要找某个节点的子节点,就得写个递归函数一层层往下找;如果要更新节点名称,又得递归找到那个节点才能改。更麻烦的是深拷贝——树形控件通常需要维护一份本地数据用于操作(比如勾选、展开状态),嵌套结构深拷贝时要递归复制每一层,数据量大的时候拷贝一次可能就要几百毫秒。我之前用JSON.parse(JSON.stringify(treeData))
拷贝一万个节点的嵌套数据,浏览器直接卡了1.2秒,用户体验瞬间拉胯。
扁平化存储:把“套娃”变成“抽屉”
后来我把数据改成了扁平化结构,性能一下就上来了。什么是扁平化结构?简单说就是把所有节点放在一个数组里,每个节点只存自己的id
和parentId
(父节点id),就像把所有文件都放进一个抽屉,贴上标签注明“属于哪个文件夹”。比如这样:
// 扁平化结构示例
const flatData = [
{ id: 1, label: '根节点', parentId: null },
{ id: 2, label: '子节点1', parentId: 1 },
{ id: 3, label: '子节点2', parentId: 1 }
]
然后用一个Map
(或者普通对象)把节点存起来,key
是节点id
,value
是节点数据。这样查询子节点时,根本不用递归,直接遍历数组找parentId
等于目标id
的节点就行;更新节点时,通过id
在Map
里直接定位,一步到位。我把医院那个项目改成扁平化结构后,查询子节点的时间从原来的200ms降到了15ms,编辑节点时再也不会卡了。
两种结构的性能对比:数据不会说谎
为了让你更直观看到差异,我做了个测试:分别用嵌套结构和扁平化结构处理1千、1万、10万级节点数据,记录查询子节点耗时、深拷贝耗时和内存占用,结果如下表(数据基于Chrome 114版本,测试代码用纯JavaScript实现,无框架干扰):
数据量 | 结构类型 | 查询子节点耗时(ms) | 深拷贝耗时(ms) | 内存占用(MB) |
---|---|---|---|---|
1千节点 | 嵌套结构 | 28 | 45 | 8.2 |
1千节点 | 扁平化结构 | 3 | 12 | 5.6 |
1万节点 | 嵌套结构 | 215 | 380 | 65.4 |
1万节点 | 扁平化结构 | 8 | 42 | 28.7 |
10万节点 | 嵌套结构 | 1890 | 2560 | 520.3 |
10万节点 | 扁平化结构 | 15 | 128 | 142.1 |
从表中能明显看到,数据量越大,扁平化结构的优势越明显。10万节点时,嵌套结构查询子节点要1.8秒,而扁平化结构只要15毫秒,快了120倍!内存占用也少了近70%。所以如果你还在用嵌套结构,赶紧试试扁平化,这一步就能解决60%的性能问题。
扁平化结构的“小技巧”:用Map加速查询
光把数据拍扁还不够,还得用高效的数据容器来存。我 用Map
对象(或者ES6的Object
,但Map的查询性能略好),把节点id作为key,节点数据作为value。比如:
// 扁平化数据+Map存储
const nodeMap = new Map();
flatData.forEach(node => {
nodeMap.set(node.id, node); // 用id作为key
});
// 查询子节点:直接过滤parentId等于目标id的节点
const getChildren = (parentId) => {
return flatData.filter(node => node.parentId === parentId);
};
这样查子节点时,用数组的filter
方法遍历一次就行,时间复杂度是O(n),但因为flatData是一维数组,遍历速度比嵌套结构的递归快得多。如果数据量超过10万,还可以提前建一个“父节点-子节点”的映射表,比如childrenMap
,key是parentId,value是子节点数组,这样查询子节点就能直接从childrenMap
里取,时间复杂度降到O(1):
// 预构建childrenMap
const childrenMap = new Map();
flatData.forEach(node => {
const parentId = node.parentId;
if (!childrenMap.has(parentId)) {
childrenMap.set(parentId, []);
}
childrenMap.get(parentId).push(node);
});
// 查询子节点:直接从childrenMap获取
const getChildren = (parentId) => {
return childrenMap.get(parentId) || [];
};
我在做那个十万级节点的项目时,先用了filter方法,查询耗时15ms,后来加了childrenMap,耗时降到了3ms,用户几乎感觉不到延迟。
渲染机制革新:只画用户能看到的部分
解决了数据结构问题,接下来要搞定“渲染”这个大头。很多人以为数据处理快了,界面就流畅了,其实不是。我之前帮一个物流系统做优化,数据结构改成扁平化后,查询速度快了,但渲染一万个节点时,页面还是卡——因为浏览器要同时渲染一万个DOM元素,这才是真正的性能杀手。
全量渲染的“DOM爆炸”:浏览器扛不住的压力
浏览器的渲染能力是有限的。我查过Chrome的性能数据,单个页面DOM元素超过1万,就会出现明显的卡顿;超过5万,页面可能直接崩溃。树形控件如果全量渲染,每个节点对应一个DOM元素(比如
我之前遇到过一个极端案例:某项目用Element UI的Tree组件,没开虚拟滚动,加载了8千个节点,结果DOM数量飙到4万个,页面首次渲染用了8秒,滚动的时候帧率只有15fps(正常要60fps才流畅)。用户说“滚动的时候像在看PPT”,后来我打开Chrome的Performance面板一看,主线程有一大段红色的“Long Task”(长任务),就是渲染DOM导致的。
虚拟滚动:只渲染“可视区域”的节点
要解决DOM数量太多的问题,最有效的办法就是虚拟滚动——简单说,就是只渲染用户当前能看到的节点,其他节点先“藏起来”,滚动的时候再动态替换。就像你看电子书,屏幕只能显示10页,没必要把整本书的内容都加载出来,翻页的时候再加载新内容就行。
虚拟滚动的核心原理有三个:
scrollTop
属性,知道用户滚动到了哪个位置,从而确定该显示哪些节点。 我用Vue写过一个简单的虚拟滚动树形控件,核心代码大概长这样(简化版):
// 虚拟滚动核心逻辑
computed: {
// 可视区域内的节点
visibleNodes() {
const startIndex = Math.floor(this.scrollTop / this.nodeHeight); // 起始索引
const endIndex = startIndex + this.visibleCount; // 结束索引
return this.flatData.slice(startIndex, endIndex); // 只取可视区域节点
},
// 占位元素高度(撑起滚动条)
placeholderHeight() {
return this.flatData.length this.nodeHeight; // 总节点数×单个节点高度
}
}
然后在模板里,用一个占位元素撑起高度,再渲染visibleNodes:
<div class="visible-nodes" style="{ transform: translateY(${startIndex nodeHeight}px)
}">
{{ node.label }}