vue中下拉树树形结构的虚拟列表优化

  • 优化下拉树产生的场景
  • 优化下拉树的具体实现
  • 改变数据源,实现dom元素的减少
  • 下拉树列表数据做虚拟列表实现
  • 封装的下拉树组件代码


优化下拉树产生的场景

最近在跟一个中烟的项目,我们单位是中烟的承接单位,碰到了一个树形结构的下拉框,卡顿比较严重,还有树形结构的穿梭框也是,这个树形结构是后端一次性给前端了,经过ant-design-vue中的树形组件渲染后,页面的dom元素会有很多,每一个dom元素上产生的交互事件就会卡顿,如下图所示

vue 列表虚拟化 vue虚拟列表优化_javascript

优化下拉树的具体实现

改变数据源,实现dom元素的减少

一开始我的想法就是改变dom渲染的数量,但是因为它是下拉框,我现在的实力做不到用户在使用的时候很平顺,可以滑动

下拉树列表数据做虚拟列表实现

之后就想到了用虚拟列表去做,封装成一个组件,为什么要封装成组件呢?因为ant-design-vue的form表单内部就维护了一个form表单,得使用它的v-decorator来做数据绑定,不然会很卡。

解决方案:自己做一个树形结构的下拉多选框,其中下拉框中的数据进行虚拟列表处理,input框要实现多选的标签化。所以下拉树中绑定的数据要和input框中的数据实时同步,我们这边的产品还要在标签数量超过15个的时候,展示剩余多少项。

针对input框:

// 这里的话大家可以用input的一个vue插件
// http://www.vue-tags-input.com/#/start
npm install @johmun/vue-tags-input
import VueTagsInput from '@johmun/vue-tags-input';

vue 列表虚拟化 vue虚拟列表优化_数据_02


input框的话可以用2个input来进行切换,使用v-show减少开销,不必重新生成dom,具体的一些操作大家可以根据需求看文档来实现,我之后会把封装的组件代码都放上来。

针对下拉框的虚拟列表:
针对虚拟列表的插件有很多,vue本身也提供了虚拟列表的实现,我一般常用的就是vue-virtual-scroll-list.
针对树形结构github上也有做了封装的,名字叫ctree,链接:https://github.com/wsfe/vue-tree/tree/2.x

npm install @wsfe/ctree
import {CTreeSearch} from "../../node_modules/@wsfe/ctree"
Vue.component('CTreeSearch', CTreeSearch)
@import '~@wsfe/ctree/dist/ctree.css';

最终的实现效果:

vue 列表虚拟化 vue虚拟列表优化_vue 列表虚拟化_03


页面的虚拟dom,我这边是始终渲染200个dom元素,可以看到右边的dom元素改变

vue 列表虚拟化 vue虚拟列表优化_vue 列表虚拟化_04

封装的下拉树组件代码

<template>
<div class="meetingTree" v-click-outside="onClickOutside">
  <div @mouseenter="tagsinputMouseEnter" @mouseleave="tagsinputMouseLeave">
    <vue-tags-input
      v-show="isTag"
      :tags="enjoyTags"
      v-model="enjoyTagsInput"
      placeholder="请选择参会人员"
      ref="vuetagsinput"
      @before-deleting-tag="deleteTagHandle"
      @focus="enjoyMeetingOpenHandle"
      @input="vuetagsInputHandle"
      :add-only-from-autocomplete="true"
    />
    <vue-tags-input
      v-show="!isTag"
      :tags="enjoyTagsCopy"
      v-model="enjoyTagsCopyInput"
      @before-deleting-tag="deleteTagHandle"
      placeholder="请选择参会人员"
      @focus="enjoyMeetingOpenHandle"
      @input="vuetagsInputHandle"
      :add-only-from-autocomplete="true"
      ref="vuetagsinputcopy"
    />
    <a-icon v-show="tagsinputFlag" class="close-icon" type="close-circle" @click="clearTagsHandle"/>
  </div>
  <div class="ctree-search">
    <div :style="{ ...treeStyle }" v-show="ctreeSearchFlag">
      <CTree-Search
        :data="personSelectData"
        :showCheckAll="false"
        v-model="conferee_uuids"
        :showCheckedButton="false"
        :renderNodeAmount="200"
        :bufferNodeAmount="200"
        default-expand-all
        checkable
        searchPlaceholder="请选择参会人员"
        @check="ctreeCheck"
        @uncheck="ctreeUncheck"
        @checked-change="checkedChange"
        ref="ctreeSearch"
      >
      <template slot="footer">
        <div class="footer-box">
          已选{{this.enjoyTags.length}}项
        </div>
      </template>
      </CTree-Search>
    </div>
  </div>
</div>
</template>
<script>
import VueTagsInput from '@johmun/vue-tags-input';
export default {
  name: 'LjMeetingSelect',
  props:{
    personSelectData:{
      type:Array,
      default:[],
    },
    originTreeData:{
      type:Array,
      default:[],
    },
    // 展开和隐藏,emit父组件改变父组件的isTag
    isTag:{
      type:Boolean,
      default:true,
    },
    addGroupData:{
      type:Array,
      default:function(){
        return [];
      }
    }
  },
  data() {
    return {
      conferee_uuids:[],
      enjoyTags:[], // 参会人员的tags,数量变多后,隐藏起来
      enjoyTagsCopy:[], // 参会人员tags的副本,用于展示所有的tags
      tagsParents:[], // 删除tags时父类的id
      tagsinputFlag:false,
      enjoyTagsInput:"",
      enjoyTagsCopyInput:"",
      firstFlag:true,
      ctreeSearchFlag:false,
    }
  },
  components:{VueTagsInput},
  computed: {
    treeStyle() {
      const { treeHeight = 300, localScroll: overflow } = this
      let height = treeHeight + 'px'
      return {
        height,
        overflow,
      }
    },
  },
  watch:{
    enjoyTags(newval,oldval){
      let deppArrayCopy=JSON.parse(JSON.stringify(this.enjoyTags));
      this.enjoyTagsCopy=deppArrayCopy // 这两组数据在前15个时始终相等
      if(newval.length>15){
        if(this.firstFlag){
          this.firstIsTag()
        }
        this.isTagChange(true)
        deppArrayCopy.splice(15,this.enjoyTags.length-15)
        let data={
          text:"共计"+this.enjoyTags.length+"项"
        }
        this.enjoyTagsCopy.push(data)
      }else{
        this.isTagChange(false)
      }
    },
    // 群组选择的监听,需要往uuids和tags中添加数据,不能重复
    addGroupData(newval){
      // 选中对应的节点
      this.$refs.ctreeSearch.setCheckedKeys(newval[0],true)
      
    }
  },
  methods: {
    // 勾选的时候触发
    ctreeCheck(value){
      this.changeSearch()
      // this.deeploopCheck(value)
    },
    // 取消勾选的时候触发
    ctreeUncheck(value){
      this.changeSearch()
      // this.deeploopUncheck(value)
    },
    checkedChange(value){
      this.enjoyTags=[]
      value.map((value)=>{
        if(value.isLeaf){
        let data={
          text:value.title,
          id:value.id
        }
        this.enjoyTags.push(data)
      }
      })
    },
    // 递归找到所选择的节点信息并增加进enjoyTags
    deeploopCheck(value){
      if(value.isLeaf){
        let data={
          text:value.title,
          id:value.id
        }
        this.enjoyTags.push(data)
      }else{
        value.children.map((item)=>{
          this.deeploopCheck(item)
        })
      }
    },
    // 递归找到所有不选择的所有节点,在enjoyTags中删除
    deeploopUncheck(value){
      if(value.isLeaf){
        this.enjoyTags=this.enjoyTags.filter((item)=>{
          return item.id != value.id
        })
      }else{
        value.children.map((item)=>{
          this.deeploopUncheck(item)
        })
      }
    },
    // 递归找到父id
    loopParentId(id,array){
      array.map((item)=>{
        if(item.id===id){
          this.loopParentId(item.pId,array)
          this.tagsParents.push(id)
        }
      })
    },
    // 删除tags标签中的数据
    deleteTagHandle(value){
      if(value.tag.id){
        console.log("删除")
        this.tagsParents=[]
        // 目录层级超过2层的删不掉,因为层级数太多,过滤不了
        this.loopParentId(value.tag.id,this.originTreeData) // id 过滤出所有父类id
        // 对conferee_uuid中的值进行删除,并且对enjoysTags进行删除
        // 1.对conferee_uuid进行删除
        this.conferee_uuids=this.conferee_uuids.filter((item)=>{
          // return item != (deleteId && parentId)
          if(!this.tagsParents.includes(item)){
            return item
          }
        })
        // 2.对enjoysTags进行删除
        this.enjoyTags.splice(value.index,1)
      }else{
        console.log("未进入删除")
      }
      
    },
    // 子传父的调用方法
    toChangeTree(){
      let checkNodes=this.$refs.ctreeSearch.getCheckedNodes()
      let idvalue=[]
      let namevalue=[]
      checkNodes.map(item=>{
        idvalue.push(item.id)
        // 非叶子节点不放入
        if(item.isLeaf){
          namevalue.push(item.title)
        }
      })
      let data=[idvalue,namevalue]
      this.$emit("treeChange",data)
    },
    // 给父组件传递值
    // toSaveData(){
    //   this.$emit("uuidChange",this.conferee_uuids)
    // },
    isTagChange(value){
      this.$emit("isTagChange",value)
    },
    // 第一次超出15个tags
    firstIsTag(){
      this.firstFlag=false
      this.$emit("firstIsTag",true)
    },
    tagsinputMouseEnter(){
      if(this.conferee_uuids.length>0){
        this.tagsinputFlag=true
      }
    },

    tagsinputMouseLeave(){
      this.tagsinputFlag=false
    },
    // 清空数据
    clearTagsHandle(){
      this.enjoyTags=[]
      this.enjoyTagsCopy=[]
      this.$refs.ctreeSearch.clearChecked()
      // 同样需要调用父组件treechange方法改变表单数据
      this.toChangeTree()
    },
    enjoyMeetingOpenHandle(){
      this.ctreeSearchFlag=true
    },
    // 触发点击外部div
    onClickOutside (event) {
      this.ctreeSearchFlag=false
      // 在这里传值并且提交表单
      this.toChangeTree()
      // this.toSaveData()
    },
    // tagsinput框搜索触发
    vuetagsInputHandle(value){
      this.debounce(this.$refs.ctreeSearch.search(value),500)
    },
    //防抖
    debounce(fn, delay) {
      let timer = null; //借助闭包
      return function () {
        if (timer) {
          clearTimeout(timer);
        }
        timer = setTimeout(fn, delay); // 简化写法
      };
    },
    changeSearch(){
      this.$refs.vuetagsinput.newTag=""
      this.$refs.vuetagsinputcopy.newTag=""
      this.$refs.ctreeSearch.search()
    }
  },
}
</script>
<style scoped lang="less">
@import '~@wsfe/ctree/dist/ctree.css';
.meetingTree{
  position:relative;
  .ctree-search{
    position: absolute;
    z-index: 1;
    background: #ffffff;
    width: 100%;
  }
  .close-icon{
    position: absolute;
    right: 5px;
    top: 5px;
    cursor:pointer;
  }
}
/deep/.ctree-tree-search__search{
  display:none !important;
}
/deep/.ctree-tree__block-area {
  margin-top: 10px !important;
}
/deep/.ctree-tree-node__checkbox {
  position: relative !important;
}
/deep/.vue-tags-input{
  max-width:none !important;
}
/deep/.ti-tag{
  background-color:#fafafa !important;
  color:#000000 !important;
  font-size:14px !important;
  border: 1px solid #e8e8e8 !important;
}
</style>

大家有需要的可以直接复制粘贴这个组件,页面引入即可。
很长时间不更新了,之后会慢慢继续分享所学,因为之前出于个人规划脱产了一段时间。
之后会更新一下C语言的算法,再之后会发布一些node和vue3碰到的问题和解决方案