目录
抛出问题
使用一般的tree组件渲染大量数据(如几千个树节点)的时候会非常卡顿,主要原因是页面中绘制的大量的Dom,滚动或展开、收起不断造成页面重绘、回流,使得性能不佳。
解决思路
Step1:将树形数据拍平成一般的List
Step2:采用padding缩进的方式营造树形结构
Step3:在结合虚拟列表高效渲染长列表
虚拟列表大致原理:当列表data中有n个item项,我们只渲染可视区域(比如10条)的item,页面滚动时获取到scrollTop,scrollTop / itemHeight = startIndex(当前滚动了多少条的索引),可视区域的数据 = data.slice(startIndex, startIndex + 10)),将可视区域数据渲染到页面即可。
数据说明:
列表项高度固定:itemHeight
列表数据:data,源数据
当前滚动位置:scrollTop
可视区域的数据:
visibleData
,就是你要真实渲染的数据列表真实长度:itemHeight * data.length,制造滚动条
接着监听的scroll事件,获取滚动位置scrollTop
计算当前可视区域起始数据索引(startIndex = Math.floor(scrollTop / itemHeight) )
计算当前可视区域结束数据索引(endIndex = startIndex + visibleCount)
计算当前可视区域的数据 (
visibleData
= data.slice(startIndex,endIndex))计算startIndex对应的数据在整个列表中的偏移量offset并设置到列表上
实际效果
完整代码
import React, { useCallback, useEffect, useRef, useState } from 'react';
import './index.css';
import { originData } from './mockData';
// 配置项
const options = {
defaultExpand: 1,
itemHeight: 30,
visibleCount: 15,
};
// 将树形数据转成普通列表数据(我用的是中国省-市-区的树形数据)
function flattenData() {
function flatten(tree, childKey = 'children', level, parent = null) {
let res = [];
tree.forEach((item) => {
item.level = level;
item.expand = level === 1;
item.parent = parent;
if (item.visible === undefined) {
item.visible = true;
}
if (!parent.visible || !parent.expand) {
item.visible = false;
}
res.push(item);
if (item[childKey] && item[childKey].length) {
res.push(...flatten(item[childKey], childKey, level + 1, item));
}
});
return res;
}
return flatten(originData, 'children', 1, {
level: 0,
visible: true,
expand: true,
value: '中国',
children: originData,
});
}
// 定义组件
function VirtualTree() {
// 如果是vue把data、visibleData...等定义在data() {}里,把setXxx定义在methods里即可
const treeRef = useRef();
const [data, setData] = useState([]);
const [visibleData, setVisibleData] = useState([]);
const [contentHeight, setContentHeight] = useState(10000);
const [offset, setOffset] = useState(0);
// 模拟获取接口数据
const getData = useCallback(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(flattenData());
}, 500);
});
}, []);
// 挂载时获取原始数据,相当于vue的 mounted()
useEffect(() => {
getData().then((res) => {
setData(res);
});
}, []);
// data变化更新可视数据,相当于vue的watch data
useEffect(() => {
if (data.length) {
updateVisibleData();
}
}, [data]);
// 可视数据变化更新容器高度, 相当于vue的watch visibleData
useEffect(() => {
setContentHeight(visibleData.length * options.itemHeight);
}, [visibleData]);
// 获取所有可视数据
function getAllVisibleData() {
return data.filter((item) => item.visible);
}
// 滚动页面时更新 visibleData、offset
function updateVisibleData(scrollTop = 0) {
const start = Math.floor(scrollTop / options.itemHeight);
const end = start + options.visibleCount * 2;
const allVisibleData = getAllVisibleData();
const _visibleData = allVisibleData.slice(start, end);
setVisibleData(_visibleData);
setOffset(scrollTop);
}
function handleScroll() {
const { scrollTop } = treeRef.current;
updateVisibleData(scrollTop);
}
function recursionVisible(children = [], status) {
children.forEach((node) => {
// 如果是折叠-->折叠所有子项; 如果是展开-->显示下一级
node.visible = status;
if (!status) {
node.expand = false;
}
if (node.children && node.children.length && !status) {
recursionVisible(node.children, status);
}
});
}
// 折叠与展开
function toggleExpand(item) {
const isExpand = !item.expand;
item.expand = isExpand;
recursionVisible(item.children, isExpand);
// 更新视图
handleScroll();
}
return (
<div ref={treeRef} className="tree" onScroll={handleScroll}>
{/* tree-phantom是用于制造滚动条 ,= 所有可视item的高度之和 */}
<div className="tree-phantom" style={{ height: contentHeight }}></div>
<div
className="tree-content"
style={{ transform: `translateY(${offset}px)` }}
>
{visibleData.map((item, index) => {
return (
<div
key={item.value + item.parent.value}
className="tree-list"
style={{
paddingLeft:
15 * (item.level - 1) + (item.children ? 0 : 15) + 'px',
height: options.itemHeight + 'px',
}}
>
{item.children && item.children.length && (
<span
onClick={(e) => {
e.stopPropagation();
toggleExpand(item);
}}
>
<i className={item.expand ? 'tree-expand' : 'tree-close'} />
</span>
)}
<span>{item.label}</span>
</div>
);
})}
</div>
</div>
);
}
export default VirtualTree;
优化:封装成组件VirtualTree
就是把列表数据data、和配置项options传入。考虑到每个item项你可能还需要自定义其他内容(比如说 收藏、添加、删除节点等)通过render.props把你要展示的每个item的content传进来(你可以自定义一些事件回调,我这里没写,只做了展示)
import React, { useEffect, useRef, useState } from 'react';
import './index.css';
// 定义组件
function VirtualTree(props) {
const { data, options } = props;
// 如果是vue把data、visibleData...等定义在data() {}里,把setXxx定义在methods里即可
const treeRef = useRef();
const [visibleData, setVisibleData] = useState([]);
const [contentHeight, setContentHeight] = useState(10000);
const [offset, setOffset] = useState(0);
// data变化更新可视数据,相当于vue的watch data
useEffect(() => {
if (data.length) {
updateVisibleData();
}
}, [data]);
// 可视数据变化更新容器高度, 相当于vue的watch visibleData
useEffect(() => {
setContentHeight(visibleData.length * options.itemHeight);
}, [visibleData]);
// 获取所有可视数据
function getAllVisibleData() {
return data.filter((item) => item.visible);
}
// 滚动页面时更新 visibleData、offset
function updateVisibleData(scrollTop = 0) {
const start = Math.floor(scrollTop / options.itemHeight);
const end = start + options.visibleCount * 2;
const allVisibleData = getAllVisibleData();
const _visibleData = allVisibleData.slice(start, end);
setVisibleData(_visibleData);
setOffset(scrollTop);
}
function handleScroll() {
const { scrollTop } = treeRef.current;
updateVisibleData(scrollTop);
}
function recursionVisible(children = [], status) {
children.forEach((node) => {
// 如果是折叠-->折叠所有子项; 如果是展开-->显示下一级
node.visible = status;
if (!status) {
node.expand = false;
}
if (node.children && node.children.length && !status) {
recursionVisible(node.children, status);
}
});
}
// 折叠与展开
function toggleExpand(item) {
const isExpand = !item.expand;
item.expand = isExpand;
recursionVisible(item.children, isExpand);
// 更新视图
handleScroll();
}
return (
<div ref={treeRef} className="tree" onScroll={handleScroll}>
{/* tree-phantom是用于制造滚动条 ,= 所有可视item的高度之和 */}
<div className="tree-phantom" style={{ height: contentHeight }}></div>
<div
className="tree-content"
style={{ transform: `translateY(${offset}px)` }}
>
{visibleData.map((item, index) => {
return (
<div
key={item.value + item.parent.value}
className="tree-list"
style={{
paddingLeft:
15 * (item.level - 1) + (item.children ? 0 : 15) + 'px',
height: options.itemHeight + 'px',
}}
>
{item.children && item.children.length && (
<span
onClick={(e) => {
e.stopPropagation();
toggleExpand(item);
}}
>
<i className={item.expand ? 'tree-expand' : 'tree-close'} />
</span>
)}
{props.render(item)}
</div>
);
})}
</div>
</div>
);
}
export default VirtualTree;
其他地方使用
import React, { useCallback, useEffect, useState } from 'react';
import { originData } from './mockData';
import VirtualTree from '../components/VirtualTree';
// 配置项
const options = {
defaultExpand: 1,
itemHeight: 30,
visibleCount: 15,
};
// 将树形数据转成普通列表数据(我用的是中国省-市-区的树形数据)
function flattenData() {
function flatten(tree, childKey = 'children', level, parent = null) {
let res = [];
tree.forEach((item) => {
item.level = level;
item.expand = level === 1;
item.parent = parent;
if (item.visible === undefined) {
item.visible = true;
}
if (!parent.visible || !parent.expand) {
item.visible = false;
}
res.push(item);
if (item[childKey] && item[childKey].length) {
res.push(...flatten(item[childKey], childKey, level + 1, item));
}
});
return res;
}
return flatten(originData, 'children', 1, {
level: 0,
visible: true,
expand: true,
value: '中国',
children: originData,
});
}
function TreeItem({ item }) {
return <span>{item.label}</span>;
}
function Tree() {
const [data, setData] = useState([]);
// 模拟获取接口数据
const getData = useCallback(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(flattenData());
}, 500);
});
}, []);
// 挂载时获取原始数据,相当于vue的 mounted()
useEffect(() => {
getData().then((res) => {
setData(res);
});
}, []);
return (
<VirtualTree
data={data}
options={options}
render={(item) => <TreeItem item={item} />}
/>
);
}
export default Tree;
附:源数据
【因为太多了只截取了北京市数据】
export const originData = [
{
children: [
{
value: '东城区',
label: '东城区',
},
{
value: '西城区',
label: '西城区',
},
{
value: '朝阳区',
label: '朝阳区',
},
{
value: '丰台区',
label: '丰台区',
},
{
value: '石景山区',
label: '石景山区',
},
{
value: '海淀区',
label: '海淀区',
},
{
value: '门头沟区',
label: '门头沟区',
},
{
value: '房山区',
label: '房山区',
},
{
value: '通州区',
label: '通州区',
},
{
value: '顺义区',
label: '顺义区',
},
{
value: '昌平区',
label: '昌平区',
},
{
value: '大兴区',
label: '大兴区',
},
{
value: '怀柔区',
label: '怀柔区',
},
{
value: '平谷区',
label: '平谷区',
},
{
value: '密云区',
label: '密云区',
},
{
value: '延庆区',
label: '延庆区',
},
],
value: '北京市',
label: '北京市',
}
]