基于vue2封装一个公共的 Tree 组件,实现流畅全选超大数据

1,366 阅读6分钟

前言

  1. LazyTree 组件是对于 FES 里面 Tree 组件的一个补充。开始之前先推荐一下我司的 FES,一个优秀的管理台应用解决方案,类似于 vue-element-admin ,但是比它更好用,感兴趣的朋友可以点击 传送门 去看一下,确实很节省开发时间。
  2. 目前 LazyTree 组件已经被 fes2.0 的公司内部内版本收录。
  3. 取名为 LazyTree 的原因是 Tree 组件名称已经被占用了。

gitee 地址

  1. gitee项目链接 gitee.com/jiangxiZhan…
  2. lazyTree 文件目录地址:gitee.com/jiangxiZhan…

主要文件

lazyTree.vue 入口根节点,处理主要逻辑
lazyTreeNode.vue 叶子节点,处理选中等事件

封装 LazyTree 组件的背景

为什么我要封装 LazyTree 组件

  1. 接手了公司的一个老项目,发现里面有些地方用到树形结构,但是里面的树形结构并不是用的公司共用组件库里面的 Tree 组件,而是用了一个叫 liquor-tree 的插件。观察了一下,发现原来是因为 Tree 组件不支持过滤节点的功能。
  2. 项目上有需求用树形结构展示选择公司的人员信息。公司大约有7000人左右,包含部门和科室信息的话总共是大约是7500条数据,使用 liquor-tree 全选整个公司组织架构的时候照成卡顿,卡顿时间 2s到3s,确实无法接受,我接手以后,业务都次提到想要优化该功能。
  3. 尝试过用自己过滤数据,传入 Tree 组件,实现过滤功能,发现效果不满意,全选依旧有卡顿的问题。

Tree 组件和 liquor-tree 数据量大卡顿的原因

  1. Tree 组件里面直接把折叠元素也渲染出来了,只是简单做了 display: none。这样的话就算不做其他操作,单单是渲染近万个节点就已经是很大的负担。一旦全选节点再去递归调用 setChild 事件,对内存的负担很大,也会导致卡顿。
  2. 当触发选择的时候 liquor-tree 组件去做一个递归去触发 checked 事件,递归过程中会先去寻找节点,然后触发点击事件,这样成本是高昂的。代码如下
check() {
    if (this.node.checked()) {
      this.node.uncheck()
    } else {
      this.node.check()
    }
  }

贴一下全选时候chrome Performance 中的截图,可以看到卡顿的原因确实是 checked 调用引起的,这里 checked 执行的时间达到了惊人的 65000ms image 当然,其实这个递归调用的问题在Tree组件中一样也是存在的

实现的过程

分析怎么写出性能好的树形组件

  1. 惰性加载,当一个节点没有展开的时候我们无需去加载它的子节点,只有展开的时候才去加载。
  2. 目前组件的卡顿都是很大原因是递归调用了每个节点的点击事件,有多少个节点就需要调用多少次函数,而去调用函数的性能其实并不高。那我想我们只需要规避调如何复杂的递归调动就行。递归调用 checked 的原因是要更新每一个节点的被选中的状态,其实我们只需要把所有选中和未选中的状态记录在 data 中就可以,每次点击一个点击的时候去更新data中的数据就可以达到目的。

先写个文档,看需要哪些内容

image

代码实现

处理用户传入的 data 信息
  1. 用户传入的data信息不能直接使用。需要做一些简单的处理,当用户用户传入的 data 信息或者 defaultSelectedIds 改变的时候就需要处理data信息,把处理后的数据设置为 list。
  2. 这里的话我们主要是处理选中状态,我们转化一下,selected 分为3中状态分别是0:未选中,1:半选,2:选中。半选就是说子节点有部门选中,父节点就会进入半选状态。
  3. 主要代码如图
lazyTree.vue

transformData(list, level = 1, parentSelected = 0, parent = null) { // 初始化原始数据
    return list.map((item) => {
        const {
            id, name, expand = false, data = null, children = []
        } = item;
        let { selected = 0 } = item;
        if (this.multiple === false && this.defaultSelectedIds.length === 0 && selected === true) {
            this.selectedId = id;
        }
        if (selected === true) {
            selected = 2;
        }
        selected = this.selectedIdsMap[id] ? 2 : selected;
        if (parentSelected === 2) {
            selected = 2;
        }
        const obj = {
            id, name, expand, data, level, isLeaf: children.length === 0, selected, isShow: true, parent
        }
        const newChildren = this.transformData(children, level + 1, selected, obj);
        obj.children = newChildren;
        return obj;
    });
}
更新选中信息
  1. 这里的选中信息主要是靠数据进行驱动,主要表现在于所有非叶子节点的选中状态都是由其叶子节点决定,这样当选中叶子节点的时候我们先更新它的selected状态为2,然后去计算其父节点的选中状态。父节点的选中状态只需要由他的直系子节点决定。
  2. 更新子节点主要代码
lazyTreeNode.vue

updateChildrenSelected(node, selected) {
    node.forEach((item) => {
        const { children } = item;
        item.selected = selected;
        this.updateChildrenSelected(children, selected);
    });
}
  1. 更新父节点选中状态主要代码
lazyTree.vue

updateParentSelected(node) {
    if (!node || !node.parent) {
        return;
    }
    let allSelectCount = 0;
    let someSelectCount = 0;
    const children = node.parent.children;
    children.forEach((item) => {
        const { selected } = item;
        if (selected === 1) {
            someSelectCount += 1;
        } else if (selected === 2) {
            allSelectCount += 1;
        }
    });
    if (someSelectCount === 0 && allSelectCount === 0) {
        node.parent.selected = 0;
    } else if (someSelectCount > 0) {
        node.parent.selected = 1;
    } else if (allSelectCount > 0 && allSelectCount < children.length) {
        node.parent.selected = 1;
    } else if (allSelectCount === children.length) {
        node.parent.selected = 2;
    }
    this.updateParentSelected(node.parent);
}
过滤节点功能
  1. 这里允许用户传入 filterText 字段进行过滤节点,我们只需要给一个 isShow 字段进行控制即可。当然防抖也是必不可少的。
  2. 主要代码如下
lazyTree.vue

防抖处理
watch: {
    filterText: {
        immediate: true,
        handler(text) {
            clearTimeout(this.timer);
            this.timer = setTimeout(() => {
                this.filterLabel = text;
                this.filterTree(this.list, this.filterLabel.toLowerCase());
                this.list.forEach((node) => {
                    this.updateSelected(node);
                });
            }, 300);
        }
    }
}

过滤节点
filterTree(list, filterLabel, show = false) { // 根据 name 字段过滤信息
    if (!list || list.length === 0) {
        return;
    }
    list.forEach((item) => {
        const { name, children = [] } = item;
        if (show === true || filterLabel.length === 0 || name.toLowerCase().indexOf(filterLabel) > -1) {
            item.isShow = true;
            this.filterTree(children, filterLabel, true);
        } else {
            item.isShow = this.filterTree(children, filterLabel, false);
        }
    });
    return list.some(item => item.isShow)
}

性能对比

在数据量相同的情况下(7500条左右),全选完成完全没有卡顿,多次测试完成时间都控制在100ms之内,贴一下performance中的截图 image

性能好的原因分析

比如说我们传入的数据结构如下:

data2:[{
    name: '江苏',
    id: '01',
    expand: true,
    children: [{
        name: '南京',
        id: '0101',
    }, {
        name: '苏州',
        id: '0102',
        children: [{
            name: '吴江',
            id: '010201'
        }, {
            name: '常熟',
            id: '010202'
        }]
    }]
}, {
    name: '云南',
    id: '02',
    children:[...]
}, {
    name: '福建',
    id: '03',
    children:[...]
}],

我们选中苏州的时候会首先去遍历它的自己子节点,吴江和常熟,给自己子节点都加上选中的标记,然后直接去更新父节点的选中状态。更新完成以后就可以重新渲染组件。我们假设有n条数据,树形结构有5层,最坏的结果就是全选根节点,这样每一条数据都都需要去遍历一般更新一下选中状态,时间复杂度是 O(n), 可以说计算量是很小的。我们也不会说从根节点出发,去寻找到每一个节点,然后去触发它的点击事件,而是直接在根组件里面更新数据,然后更新页面,没有其他多余操作,自然全选也就比较流畅了。测试了一下几万条数据量,依旧是十分流畅,没人任何卡顿。