el-tree组件展示节点过多时造成页面卡顿、奔溃的解决办法

博客讲述了如何通过引入virtual-scroll-list插件并结合el-tree源码,创建一个新的虚拟滚动树组件virtualNodeTree,以解决在Vue+Element-UI项目中,当el-tree渲染大量文件(如5w个)时导致的页面卡顿和崩溃问题。组件利用虚拟滚动技术,仅渲染可视区域内的节点,显著提升了页面性能。此外,还提供了组件的使用示例和注意事项,包括搜索限制和父容器高度设定等。
摘要由CSDN通过智能技术生成

解决el-tree组件展示节点过多时造成页面卡顿、奔溃

前几天测试提了个BUG,文件列表展示5w个文件页面会卡顿甚至奔溃。
项目用的是vue+element-ui框架,我是使用el-tree进行渲染文件列表的。
参考网上使用virtual-scroll-list插件与el-tree源码写成一个新组件。virtual-scroll-list可以只渲染页面呈现部分的节点,这样就不会造成卡顿了,源el-tree是直接将5w个节点直接渲染到页面,导致页面奔溃。
这是使用virtual-scroll-list插件与el-tree源码结合后的组件:github组件下载gitee组件下载
组件使用方法(传入的属性)与el-tree一致,可根据自己的业务需求更改,我做的需求只是进行文件导出。
组件使用示例:

<virtualNodeTree
      ref="dirTree"
      :data.sync="treeData"
      :load="loadDir"
      :keeps="50"
      :check-strictly="false"
      :props="{
        isLeaf: 'leaf'
      }"
      lazy
      show-checkbox
      node-key="path"
      class="treeWrap"
      @check-change="handleCheckChange">
      <span slot-scope="{ data }">
        <svg-icon v-if="data.ftype === '1'" style="color: #fdd300;" icon-class="faFolder"/>
        <span v-else>
          <svg-icon :icon-class="fileInputHandle(data).icon" :style="{color: fileInputHandle(data).color}"/>
        </span>
        <span>{{ data.fname }}</span>
      </span>
    </virtualNodeTree>

组件引入:
在这里插入图片描述

效果图:
请添加图片描述

more文件下面有5w个文件,实际页面渲染50个文件,根据组件传入的keeps展示文件数,默认30个;
注意:
1.搜索只能搜到已渲染的节点,可以自己做递归搜索源数据,不过这样的话数据一多会很卡,建议后端写个搜索api
2.该组件的父容器一定要确定高度,不能以整个body作为父容器,这样有可能滚动时渲染不出下面的文件。

补充:
我使用的完整代码

<template>
  <div class="app-container">
    <div>
      <el-button
        class="ame-button"
        size="mini"
        type="primary"
        @click="exportHandle"
        :loading="exportLoading"
        :disabled="exportLoading"
      >导出
      </el-button>
    </div>
    <virtualNodeTree
      v-loading="loading"
      ref="dirTree"
      :data.sync="treeData"
      :load="loadDir"
      :keeps="50"
      :check-strictly="false"
      :props="{
        isLeaf: 'leaf'
      }"
      lazy
      show-checkbox
      node-key="path"
      class="treeWrap"
      @check="handleNodeCheck"
    >
      <span slot-scope="{ data }">
        <svg-icon v-if="data.ftype === '1'" style="color: #fdd300;" icon-class="faFolder"/>
        <span v-else>
          <svg-icon :icon-class="fileInputHandle(data).icon" :style="{color: fileInputHandle(data).color}"/>
        </span>
        <span>{{ data.fname }}</span>
      </span>
    </virtualNodeTree>
    <!--
    <el-tree
      ref="dirTree"
      :data.sync="treeData"
      :load="loadDir"
      :check-strictly="false"
      :props="{
        isLeaf: 'leaf'
      }"
      lazy
      show-checkbox
      node-key="path"
      class="treeWrap"
      @check-change="handleCheckChange"
    >
      <span slot-scope="{ data }">
        <svg-icon v-if="data.ftype === '1'" style="color: #fdd300;" icon-class="faFolder"/>
        <span v-else>
          <svg-icon :icon-class="fileInputHandle(data).icon" :style="{color: fileInputHandle(data).color}"/>
        </span>
        <span>{{ data.fname }}</span>
      </span>
    </el-tree>
    -->
  </div>
</template>

<script>
import { slotFileList, slotFileExport } from '@/api/disc.js'
import { downloadFile } from '@/utils/index'
import getFileIcon from '@/utils/getFileIcon'
import { getFilesNumFromFolder } from '@/api/mtoptical'
import { Message } from 'element-ui'
import virtualNodeTree from '@/components/virtualNodeTree/tree'

export default {
  name: 'DiscFileDetail',
  components: { virtualNodeTree },
  data() {
    return {
      treeData: [],
      dirInfos: [],
      lock: false,
      loading:false,
      exportLoading:false,
      exportMaxNum: 10000 // 允许导出文件的最大数量
    }
  },
  mounted() {},
  methods: {
    async handleNodeCheck(data, selctedInfo) {
      const checked = selctedInfo.checkedKeys.includes(data.path)
      if (data.ftype == '1' && checked) {
        if (typeof data.allFileNum === 'number') {
          // 已获悉该文件夹数量的不再查询
          this.exportUtils('fileNumChange', { dirInfos: this.dirInfos, data, fileNum: parseInt(data.allFileNum), checked })
          return
        }
        const params = {
          nodeId: this.$route.query.nodeId || '',
          libId: this.$route.query.libId || '',
          grooveId: this.$route.query.grooveId || '',
          path: data.path || '/'
        }
        if (this.$route.query.rfid) params.rfid = this.$route.query.rfid
        const res = await this.getFilesNumFromFolder(params)
        data.allFileNum = res
        this.exportUtils('fileNumChange', { dirInfos: this.dirInfos, data: data || '/', fileNum: parseInt(res), checked })
        // if (res > this.exportMaxNum) {
        //   this.$message.error(`导出文件数量不能超过${this.exportMaxNum}`)
        //   // 取消勾选
        //   this.$refs.dirTree.setChecked(data, false, true)
        // } else {
        //   data.allFileNum = res
        //   this.exportUtils('fileNumChange', { dirInfos: this.dirInfos, data: data || '/', fileNum: parseInt(res), checked })
        //   console.log('添加数量完成', this.dirInfos[0]);
        // }
      } else if (data.ftype != '1' && checked) {
        // 勾选文件
        this.exportUtils('fileNumChange', { dirInfos: this.dirInfos, data, fileNum: null, checked })
      } else if (data.ftype != '1') {
        // 取消勾选文件
        this.exportUtils('fileNumChange', { dirInfos: this.dirInfos, data, fileNum: null, checked })
      } else {
        // 取消勾选文件夹
        this.exportUtils('fileNumChange', { dirInfos: this.dirInfos, data, fileNum: null, checked })
      }
    },
    getFilesNumFromFolder(params) {
      return new Promise((resolve, reject) => {
        const loaderTip = this.$loading({
          lock: true,
          text: '请稍等......',
          spinner: 'el-icon-loading',
          background: 'rgba(0, 0, 0, 0.7)'
        })
        getFilesNumFromFolder(params).then(res => {
          // console.log('获取到的文件数量', res);
          loaderTip.close()
          resolve(typeof res === 'number' ? parseInt(res) : 0)
        }).catch(() => {
          loaderTip.close()
          reject(0)
        })
      })
    },
    fileInputHandle(file) {
      const nameSplit = file.fname.split('.')
      let iconInfo = null
      if (nameSplit.length > 1) {
        iconInfo = getFileIcon(nameSplit[nameSplit.length - 1])
      } else {
        iconInfo = getFileIcon('其他')
      }
      return iconInfo
    },
    async exportUtils(fn, params) {
      return new Promise(async(resolve, reject) => {
        let fileTotal, dataPath
        switch (fn) {
          // 添加文件夹信息到文件夹信息集合内
          case 'addDirInfo':
            for (let i = 0; i < params.dirInfos.length; i++) {
              if (params.node.data.path === params.dirInfos[i].path) {
                params.dirInfos[i].children = params.addInfo
                resolve('finished')
              }
              if (params.dirInfos[i].children) {
                const status = this.exportUtils('addDirInfo', { ...params, dirInfos: params.dirInfos[i].children })
                if (status === 'finished') resolve()
              }
            }
            break
          // 添加文件夹数量到文件夹信息集合内
          case 'fileNumChange':
            // 勾选的是文件比对时需要删除path后面的文件名后再比对
            if (params.data.ftype != '1') {
              const tmp = params.data.path.split('/')
              dataPath = tmp.slice(0, tmp.length - 1).join('/')
            } else {
              dataPath = params.data.path || '/'
            }
            for (let i = 0; i < params.dirInfos.length; i++) {
              // 找到该文件夹信息
              if (dataPath === params.dirInfos[i].path) {
                // console.log('已找到该信息', params);
                if (params.checked) {
                  const curDirSelFileNum = params.data.ftype == '1' ? (typeof params.dirInfos[i].curSelFileNum === 'number' ? params.dirInfos[i].curSelFileNum : 0) : 0
                  const curAllCheckedFileNum = await this.exportUtils('getFileTotal') + (params.fileNum || 1) - curDirSelFileNum
                  // 添加前检查是否超出最大导出数量
                  if (curAllCheckedFileNum > this.exportMaxNum) {
                    this.$message.error(`导出文件数量不能超过${this.exportMaxNum}`)
                    // 取消勾选
                    this.$refs.dirTree.setChecked(params.data, false, true)
                    if (params.data.ftype == '1') {
                      params.dirInfos[i].curSelFileNum = 0
                    }
                    resolve(params.data.ftype == '1' ? -curDirSelFileNum : 0)
                    return
                  }
                  // console.log('当前勾选文件数', curAllCheckedFileNum);
                  // 勾选的是文件夹
                  if (params.data.ftype == '1') {
                    params.dirInfos[i].allFileNum = params.fileNum
                    params.dirInfos[i].curSelFileNum = params.fileNum
                    this.exportUtils('setAllChildrenChecked', { children: params.dirInfos[i].children })
                    resolve(params.fileNum - curDirSelFileNum)
                  } else {
                    if (params.dirInfos[i].curSelFileNum === 'unknown') {
                      params.dirInfos[i].curSelFileNum = 1
                    } else {
                      params.dirInfos[i].curSelFileNum += 1
                    }
                    resolve(1)
                  }
                } else {
                  // 取消勾选
                  if (params.data.ftype == '1') {
                    params.dirInfos[i].curSelFileNum = 0
                    if (params.dirInfos[i].allFileNum === 'unknown') {
                      const queryParams = {
                        nodeId: this.$route.query.nodeId || '',
                        libId: this.$route.query.libId || '',
                        grooveId: this.$route.query.grooveId || '',
                        path: params.data.path || '/'
                      }
                      resolve(await this.getFilesNumFromFolder(queryParams))
                    }
                    resolve(params.dirInfos[i].allFileNum)
                  } else {
                    if (params.dirInfos[i].curSelFileNum !== 'unknown') {
                      params.dirInfos[i].curSelFileNum -= 1
                    }
                    resolve(1)
                  }
                }
              }
              if (params.dirInfos[i].children && params.dirInfos[i].children.length !== 0) {
                const num = await this.exportUtils('fileNumChange', { ...params, dirInfos: params.dirInfos[i].children })
                if (typeof num === 'number') {
                  if (params.dirInfos[i].curSelFileNum === 'unknown') {
                    params.dirInfos[i].curSelFileNum = num
                  } else {
                    params.dirInfos[i].curSelFileNum += params.checked ? num : -num
                  }
                  resolve(num)
                }
              }
              if (i === params.dirInfos.length - 1) resolve('continue')
            }
            break
          // 获取已勾选的文件总数
          case 'getFileTotal':
            fileTotal = this.dirInfos.reduce((total, item) => {
              const tmp = item.curSelFileNum === 'unknown' ? 0 : item.curSelFileNum
              return total + tmp
            }, 0)
            resolve(fileTotal)
            break
          // 当父文件夹勾选后,将所有已知文件总数量的子文件夹的curSelFileNum设置为allSelFileNum
          case 'setAllChildrenChecked':
            if (Array.isArray(params.children) && params.children.length !== 0) {
              for (const i in params.children) {
                const allFileNum = params.children[i].allFileNum
                params.children[i].curSelFileNum = allFileNum === 'unknown' ? 'unknown' : allFileNum
                if (params.children[i].children && params.children[i].children.length !== 0) {
                  this.exportUtils('setAllChildrenChecked', params.children[i].children)
                }
              }
            }
            break
        }
      })
    },
    loadDir(node, resolve) {
      const temp = {
        nodeId: this.$route.query.nodeId || '',
        libId: this.$route.query.libId || '',
        oid: this.$route.query.oid || '',
        path: node.data.path || '/'
      }
      if (this.$route.query.src === 'warehouseTask' &&
        this.$route.query.rfid
      ) {
        temp.rfid = this.$route.query.rfid
      } else {
        temp.grooveId = this.$route.query.grooveId || ''
      }
      this.loading = true;
      slotFileList(temp).then(res => {
        this.loading = false;
        if (res && res instanceof Array) {
          const addInfo = []
          for (const i in res) {
            if (res[i].ftype == '1') {
              addInfo.push({
                path: (node.data.path || '') + '/' + res[i].fname,
                level: node.level + 1,
                allFileNum: 'unknown',
                curSelFileNum: 'unknown',
                children: null
              })
            }
          }
          if (this.dirInfos.length === 0) {
            this.dirInfos.push({
              path: node.data.path || '/',
              allFileNum: 'unknown',
              curSelFileNum: 'unknown',
              level: node.level,
              children: addInfo
            })
          } else {
            this.exportUtils('addDirInfo', { dirInfos: this.dirInfos, node, addInfo })
          }
          const list = res.map(item => {
            return {
              ...item,
              path: (node.data.path || '') + '/' + item.fname,
              leaf: item.ftype != '1'
              // disabled: item.ftype == '1'
            }
          })
          resolve(list)
        } else {
          resolve([])
        }
      }).catch(() => {
        this.loading = false;
        resolve([])
      })
    },
    exportHandle() {
      const checkeNodes = this.$refs.dirTree.getCheckedNodes()
      if (checkeNodes.length < 1) {
        this.$message.error('请选择需要导出的数据')
        return
      }
      this.exportLoading = true;
      setTimeout(() => {
        try {
          // const paths = checkeNodes.map(item => {
          //   return { fileName: item.path, fileType: item.ftype === '1' ? '1' : '2' }
          // })
          let paths = checkeNodes.map(item => {
            return { pathArr: item.path.split('/'), fileType: item.ftype === '1' ? '1' : '2' }
          })
          // 如果文件夹与子文件件都勾选了,只保留顶级文件夹
          for (let i = 0; i < paths.length; i++) {
            if (!paths[i]) continue
            out: for (let j = i + 1; j < paths.length; j++) {
              if (!paths[j]) continue out
              for (let k = 0; k < paths[i].pathArr.length; k++) {
                if (paths[i].pathArr[k] !== paths[j].pathArr[k]) {
                  continue out
                }
              }
              paths[j] = null
            }
          }
          // 过滤掉null的元素
          paths = paths.filter(item => item)
          // const paths = checkeNodes.map(item => item.path)
          const temp = {
            nodeId: this.$route.query.nodeId || '',
            libId: this.$route.query.libId || '',
            grooveId: this.$route.query.grooveId || '',
            oid: this.$route.query.oid || '',
            exportFiles: paths.map(item => {
              return { fileName: item.pathArr.join('/'), fileType: item.fileType === '1' ? '1' : '2' }
            })
          }
          if (this.$route.query.rfid) temp.rfid = this.$route.query.rfid
          // console.log('提交参数', temp);
          // return
          slotFileExport(temp).then(res => {
            downloadFile(res, '导出', 'xlsx')
            this.exportLoading = false;
          }).catch(error => {
            const fileReader = new FileReader()
            fileReader.onload = function(e) {
              Message.error(this.result)
            }
            fileReader.readAsText(error.response.data)
            this.exportLoading = false;
          })
        } catch (e) {
          this.$message.error(e)
          this.exportLoading = false;
        }
      }, 20)
    }
  }
}
</script>

<style lang="scss" scoped>
  .el-tree {
    height: calc(100vh - 136px - 90px);
  }

  .app-container {
    background: #fff;
    padding: 20px;
    margin: 0px 10px 0 10px;
    /* height: 800px; */
    overflow-y: auto;
    border-radius: 5px;
  }

  .treeWrap {
    margin-top: 20px;
    border-radius: 4px;
    border: 1px solid #9e9e9e;
  }
</style>

  • 6
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 26
    评论
评论 26
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值