vue 实现 tree 树形大量数据的虚拟滚动(包含树的搜索,勾选)

2,158 阅读4分钟

抛出问题

使用一般的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)),将可视区域数据渲染到页面即可。

image.png 数据说明:

列表项固定高度: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并设置到列表上

实际效果如图

3.jpg

2.jpg

微信图片_20220715152149.jpg 实际有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} /> &nbsp;&nbsp;
                                            </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;//解决切换空白的问题
}