Vue2 手写虚拟树组织树tree,树支持超大数据量不卡顿,手写el-tree部分功能,支持全选和非全选

1,245 阅读6分钟

1. 场景描述

  • 你有没有碰到这种情况,当你使用element-ui的el-tree组件时,你的树的节点量很大很大(10000+),每个节点下面的子节点也有可能多达10000+,使用el-tree首屏加载非常慢
  • 点击某个节点,由于子节点非常多,导致要等很久节点才能够展开
  • 页面滚动很卡顿,点击某个节点也很卡
  • 后端不支持点击某个节点返回对应的子节点信息,而是一次性把所有节点的信息返回给你了,你自己去拼成tree

为什么会出现上面的情况呢?

  • 其实js处理数据是很快的,原因是数据多渲染到页面,dom节点多导致的卡顿

如何解决呢?

  • 解决的思路,减少渲染的dom节点的数量,我们就要用到虚拟滚动,但是展现的数据是有层级递归的所以我们实现虚拟树。咱一起来手写一个虚拟树组件。(Vue2版本,Vue3思路应该大差不差)

gitee代码地址:gitee.com/Lizhihang12…

2. 组件描述

  • 组件用于生成组织树
  • 组件需要支持任何时候,都只渲染固定数量的数据
  • 组件滚动的时候需要更新数据
  • 点击某个节点,展开子节点,此时也要能够更新数据
  • 滚动事件不能触发太多次,使用防抖还是节流?白屏情况怎么办
  • 如果你需要支持搜索数据,那么这个组件也要能够支持

最终效果: image.png

3. 组件开始

假设是在vue2的环境中实现下面的代码,快去搭建一个基础的环境吧

3.1 数据结构

数据结构如下,整体上是数组,每一项数据可能包含children数组代表子节点

    let treeData = [
        {
            name: '1',
            path: '/1',
            showChildren: false, 
            children: [
                {
                    name: '11',
                    path: '/1/11',
                    showChildren: false, 
                    children: [
                        {
                            name: '111',
                            path: '/1/11/111',
                            showChildren: false, 
                            children: []
                        }
                    ]
                }
            ]
        },
        {
            name: '2',
            path: '/2',
            showChildren: false, 
            children: [
                {
                    name: '22',
                    path: '/2/22',
                    showChildren: false, 
                    children: [
                        {
                            name: '222',
                            path: '/2/22/222',
                            showChildren: false, 
                            children: []
                        }
                    ]
                }
            ]
        },
        {
            name: '3',
            path: '/3',
            showChildren: false, 
            children: [
                {
                    name: '33',
                    path: '/3/33',
                    showChildren: false, 
                    children: [
                        {
                            name: '333',
                            path: '/3/33/333',
                            showChildren: false, 
                            children: [
                              {
                                  name: '3333',
                                  path: '/3/33/333/3333',
                                  showChildren: false, 
                                  children: []
                              }
                            ]
                        }
                    ]
                }
            ]
        },
      ]
  • name一般是用于树的显示
  • path是完整的路径
  • showChildren用于控制是否展开子节点
  • children是子节点的内容
  • 其实还应该有level表示每一层的深度,这个我们在下面的函数中再添加

3.2 拍平数据

这是很关键的一步。为什么要拍平这个数据呢?只要把原本的嵌套数据转化为一维的数组,然后用startend索引去截取里面的数据,就能实现每次只展示一部分数据的效果。

如何拍平呢?就是按照顺序递归遍历数据及其children,依次放到数组中

    function flatten = (
        list = [], // 要排名的数据
        level = 1, // 树的深度
        parent = null, // 父节点的数据
        defaultExpand = false // 控制每一层是展开还是关闭
    ) => {
        let arr = []
        for (let i = 0; i < list.length; i++) {
            let item = list[i]
            // 设置深度
            this.$set(item, 'level', level) 
            // 默认所有的节点都不展开,不显示子节点
            this.$set(item, 'showChildren', defaultExpand) 
            if (item.visible === undefined && item.level > 1) {
               // visible变量用于控制是否显示,
               // 我们的原理就是每次只展示固定数量的节点
               // 有的节点隐藏就是通过这个变量判断
               // 我们让层级大于1的都隐藏
               this.$set(item, 'visible', false) 
            } else if (item.visible === undefined) {
               // 层级是1的显示
               this.$set(item, 'visible', true) 
            }
            
            // 设置parent的值
            this.$set(item, 'parent', parent) 
            // 把当前的item的值放到arr中
            arr.push(item)
            
            // 递归遍历
            if (item['children'] && item['children'].length > 0) {
                arr.push(
                    ...this.flatten(
                        item['children'],
                        level + 1, // 树的深度递归就 + 1
                        item, // 当前的item用作父亲
                        defaultExpand // 控制每一层是展开还是关闭 
                    )
                )
            }
        }
        return arr
    }
  1. 声明一个arr数组,最终拍平的结果都放在这个里面
  2. 我们依次设置每个节点的level,showChildren,visible,parent,children
  • level: 每个节点所在的层级
  • showChildren:是否展示子节点
  • visible:当前节点是否可见,如果父节点showChildren是true,子节点的visible就是true,否则是false,假设所有的一级节点的visible是true,所有的二级节点是false
  • parent:当前节点的父节点是谁
  • children:当前节点的子节点是谁
  1. 需要递归遍历子节点

3.3 visible为true的数据

拍平后数据长啥样呢?

    const flattenTree = this.flatten(this.treeData)
    // flattenTree应该是长这样的
    /*
        [
            {name: '1', path: '/1', level: 1, children: [{name: '11', path: '/1/11', level: 2, children: []}], showChildren: true},
            {name: '11', path: '/1/11', level: 2, children: [……], showChildren: false},
            {name: '111', path: '/1/11/111', level: 3, children: [……], showChildren: false},
            {name: '1111', path: '/1/11/111/1111', level: 4, children: [……], showChildren: false},
            {name: '11111', path: '/1/11/111/1111/11111', level: 5, children: [……], showChildren: false},
        ]
    */ 

上面是所有的数据拍平后的结果,这个flattenTree可能有10000条+,也就是所有的节点的数量包括子节点的数量加起来的和,我们最终的是allVisibleData中visible属性为true的节点数据,visible为true的节点。

this.allVisibleData = (this.flattenTree || []).filter(item => item.visible)

这个是什么意思呢?比如节点1下面有节点1/1和节点1/1/1,由于节点1是折叠的,所以他的子节点的visible的数据是false。我们去截取数组的时候,肯定从visible是true里面的去截取

3.4 start和end的截取

start和end是哪里来?是滚动时判断出来的,监听滚动条滚动的距离,判断此时从哪里开始显示

下面是给指定的dom元素绑定滚动事件

  <div 
    class="viewport"
    ref="viewport"
    @scroll="handleScroll"
    >
  </div>
    handleScroll () {
     if (this.$refs.viewport) {
        this.top= this.$refs.viewport.scrollTop // 拿到滚动距离
        this.updateVisibleData(this.top) // 处理可视数据
      }
    },

updateVisibleData函数里面处理最终的数据

    // 更新可视区域的数据
    updateVisibleData (scrollValue = 0) {
      // 开始索引,防止白屏,多减去一部分的距离
      let numMore = Math.floor(scrollValue / this.option.itemHeight) - Math.floor(this.option.count)
      let num = Math.floor(scrollValue / this.option.itemHeight)
      this.start = numMore < 0 ? num : numMore
      // 结束索引,防止白屏,多加一部分距离
      this.end = this.start + this.option.count * 2
      // 所有显示数组
      this.allVisibleData = (this.flattenTree || []).filter(item => item.visible)
      // 最终显示数据
      this.visibleData = this.allVisibleData.slice(this.start, this.end)
      // ul滚动的绝对定位滚动值
      this.offset = this.start * this.option.itemHeight
    },

上面的代码是什么意思呢?

先理解第一幅图,

image.png 上图中,我们为什么需要展位容器呢?占位容器帮助我们撑开了盒子,我们就可以开始滚动了。

再理解第二幅图

image.png

当发生滚动时,

image.png

  1. 这幅图里面有ul,这个是数据的容器,用于展示数据,我们后续通过控制transform: translateY(10px);(比直接设置top的情况要好,transform不会导致重排),注意,ul容器的移动的距离和placeholder占位容器的滚动一致时,数据才能被看到!!!
  2. 你还能够注意到,为了避免白屏的情况出现,我们还会在首尾处多添加一些值,结尾索引的this.end = this.start + this.option.count * 2,开始索引的Math.floor(scrollValue / this.option.itemHeight) - Math.floor(this.option.count / 2)

3.5 设置样式

如何实现,树样式的递归效果呢?

image.png 如下,设置动态stylepaddingLeft,值是某个数字 * (level - 1)

    <ul class="sb-tree"
     :style="{transform: `translateY(${offset}px)`}"
    >
      <li 
        v-for="(item) in visibleData"
        :key="item.id + item.path + item.uuid"
      >
        <span
          v-if="item.ip"
          @click.prevent.stop="handleNodeClick(item)"
          class="tree-item"
          :style="{
            display: 'block',
            background: (item.path + item.uuid) === ($store.state.selectCatalog.path + $store.state.selectCatalog.id) ? '#F1F4FF' : 'none',
            color: (item.path + item.uuid) === ($store.state.selectCatalog.path + $store.state.selectCatalog.id) ? '#3C7EFF' : '#2C3E50',
+            paddingLeft: 12.5 * (item.level - 1) + 'px',
          }"
        >

3.8 handleNodeClick

点击某个节点的时候,我们要做什么操作呢?

  1. 假设这个节点有子节点,此时处于折叠状态,我们点击后就要展开子节点;假设此时处于展开状态,点击就要折叠
handleNodeClick(data, type) {
    this.$set(data, "showChildren", !data.showChildren);
    this.recursionVisible(data, data.showChildren)
}
  1. recursionVisible函数,来控制子节点的展开和关闭
recursionVisible (data, status) {
    if (status) {
        data.children.forEach(node => {
          // 只展开第一层 ==》通过visible属性来控制显示和隐藏
          node.visible = status
          // 如果下面解开注释,就是把所有的children都展开了,应该只操作一层
          // if (node.children) {
          //   this.recursionVisible(node.children, status)
          // }
        })
    } else {
        // 关闭,应该把所有子节点都关闭
        data.children.forEach(node => {
          node.visible = status
          node.showChildren = status // 展开节点都给它收起来
          if (node.children) {
            this.recursionVisible(node, status)
          }
        })
    }

}
  1. 记录当前点击的节点的path或者id
handleNodeClick(data, type) {
    this.$set(data, "showChildren", !data.showChildren);
    this.recursionVisible(data, data.showChildren)
    this.selectedNode = data.id;
    this.selectedNode = data.uuid;
    this.handleScroll() // 触发一次滚动事件更新视图
}

3.7 防抖还是节流?

建议用防抖,为什么?

因为节流是规定时间只能执行一次,防抖是规定时间你又执行了会重新开始计时。如果是节流,那你handleScroll事件如果连续触发了好多次了呢?top的值改变了,但是数据的start和end没有更新,那么页面就看不到数据了。

handleScroll () {
  if (this.scrollTimer) {
    clearTimeout(this.scrollTimer)
  }
  this.scrollTimer = setTimeout(() => {
    if (this.$refs.viewport) {
      if (isSearch === true) this.top = 0
      else this.top = this.$refs.viewport.scrollTop // 滚动的距离
      this.updateVisibleData(this.top)
    }
  }, this.option.timeout) // this.option.timeout假设是30
},

这里有一个点,防抖,时间设置为多少比较合适。我个人测试过,如果你在滚动的时候,内存没什么变化,页面效果也还不错,可以设置的短一点好一些。我是出现了内存涨的很快,设置为30依然涨的很快。设置为100,则会出现白屏情况,至于原因,就是scroll触发dom移动了,最新的数据还没更新好。

3.8 多选按钮如何添加

模仿element-ui里面的tree组件的多选,在li标签里面添加checkbox。

实现点击勾选成功,在input的change事件里面做勾选操作;注意在最外层的label里面标上@click.stop,否则会把节点展开来,

<li 
    v-for="item in visibleData"
    :key="item.path + item.name"
    @click="handleNodeClick(item)"
    :style="{paddingLeft: `${item.level * 5}px`}"
  > 
+    <label 
      class="checkbox" 
      v-if="option.showCheckbox" 
      @click.stop
     >
      <span class="checkbox__input"
      :class="
        [
          item.isChecked ? 'is-checked' : '', 
          item.isHalfChecked ? 'is-indeterminate' : '',
        ]
      "
      :for="item.path + item.name"
      >
        <span class="checkbox__inner"></span>
        <input 
          class="checkbox__original" 
          type="checkbox"
          :checked="item.isChecked" 
          :indeterminate="item.isHalfChecked"
          @change.stop="handleCheckChange(item)"
          :id="item.path + item.name"
        >
      </span>
+    </label>
    <i v-if="item.showChildren" class="el-icon-caret-bottom"></i>
    <i v-else class="el-icon-caret-right"></i>
    <span>{{ item.name }}</span>
  </li>

待添加的样式

.checkbox__original {
   width: 0;
   height: 0;
}
.checkbox__inner {
    display: inline-block;
    box-sizing: border-box;
    position: relative;
    border-radius: 2px;
    width: 14px;
    height: 14px;
    border: 1px solid #dcdfe6;
}
.checkbox__input.is-checked {
    .checkbox__inner {
      background-color: #409eff;
      border-color: #409eff;
      &::after {
        content: '';
        position: absolute;
        box-sizing: content-box;
        top: 1px;
        left: 4px;
        width: 3px;
        height: 7px;
        font-size: 12px;
        color: #fff;
        border: 1px solid #fff;
        border-left: 0;
        border-top: 0;
        transition: transform .15s ease-in .05s;
        transform-origin: center;
        transform: rotate(44deg) scaleY(1);
      }
    }
  }
  .checkbox__input.is-indeterminate {
    .checkbox__inner {
      background-color: #409eff;
      border-color: #409eff;
      &::after {
        content: '';
        position: absolute;
        box-sizing: content-box;
        left: 0;
        right: 0;
        top: 5px;
        height: 2px;
        transform: scale(.5);
        background-color: #fff;
      }
    }
}

1732696174375.gif

注意!handleCheckChange方法需要跟input的change事件绑定,如果写在label上面,可能会导致勾选情况和点击情况没对应上。(尤其注意这个,刚开始我就是下面那样写的,直到去看element-ui里面的checkbox组件,发现他是这样封装的)

  <label 
  class="checkbox" 
  v-if="showCheckbox" 
+@click.prevent.stop="handleCheckChange(item)"
  >
    <span class="checkbox__input"
    :class="
      [item.isChecked ? 'is-checked' : '', item.isHalfChecked ? 'is-halfChecked' : '']
    "
    >
      <span class="checkbox__inner"></span>
      <input 
      class="checkbox__original" 
      type="checkbox"
      :checked="item.isChecked" 
      :indeterminate="item.isHalfChecked"
      >
    </span>
</label>

拍平数组里面做的操作

flatten (
  list = [],
  childKey = 'children',
  level = 1,
  parent = null,
  defaultExpand = true,
  isSearch
    ) {
    if (!Array.isArray(list)) {
      return
    }
    let arr = []
      // 无ipc
    list.forEach((item) => {
      this.$set(item, 'level', level)
      if (item.showChildren === undefined) {
        item.showChildren === defaultExpand
        this.$set(item, 'showChildren', defaultExpand)
      }
      if (item.visible === undefined && level > 2) {
        this.$set(item, 'visible', false)
      } else if (item.visible === undefined) {
        this.$set(item, 'visible', true)
      }
      this.$set(item, 'parent', parent)
+      // 设置勾选框 增加isChecked属性和isHalfChecked属性,
      if (this.option.showCheckbox) {
        this.$set(item, 'isChecked', false)
        this.$set(item, 'isHalfChecked', false)
      }
      // 当前目录
      arr.push(item)
      // 当前目录下的子目录
      if (item[childKey] && item[childKey].length > 0) {
        arr.push(...this.flatten(
          item[childKey],
          childKey,
          level + 1,
          item,
          defaultExpand,
          isSearch
        ))
      }
    })
    return arr
},

处理节点的勾选

    handleCheckChange (node) {
      try {
        // 如果是半选,修改为全选
        if (node.isHalfChecked) {
          this.$set(node, 'isChecked', true)
        } else if (node.isChecked) {
          // 如果是全选,修改为非全选
          this.$set(node, 'isChecked', false)
        } else {
          // 如果是非全选,修改为全选
          this.$set(node, 'isChecked', true)
        }
        // 只会有孩子选中时(handleCheckChildren)触发isHalfChecked的修改,其他都改为false
        this.$set(node, 'isHalfChecked', false)
        // 处理子目录和子ipc
        this.handleCheckChildren(node)
        // 处理父目录
        this.handleCheckParent(node.parent)
      } catch (error) {
        console.log(error, 'error');
      }
    },

勾选当前节点影响子节点:

  1. 父节点点击后只会改为全选或者全不选,子节点也是
  2. 子节点半选统一改为false
  3. 递归遍历当前函数
handleCheckChildren (node) {
  node.children && node.children.forEach(child => {
    // 点击当前目录,修改子目录,只会影响子目录的全选或者全不选,半选统一为false
    this.$set(child, 'isChecked', node.isChecked)
    this.$set(child, 'isHalfChecked', false)
    if (child.children) {
      // 递归遍历子目录
      this.handleCheckChildren(child)
    }
  })
},

点击当前节点影响父节点,逻辑:

  1. 如果子节点全部勾选,父节点就是勾选
  2. 如果子节点有部分勾选,父节点就是半选状态
  3. 如果子节点全部没选中,父节点就是没有勾选的状态
  4. 递归影响父亲的父亲
handleCheckParent (node) {
      // 初始值,假设所有子节点是checked,在后面的遍历中修改这个值
      let isAll = true 
      // 初始值,假设没有子节点是半选,在后面的遍历中修改这个值
      let isHalf = false 
      // 无name属性的目录不递归
      if (!node || !node.name) {
        return
      }
      // 根据所有的子目录判断是否全选
      if (node && node.children && node.children.length > 0) {
        // 如果子目录没有全部勾选,item.isChecked是false
        isAll = node.children.every(item => item.isChecked)
        // 如果子目录有一个是全选或者半选状态
        isHalf = node.children.some(item => item.isHalfChecked || item.isChecked)
        // 修改父目录选中
        node.isChecked = isAll
        // 全选和半选不能同时存在
        node.isHalfChecked = !isAll && isHalf 
      }
      // 递归影响父节点
      this.handleCheckParent(node.parent)
},