抛出问题
使用一般的tree组件渲染大量数据(如几千个树节点)的时候会非常卡顿,主要原因是页面中绘制的大量的Dom,滚动或展开、收起不断造成页面重绘、回流,使得性能不佳。
最近在项目中(用的antd design vue)就遇到后端返回的数据里面的树节点有20000+,这还只是测试环境的数据,如果上线可能达到10万+,在根节点的时候树的渲染就要10s+。展开根节点的时候有600+节点,这是就渲染更慢了。当我展开第三级的时候有个节点下面竟然10000+的数据,没一会儿网页直接就崩了。当点击勾选的时候回填也是超级慢,基本七八秒起吧。
开始想到的是利用组件的异步加载方法,然后叫后端修改了接口,最后还是展开600+和10000+数据的时候也是超级慢,或者直接崩了。
然后我从网上找了一些方法,用了一些组件像vue-easy-tree,什么的,我发现我的项目不支持,然后大家都有看到antd design vue 3.X的版本加个height配置就可以实现虚拟滚动啊。可是我是2.X的项目,我不能升级,升级了会更多问题。尝试了了网上说的各种组件,还是不行,因为公司自带的一些npm插件的原因,最近就只有自己构造整颗树了。
解决思路
1、将树形结构数据平铺成一般的列表数据
2、采用padding缩进的方式来构造树形结构
3、再结合虚拟列表高效渲染长列表
4、实现树的搜索需要先将树形结构的数据进行搜索,然后获得的新数据再平铺成一般数据,但是和之前稍有点区别就是要展开所有的节点
虚拟列表的大致原理:当列表data中有n个item项,只需要渲染可视区域(比如10条)的item,页面滚动时获取scrollTop,scrollTop / itemHeight = startIndex(当前滚动了多少条的索引),可视区域的数据 = data.slice(startIndex, startIndex + 10)),将可视区域数据渲染到页面即可。
数据说明:
列表项固定高度:itemHeight
列表数据:data,源数据
当前滚动位置:scrollTop
可视区域的数据:visableData,就是你要真实渲染的数据
列表真实长度:itemHeight*data.length(这里的data是指所有可视区域的数据) ,制造滚动条
接着监听scroll事件,获取滚动位置scrollTop
计算当前可视区域起始数据索引(satrtIndex = Math.floor(scrollTop/itemHeight))
计算当前可视区域结束数据索引(endIndex = startIndex + visiableConunt)
计算当前可视区域的数据(visiableData = data.slice(satrtIndex ,endIndex )
计算satrtIndex 对应的数据在整个列表的偏移量offset并设置到列表上
实际效果如图
实际有2万+数据,页面始终渲染了24条数据
完整代码
index.vue
<div>
<Tree
:initTreeData="initTreeData"
:checkedList="checkedList"
@handleTreeCheck="handleTreeCheck"
/>
<a-button type="primary" @click="clearCheckList">
清空数据
</a-button>
</div>
</template>
<script>
import Util from "./utils.js";
import Tree from "./tree"
import axios from 'axios';
export default{
name:"Tree",
data(){
return{
initTreeData:[],
checkedList:[],
}
},
components:{
Tree
},
mounted(){
this.getTreeData();
},
methods:{
// 初始化树的结构
getInitData(){
// 调用后端接口获取数据
const getTreeData = () =>{
return new Promise((resolve,reject) =>{
axios.post("https://xxx.com",params).then(res=>{
resolve(res);
})
.cath(e =>{
reject(e);
});
});
};
(async () =>{
await getTreeData()
.then(res =>{
return res.data.data;
})
.then(res =>{
//处理原始数据,根据项目情况看是否需要处理
let newData = Util.getNewTreeData([res]);
this.initTreeData = Util.renserTreeNodes(newData);
})
})
},
// 保存勾选的数据
handleTreeCheck(info,e){
if(info){
if(e.target.checked){
this.checkedList.push(info.key);
}
if(e.target.checked === false){
// 取消勾选的数据复选框清除
this.checkedList = this.checkedList.filter(val => val != info.key)
}
}
},
clearCheckList(){
this.checkedList = [];
}
}
}
</script>
公用方法 utils.js
export default{
getNewTreeData(arr){
arr.map(item => {
if(item.children && item.userList){
let temp = [];
item.userList.map(item =>{
let obj ={
key:item.id || item.key,
title:item.fullname +`[${item.username}]`
};
temp.push(obj);
});
delete item.userList;
item.children.push(...temp);//把userlist里面的数据也放到children里和显示在一级
}
if(item.children && item.children.length > 0) {
this.getNewTreeData(item.children);
}else if(item.userList && item.userList.length > 0 ){
item.children = [];
let temp = [];
item.userList.map(item =>{
let obj ={
key:item.id || item.key,
title:item.fullname +`[${item.username}]`
};
temp.push(obj);
});
delete item.userList;
item.children.push(...temp);
}
});
return arr;
},
// 渲染树(这里返回的数据就是没平铺前的完整数据,包括图标等字段)
renderTreeNodes(data){
return data.map(item => {
if(item.children){
return {
title:item.title,
key:item.key,
children:this.renderTreeNodes(item.children),
userType:false,//用于区别是用户还是组织
slots:{
icon:"apartment"
}
};
}
if(item){
return {
title:item.title,
key:item.key,
userType:true,//用于区别是用户还是组织
slots:{
icon:"user"
}
};
}
})
},
// 搜索函数
serchData(arr,value){
if(!Array.isArray(arr) || arr.length == 0){
return;
}
let res = [];
arr.forEach(item =>{
if(
(item.title && item.title.indexOf(value) > -1) ||
(item.fullname && item.fullname.indexOf(value) > -1) ||
(item.username && item.username.indexOf(value) > -1)
){
const children = this.serchData(item.children,value);
const userList = this.serchData(item.userList,value);
const obj = {...item,children,userList};
res.push(obj);
}else{
if(
(item.children && item.children.length > 0) ||
(item.userList && item.userList.length > 0)
){
const children = this.serchData(item.children,value);
const userList = this.serchData(item.userList,value);
const obj = {...item,children,userList};
res.push(obj);
}
}
});
return res;
}
}
tree组件(这里是重点)
<script>
import Util from "./utils.js";
let options = {
itemHeight:30,
visiableCount:24 //这个数字要注意,24*30要比高度容器高度多一点,不要多太多,多要多滚动到最后会有显示问题
}
export default{
name:'tree',
data(){
return {
data:[],
visiableData:[],//当前可视的数据
contentHeight:10000,//滚动区域的高度
offset:0,
keyword:"", //搜索的关键字
newAllVisiableData:[] //滚动区域的所有数据
};
},
props:{
initTreeData:{
type:Array
},
checkedList:{
type:Array
},
},
// 页面内容我是在vue项目里面安装了jsx的,所有这样写,也可以采用在template里面写
render(h){
return(
<div>
<div style="display:flex">
<a-input onChange={this.inputChange} />
<a-button onClick={this.search}>查询</a-button>
</div>
<div ref="treeRef" onScroll={this.handleScroll} class="treeStyle">
{/** tree-pathtom 用于制造滚动条,=所有可视区域item的高度之和 */}
<div class="tree-pathtom" style={{height:this.contentHeight + "px"}}>
<div class="tree-content" style={{transform:`translateY(${this.offset})px`}}>
<a-checkbox-group value={this.checkedList}>
{this.visiableData.map((item,index) =>{
return(
<div
key={item.key}
class="tree-list"
style={{
paddingLeft:15 * (item.level-1) +(item.children ? 0 :15) + 5 +"px",
height:options.itemHeight +"px"
}}
>
{
item.children && item.children.length > 0 ?
(
<span onClick={e=>{
e.stopPropagation();
this.toogleExpand(item)
}}>
<a-icon type={item.expand ? "caret-down" : "caret-right"} />
</span>
) : (
<span style={{paddingLeft:"15px"}}></span>
)
}
<span>
<a-checkbox value={item.key} onChange={e=>{
this.onChange(e,item);
}}>
<span>
<a-icon type={item.slots.icon} />
</span>
<span title={item.title}>{item.title}</span>
</a-checkbox>
</span>
</div>
);
})}
</a-checkbox-group>
</div>
</div>
</div>
</div>
)
},
watch:{
// 监听data数据的变化
data(){
this.$nextTick(() =>{
if(this.data.length){
this.updateVisiableData();
}
});
},
// 监听展开的可视区域的数据变化更新容器的高度
newAllVisiableData(){
this.$nextTick(()=>{
this.contentHeight = this.newAllVisiableData * options.itemHeight;
})
}
},
mounted(){
this.data = [...Util(this.initTreeData && this.flattenData(this.initTreeData))];
},
methods:{
// 获取输入框的值
inputChange(e){
this.keyword = e.target.value;
},
// 点击查询
search(){
if(this.keyword){
let res = [...Util(this.initTreeData && Util.serchData(this.initTreeData,this.keyword))];
if(!res || !res.length){
alert("没有搜索到相匹配的而部门或人"); //给提示
return;
}
this.data = [...(res && this.flattenData(res))];
}else{
this.data = [...Util(this.initTreeData && this.flattenData(this.initTreeData))]; //输入框为空就至电视根节点,不展开
}
},
// 点击复选框
onChange(e,item){
this.$emit("handleTreeCheck",item,e); //把勾选的数据传给父组件
},
// 数据平铺处理
flattenData(tree,level = 1,parent = null,res = []){
tree.forEach(item =>{
item.level = level;
item.expand = this.keyword ? true :false; //当搜索的时候是默认都要展开的
item.parent = parent;
item.visiable = true; //是否显示
if(parent && !parent.expand){ //当父节点没展开的时候子节点全部不显示
item.value = false;
}
res.push(item);
if(item.children && item.children.length > 0){ //有子节点就进行递归
this.flattenData(item.children,level + 1, item, res);
}
});
return res;
},
// 获取所有可视数据
getAllvisiableData(){
return this.data.filter(item => item.visiable);
},
// 滚动页面的时候更新visiableData、offset
updateVisiableData(scrollTop = 0){
const start = Math.floor(scrollTop / options.itemHeight);
const end = start + options.visiableCount;
const allVisiableData = this.getAllvisiableData();
this.newAllVisiableData = allVisiableData;
const _visiableData = allVisiableData.slice(start,end);
this.visiableData - _visiableData;
this.offset = scrollTop;
},
// 监听treeRef容器的滚动
handleScroll(){
const {scrollTop} = this.$refs.treeRef;
this.updateVisiableData(scrollTop);
},
// 根据展开与否来显示更新子节点的子项
recursionVisiable(children = [],status){
children.forEach(node =>{
// 如果是折叠-->折叠 所有子项;如果是展开-->显示下一级
node.visiable = status;
if(!status){
node.expand = false;
}
if(node.children && node.children.length && !status){
this.recursionVisiable(node.children,status);
}
})
},
// 折叠展开事件
toogleExpand(item){
const isExpand = !item.expand;
item.expand = isExpand;
this.recursionVisiable(item.children,isExpand);
// 更新视图
this.handleScroll();
}
}
}
</script>
<style lang="less" scoped>
.tree-list{
white-space: nowrap;
line-height: 30px;
}
.treeStyle{
height: 670px;
overflow-y: auto;
margin-top: 8px;
border: 1px solid #e8e8e8;
}
</style>
从后端返回的接口数据大致是这样的
{
code: 0,
data: {
children: [
{ key: 123, title: "中国银行北京分行", userList: [], children: [{ key: 1239, title: "中国银行北京昌平分行", userList: [{ username: "000113", id: "33", "fullname": "秀秀" }], children: [] }] },
{ key: 123, title: "中国银行北京分行", userList: [], children: [{ key: 1268, title: "中国银行北京XX分行", userList: [{ username: "000111", id: "81", "fullname": "蕾蕾" }], children: [] }] },
{ key: 124, title: "中国银行上海分行", userList: [], children: [] },
],
key: 1,
title: "中国银行",
userList: [
{ username: "000111", id: "1", "fullname": "张三" },
{ username: "000221", id: "2", "fullname": "李四" },
{ username: "000115", id: "3", "fullname": "王五" },
{ username: "000115", id: "4", "fullname": "钱六" },
{ username: "000116", id: "5", "fullname": "陈起" },
{ username: "000118", id: "6", "fullname": "申八" },
]
}
补充bug
①问题描述
写成一个公共组件用后发现一个bug,A页面的树滚动了一部分,切换去B页面,然后再切回A页面,会出现滚动距离那么高的空白部分,如图所示
②解决办法
就是滚动条滚动的距离不对,需要把滚动条定位到之前滚动的位置就可以啦,修改方法如下
activated(){
document.getElementsByClassName("treeStyle")[0].scrollTop = this.offset;//解决切换空白的问题
}