虚拟列表优化:el-transfer穿梭器,在数据量大的情况下加载卡顿,全选卡顿的问题

1,495 阅读5分钟
本文参考主要参考
  1. (Transfer)解决:Element-ui 中 Transfer 穿梭框因数据量过大而渲染卡顿问题的三种方法
  2. Element-UI的transfer穿梭框组件数据量大解决方案
  3. (对element-plus的组件二次修改-自定义组件 - 掘金 (juejin.cn))
前言:

  产品需求中需要用到穿梭器,并且需要一次性将数据添加至穿梭器,在接口返回几百条时就已经明显慢了,返回上千条时页面加载都要等好几秒了。
  经过网上文章检索,花时间把element-plus中el-transfer源码大致看了一下,本来是准备参考文章1进行虚拟列表优化,但是在查看虚拟化树形控件(tree-v2)时发现自带的虚拟化列表virtual-list中的FixedSizeList ,并且对部分源码transfer用通义灵码(个人感觉非常好用)进行优化。

image.png

附源码:点击前往
文章使用示例:点击前往

一、话不多说,看实际应用效果

1.官方渲染1W条数据使用时间28秒左右,期间整体页面卡顿不可操作,毕竟1W个dom渲染还是挺耗时的,等到浏览器反应过来了才可以滚动查看数据

image.png

2.优化后虚拟列表渲染时间,120毫秒

image.png

3.这里用到的页面加载计时函数
// 在页面加载完成后调用该函数,可以获取到页面渲染时间
function getPageLoadTime() {
  var startTime = performance.now();
  window.addEventListener('load', function() {
    var endTime = performance.now();
    var loadTime = endTime - startTime;
    console.log('页面渲染时间为:' + loadTime + '毫秒');
  });
}
4.实际效果对比-使用官方组件:

官方组件1w操作.gif

5.实际效果对比-使用虚拟化组件:

虚拟组件1w操作.gif

小结:

整体来说比官方组件流畅,但是需要注意默认组件增加itemSizeheight,用于虚拟列表计算高度,itemSize需要根据实际稍微增加部分来,这里使用的与列表相同的高度itemSize=30,最后几条数据没有渲染出来

image.png

将itemSize设置为40后正常

image.png

这里最后一条的样式是el-checkbox的默认样式,需要手动deep添加覆盖

:deep(.el-checkbox:last-of-type){
  margin-right: 30px;
}

二、部分代码优化(使用通义灵码)

先贴一张transfer源码目录结构图,通过代码优化多数都是优化includes的校验,使用Set()

image.png

1. 优化use-check
1.1 全选updateAllChecked()
  const updateAllChecked = () => {
    // 检查 checkableData.value 是否为空
    if (checkableData.value.length === 0) {
      panelState.allChecked = false;
      return;
    }
  
    // 提取数据键值,假设 propsAlias.value.key 是 'id' 或类似的唯一标识符
    const dataKeys = checkableData.value.map(
      (dataItem) => dataItem[propsAlias.value.key] 
    );
  
    // 将 panelState.checked 转换为 Set 以提高查找性能
    const checkedSet = new Set(panelState.checked);
  
    // 更新 allChecked 的状态
    panelState.allChecked = dataKeys.length > 0 && dataKeys.every((dataKey) => checkedSet.has(dataKey));
  }
1.2 优化选中状态panelState.checked的watch
  watch(
    () => panelState.checked,
    (val, oldVal) => {
      updateAllChecked()
  
      if (panelState.checkChangeByUser) {
        // 使用 Set 来存储值,以提高查找效率
        const setVal = new Set(val);
        const setOldVal = new Set(oldVal);
  
        // 计算新增加和被移除的键
        const addedKeys = Array.from(setVal).filter(v => !setOldVal.has(v));
        const removedKeys = Array.from(setOldVal).filter(v => !setVal.has(v));
        const movedKeys = [...addedKeys, ...removedKeys];
  
        emit(CHECKED_CHANGE_EVENT, val, movedKeys);
      } else {
        emit(CHECKED_CHANGE_EVENT, val);
        // 在这里设置为true,表示下一次变化是由用户引起的
        panelState.checkChangeByUser = true;
      }
    }
  )
1.3 优化props.data的watch
  watch(
    () => props.data,
    () => {
      // 假设TransferKey为string类型
      const checked: TransferKey[] = []
      const filteredDataKeys = new Set(filteredData.value.map(
        (item) => item[propsAlias.value.key] as TransferKey // 确保类型正确
      ))
  
      panelState.checked.forEach((item) => {
        if (filteredDataKeys.has(item)) {
          checked.push(item)
        }
      })
  
      // 修改状态前的注释
      // 清除用户触发的检查变更标志,并更新已选中的项
      panelState.checkChangeByUser = false
      panelState.checked = checked
    }
  )
1.4 优化默认选中props.defaultChecked的watch
  watch(
    () => props.defaultChecked,
    (val, oldVal) => {
      // 保留原有的逻辑,即使 val 和 oldVal 完全相同也会更新 checked
      const checkableDataKeysSet = new Set(checkableData.value.map(
        (item) => item[propsAlias.value.key]
      ));
  
      // 使用 Set 的 has 方法来填充 checked 数组
      const checked: TransferKey[] = val.filter(item => checkableDataKeysSet.has(item));
  
      panelState.checkChangeByUser = false;
      panelState.checked = checked;
    },
    {
      immediate: true,
    }
  )
2.优化use-computed-data
2.1 优化sourceData() targetData() 主要是使用Set替换includes
  // 创建一个Set用于快速查找
  const modelValueSet = computed(() => new Set(props.modelValue));

  // 过滤函数
  const sourceData = computed(() => {
   //原代码
   // props.data.filter((item) => !props.modelValue.includes(item[propsAlias.value.key]))
    if (!Array.isArray(props.data)) return [];

    return props.data.filter((item) => {
      if (typeof item !== 'object' || item === null) return false;
      const key = propsAlias.value.key;
      if (key === undefined || typeof key !== 'string') return false;

      const value = item[key];
      return !modelValueSet.value.has(value);
    });
  });

  // 过滤函数
  const targetData = computed(() => {
    if (props.targetOrder === 'original') {
      // 原代码
      // return props.data.filter((item) => props.modelValue.includes(item[propsAlias.value.key]))
      return props.data.filter((item) => {
        const key = propsAlias.value.key;
        const value = item[key];
        return modelValueSet.value.has(value);
      });
    } else {
      return props.modelValue.reduce(
        (arr, cur) => {
          const val = props.dataObj.value[cur];
          if (val) {
            arr.push(val);
          }
          return arr;
        },
        []
      );
    }
  });
3.优化use-move的addToLeft(),addToRight()
3.1 addToLeft()
  const addToLeft = () => {
    // 获取当前值的副本以避免修改原始数据
    const currentValue = [...props.modelValue];

    // 创建一个临时数组用于存储要移除的元素
    const itemsToRemove:any = [];

    // 遍历 rightChecked 数组
    checkedState.rightChecked.forEach((item) => {
        const index = currentValue.indexOf(item);
        if (index > -1) {
            // 记录需要移除的元素
            itemsToRemove.push(item);
        }
    });

    // 使用 filter 方法来移除元素,这样更高效
    const filteredArray = currentValue.filter(item => !itemsToRemove.includes(item));

    // 更新值
    _emit(filteredArray, 'left', checkedState.rightChecked);
}
3.2 addToRight()
const addToRight = () => {
  let currentValue = props.modelValue.slice()
  // 将数组转换为集合以加快查找速度
  const leftCheckedSet = new Set(checkedState.leftChecked);
  const modelValueSet = new Set(props.modelValue);
    // 筛选和映射需要移动的items
    const itemsToBeMoved = props.data
    .filter((item: TransferDataItem) => {
      // 确保密钥存在于项目中
      const itemKey = item[propsAlias.value.key];
      // 检查item是否已被选中,并且尚未在目标列表中
      return leftCheckedSet.has(itemKey) && !modelValueSet.has(itemKey);
    })
    .map((item: TransferDataItem) => item[propsAlias.value.key]);

  currentValue =
    props.targetOrder === 'unshift'
      ? itemsToBeMoved.concat(currentValue)
      : currentValue.concat(itemsToBeMoved)

  if (props.targetOrder === 'original') {
    const currentValueSet = new Set(currentValue);
    currentValue = props.data
     .filter((item) => currentValueSet.has(item[propsAlias.value.key]))
     .map((item) => item[propsAlias.value.key]);
  }

  _emit(currentValue, 'right', checkedState.leftChecked)
}
二、组件优化部分
1.在transfer.ts中的transferProps增加两个入参itemSize、height
  itemSize: {
    type: Number,
    default: 26,
  },
  height: {
    type: Number,
    default: 200,
  },
2.transfer-panel.ts中transferPanelProps接收参数取transferProps的itemSize、height

image.png

3.transfer-panel.vue中使用fixed-size-list包裹el-checkbox

这里需要注意对fixed-size-list定义虚拟化渲染的容器为containerElement="label",因为checkbox最后渲染包裹的是“label”标签,如果不加将列表将会滚动,下面的不会渲染

      <el-checkbox-group
        v-show="!hasNoMatch && !isEmpty(data)"
        v-model="checked"
        :validate-event="false"
        :class="[ns.is('filterable', filterable), ns.be('panel', 'list')]"
        style="overflow: hidden;"
      >  
        <!-- 虚拟化列表 -->
        <fixed-size-list
          :class-name="ns.b('virtual-list')"
          :data="filteredData"
          containerElement="label"
          :total="filteredData.length"
          :height="height"
          :item-size="itemSize"
        >
          <template #default="{ data , index }">
            <el-checkbox
              :key="data[index][propsAlias.key]"
              :class="ns.be('panel', 'item')"
              :label="data[index][propsAlias.key]"
              :disabled="data[index][propsAlias.disabled]"
              :validate-event="false"
            >
              <option-content :option="optionRender?.(data[index])" />
            </el-checkbox>
          </template>
        </fixed-size-list>
      </el-checkbox-group>
4.调整多个文件的引入方式

涉及多个文件改动,这里简单介绍统一的改动调整引入

//源码
import { useLocale, useNamespace } from '@element-plus/hooks'
import { ElCheckbox, ElCheckboxGroup } from '@element-plus/components/checkbox'
import { ElInput } from '@element-plus/components/input'
import { FixedSizeList } from '@element-plus/components/virtual-list'
//改动:饿了吗组件相关、hook相关的引入都可以直接调整成一行按需引入
import { useLocale, useNamespace ,FixedSizeList ,ElCheckbox ,ElCheckboxGroup ,ElInput } from 'element-plus'
//使用@element-plus/utils统一调整成'element-plus/es/utils/index'
import { isEmpty } from '@element-plus/utils'
import { isEmpty } from 'element-plus/es/utils/index'

写在最后:第一次掘金发文章,写得不好,请见谅