element-ui transfer虚拟滚动+性能优化

1,202 阅读3分钟

起因是因为在工作中遇到了一个需求需要使用el-transfer,但是由于数据量接近有1w条,会导致该组件非常的卡顿;

看到的一篇文章中提到了两种解决方法,一种是分页,但是分页功能并不满足需求,所以只能使用虚拟滚动来实现需求,刚好本人在以前学习过虚拟滚动的实现方法。

所以本篇文章重点就不讲如何实现虚拟滚动了。

虚拟滚动:juejin.cn/post/710261…

业务实现

首先需要将element-ui中的transfer拷贝至自己的components文件夹下,直接将整改文件夹复制过去即可

文件路径: node_modules\element-ui\packages\transfer

image.png

虚拟滚动实现代码:

transfer-panel.vue

template:

唯一修改的地方(使用的是viewData)

<el-checkbox-group
  v-model="checked"
  v-show="!hasNoMatch && data.length > 0"
  :class="{ 'is-filterable': filterable }"
  class="el-transfer-panel__list">
  <div class="checkbox-container_background"></div>
  <div class="checkbox-container">
    <el-checkbox
      class="el-transfer-panel__item"
      :label="item[keyProp]"
      :disabled="item[disabledProp]"
      v-for="item in viewData"
      :key="item[keyProp]">
      <option-content :option="item"></option-content>
    </el-checkbox>
  </div>
</el-checkbox-group>

style

.el-transfer-panel {
  width: 220px;
}
.el-transfer-panel__filter {
  margin: 15px 30px;
}
.el-checkbox-group {
  position: relative;
}
.checkbox-container_background {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  z-index: -1;
}
.checkbox-container {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
}

script:

props: {
    className: String
},

data() {
  return {
    checked: [],
    allChecked: false,
    query: '',
    inputHover: false,
    checkChangeByUser: true,
    viewData: [],  // 试图渲染的数据
    listData: []  // 全部数据
  };
},

// 主要改造是在watch中的filteredData中
watch: {
    filteredData(val, oldVal) {
        // 第一次接收到数据的时候渲染视图
        if (!oldVal.length || oldVal.length < 10 || val.length < 20) {
          this.viewData = val.slice(0, val.length < 20 ? val.length : 20)
        }
        // 移动之后移动top刷新视图
        this.movedRefreshView(val, oldVal)
        
        // 讲移动之后的数据从viewData中过滤并把之后的元素填充
        this.filterViewData()
        
        // 变动之后的全部数据
        this.listData = [...this.filteredData]
        // 设置虚拟容器的高度
        this.setVirtualBackHeight()
      },
},

computed: {
    filteredData() {
        return this.data.filter(item => {
          if (typeof this.filterMethod === 'function') {
            return this.filterMethod(this.query, item);
          } else {
            const label = item[this.labelProp] || item[this.keyProp].toString();
            return label.toLowerCase().indexOf(this.query.toLowerCase()) > -1;
          }
        }).map((item, index) => {
          return {
            ...item,
            top: 30 * index // 视图容器滚动的top标记
          }
        });
      },
},

 mounted() {
     this.initVirtualScroll({
          backEl: `.${this.className} .checkbox-container_background`,
          childEl: `.${this.className} ` + '.el-transfer-panel__item',
          contentEl: `.${this.className} ` + '.checkbox-container',
          outEl: `.${this.className} ` + '.el-transfer-panel__list',
        })
},


methods: {
// 相关方法注册
initVirtualScroll({ contentEl, outEl }) {
        const MAX_COUNT = 10
        let switchScrollScale = []
        let ITEM_HEIGHT = 30
        const contentDom = document.querySelector(contentEl)
        const outDom = document.querySelector(outEl)

        let tick = false // 节流开关
        outDom.addEventListener('scroll', (e) => {
          if (!tick) {
            tick = true
            window.requestAnimationFrame(() => {
              tick = false
            })
            getRunDataList(e.target.scrollTop, { contentDom })
          }
        })

        const getRunDataList = (distance, { contentDom }) => {
          // 因为有beforeList和afterList的数据兜底
          // 所以可以在scrollTop处于nowList的数据上的时候不渲染数据
          // 等到滚动出了安全区的时候再渲染数据
          if (!switchScroll(distance)) {
            const startIndex = getStartIndex(distance)
            this.startIndex = startIndex

            const beforeList = this.listData.slice(getBeforeIndex(startIndex), startIndex)
   
            const renderData = this.listData.slice(getBeforeIndex(startIndex), getAfterIndex(startIndex) + MAX_COUNT)
            changeListTop(contentDom, startIndex, beforeList[0] || this.listData[startIndex])

            // 改变安全区
            changeSwitchScale(startIndex, getBeforeIndex(startIndex), getAfterIndex(startIndex))

            // 修改试图区数据
            this.changeViewData(renderData)
          }
        }

        // 移动list元素到指定位置
        function changeListTop (contentDom, startIndex, { top }) {
          contentDom.style.transform = `translate3d(0, ${top}px, 0)`
        }

        // 判断是否在安全区
        function switchScroll (scrollTop) {
          return scrollTop > switchScrollScale[0] && scrollTop < switchScrollScale[1]
        }

        // 改变安全区
        function changeSwitchScale (startIndex, beforeIndex, afterIndex) {
          const beforeScale = Math.ceil(startIndex) * ITEM_HEIGHT
          const afterScale = Math.floor((afterIndex)) * ITEM_HEIGHT
          switchScrollScale = [beforeScale, afterScale]
        }

        // 二分法查找
        const getStartIndex = (scrollTop) => {
          let start = 0
          let end = this.listData.length - 1
          while (start < end) {
            const mid = Math.floor((end + start) / 2)
            const { top } = this.listData[mid]
            if (scrollTop >= top && scrollTop < top + ITEM_HEIGHT) {
              start = mid
              break
            } else if (scrollTop >= top + ITEM_HEIGHT) {
              start = mid + 1
            } else if (scrollTop < top) {
              end = mid - 1
            }
          }
          return start < 0 ? 0 : start
        }

        function getBeforeIndex (startIndex) {
          return startIndex - MAX_COUNT < 0 ? 0 : startIndex - MAX_COUNT
        }

        const getAfterIndex  = (startIndex) => {
          return startIndex + MAX_COUNT > this.listData.length ? this.listData.length : startIndex + MAX_COUNT
        }

      },

      setScrollTop(index) {
        document.querySelector(`.${this.className} ` + '.el-transfer-panel__list').scrollTop = index * 30
      },

      changeViewData(data) {
        this.viewData = [...data]
      },

      movedRefreshView(val, oldVal) {

        const getViewIndex = () => {
          const viewLength = this.viewData.length
          let result = 0
          if (viewLength <= 10)  {
            result = 0
          } else if (viewLength <= 20) {
            result = viewLength - 11
          } else if (viewLength <= 30) {
            result = 10
          }
          return val.length > oldVal.length ? 0 : result
        }
        // 初始化不改变top
        if (val.length === oldVal.length) return
        
        const viewIndex = getViewIndex()
        const index = this.filteredData.findIndex((item) => item.id === this.viewData[viewIndex]?.id)
        if (index !== -1) {
          this.setScrollTop(index)
        }
      },

      setVirtualBackHeight() {
        document.querySelector(`.${this.className} .checkbox-container_background`).style.height
          = this.listData.length * 30  + 'px'
      },

      filterViewData() {
        const filteredDataKeys = this.filteredData.map(item => item[this.keyProp]);
        this.viewData = this.viewData.filter((item) => filteredDataKeys.includes(item[this.keyProp]))
      },
      
      // 全选方法性能优化
       updateAllChecked() {
        let allKeyProps = {};
        const checkAbleDataKeys = this.checkableData.map(item => {
          let keyProps = {};
          keyProps[item[this.keyProp]] = true;
          allKeyProps[item[this.keyProp]] = true;
          return keyProps;
        });
        this.allChecked =
          checkAbleDataKeys.length > 0 &&
          this.checked.length > 0 &&
          this.checked.every((item) => allKeyProps[item]);
      },
      
}

main.vue

在实现以上虚拟滚动之后,发现向右添加数据的时候还是会卡顿,需要优化mian.vue中的过滤算法

主要就是将原组件中嵌套循环的方法改成了单一循环(O(n²) ->O(n))

template

在两个transfer-panel组件上绑定className属性,并且给予对应类名

<div class="el-transfer">
  <transfer-panel
    v-loading="isTransLate"
    v-bind="$props"
    class="leftPanel"
    :className="'leftPanel'"
    ref="leftPanel"
    :data="sourceData"
    :title="titles[0] || t('el.transfer.titles.0')"
    :default-checked="leftDefaultChecked"
    :placeholder="filterPlaceholder || t('el.transfer.filterPlaceholder')"
    @checked-change="onSourceCheckedChange">
    <slot name="left-footer"></slot>
  </transfer-panel>
  <div class="el-transfer__buttons">
    <el-button
      type="primary"
      :class="['el-transfer__button', hasButtonTexts ? 'is-with-texts' : '']"
      @click.native="addToLeft"
      :disabled="isTransLate || rightChecked.length === 0">
      <i class="el-icon-arrow-left"></i>
      <span v-if="buttonTexts[0] !== undefined">{{ buttonTexts[0] }}</span>
    </el-button>
    <el-button
      type="primary"
      :class="['el-transfer__button', hasButtonTexts ? 'is-with-texts' : '']"
      @click.native="addToRight"
      :disabled="isTransLate || leftChecked.length === 0">
      <span v-if="buttonTexts[1] !== undefined">{{ buttonTexts[1] }}</span>
      <i class="el-icon-arrow-right"></i>
    </el-button>
  </div>
  <transfer-panel
    v-loading="isTransLate"
    v-bind="$props"
    ref="rightPanel"
    :className="'rightPanel'"
    class="rightPanel"
    :data="targetData"
    :title="titles[1] || t('el.transfer.titles.1')"
    :default-checked="rightDefaultChecked"
    :placeholder="filterPlaceholder || t('el.transfer.filterPlaceholder')"
    @checked-change="onTargetCheckedChange">
    <slot name="right-footer"></slot>
  </transfer-panel>

script

computed: {
  dataObj() {
    const key = this.props.key;
    return this.data.reduce((o, cur) => (o[cur[key]] = cur) && o, {});
  },

  sourceData() {
    const valueObj = {}
    this.value.forEach(item => valueObj[item] = true)
    return this.data.filter(item => !valueObj[item[this.props.key]]);
  },

  targetData() {
    if (this.targetOrder === 'original') {
      const valueObj = {}
      this.value.forEach(item => valueObj[item] = true)
      return this.data.filter(item => valueObj[item[this.props.key]]);
    } else {
      return this.value.reduce((arr, cur) => {
        const val = this.dataObj[cur];
        if (val) {
          arr.push(val);
        }
        return arr;
      }, []);
    }
  },

  hasButtonTexts() {
    return this.buttonTexts.length === 2;
  }
},

methods: {
  addToRight() {
        let currentValue = this.value.slice();
        const itemsToBeMoved = [];
        const key = this.props.key;

        let leftCheckedKeyPropsObj = {};
        this.leftChecked.forEach((item) => {
          leftCheckedKeyPropsObj[item] = true;
        });

        let valueKeyPropsObj = {};
        this.value.forEach((item) => {
          valueKeyPropsObj[item] = true;
        });
        this.data.forEach((item) => {
          const itemKey = item[key];
          if ( leftCheckedKeyPropsObj[itemKey] && !valueKeyPropsObj[itemKey] ) {
            itemsToBeMoved.push(itemKey);
          }
        });

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

        this.$emit('input', currentValue);
        this.$emit('change', currentValue, 'right', this.leftChecked);
      },

}

全部代码:

main.vue

<template>
  <div class="el-transfer">
    <transfer-panel
      v-loading="isTransLate"
      v-bind="$props"
      class="leftPanel"
      :className="'leftPanel'"
      ref="leftPanel"
      :data="sourceData"
      :title="titles[0] || t('el.transfer.titles.0')"
      :default-checked="leftDefaultChecked"
      :placeholder="filterPlaceholder || t('el.transfer.filterPlaceholder')"
      @checked-change="onSourceCheckedChange">
      <slot name="left-footer"></slot>
    </transfer-panel>
    <div class="el-transfer__buttons">
      <el-button
        type="primary"
        :class="['el-transfer__button', hasButtonTexts ? 'is-with-texts' : '']"
        @click.native="addToLeft"
        :disabled="isTransLate || rightChecked.length === 0">
        <i class="el-icon-arrow-left"></i>
        <span v-if="buttonTexts[0] !== undefined">{{ buttonTexts[0] }}</span>
      </el-button>
      <el-button
        type="primary"
        :class="['el-transfer__button', hasButtonTexts ? 'is-with-texts' : '']"
        @click.native="addToRight"
        :disabled="isTransLate || leftChecked.length === 0">
        <span v-if="buttonTexts[1] !== undefined">{{ buttonTexts[1] }}</span>
        <i class="el-icon-arrow-right"></i>
      </el-button>
    </div>
    <transfer-panel
      v-loading="isTransLate"
      v-bind="$props"
      ref="rightPanel"
      :className="'rightPanel'"
      class="rightPanel"
      :data="targetData"
      :title="titles[1] || t('el.transfer.titles.1')"
      :default-checked="rightDefaultChecked"
      :placeholder="filterPlaceholder || t('el.transfer.filterPlaceholder')"
      @checked-change="onTargetCheckedChange">
      <slot name="right-footer"></slot>
    </transfer-panel>
  </div>
</template>

<script>
  import ElButton from 'element-ui/packages/button';
  import Emitter from 'element-ui/src/mixins/emitter';
  import Locale from 'element-ui/src/mixins/locale';
  import TransferPanel from './transfer-panel.vue';
  import Migrating from 'element-ui/src/mixins/migrating';

  export default {
    name: 'ElTransfer',

    mixins: [Emitter, Locale, Migrating],

    components: {
      TransferPanel,
      ElButton
    },

    props: {
      data: {
        type: Array,
        default() {
          return [];
        }
      },
      titles: {
        type: Array,
        default() {
          return [];
        }
      },
      buttonTexts: {
        type: Array,
        default() {
          return [];
        }
      },
      filterPlaceholder: {
        type: String,
        default: ''
      },
      filterMethod: Function,
      leftDefaultChecked: {
        type: Array,
        default() {
          return [];
        }
      },
      rightDefaultChecked: {
        type: Array,
        default() {
          return [];
        }
      },
      renderContent: Function,
      value: {
        type: Array,
        default() {
          return [];
        }
      },
      format: {
        type: Object,
        default() {
          return {};
        }
      },
      filterable: Boolean,
      props: {
        type: Object,
        default() {
          return {
            label: 'label',
            key: 'key',
            disabled: 'disabled'
          };
        }
      },
      targetOrder: {
        type: String,
        default: 'original'
      }
    },

    data() {
      return {
        leftChecked: [],
        rightChecked: [],
        isTransLate: false
      };
    },

    computed: {
      dataObj() {
        const key = this.props.key;
        return this.data.reduce((o, cur) => (o[cur[key]] = cur) && o, {});
      },

      sourceData() {
        const valueObj = {}
        this.value.forEach(item => valueObj[item] = true)
        return this.data.filter(item => !valueObj[item[this.props.key]]);
      },

      targetData() {
        if (this.targetOrder === 'original') {
          const valueObj = {}
          this.value.forEach(item => valueObj[item] = true)
          return this.data.filter(item => valueObj[item[this.props.key]]);
        } else {
          return this.value.reduce((arr, cur) => {
            const val = this.dataObj[cur];
            if (val) {
              arr.push(val);
            }
            return arr;
          }, []);
        }
      },

      hasButtonTexts() {
        return this.buttonTexts.length === 2;
      }
    },

    watch: {
      value(val) {
        this.dispatch('ElFormItem', 'el.form.change', val);
      }
    },

    methods: {
      getMigratingConfig() {
        return {
          props: {
            'footer-format': 'footer-format is renamed to format.'
          }
        };
      },

      onSourceCheckedChange(val, movedKeys) {
        this.leftChecked = val;
        if (movedKeys === undefined) return;
        this.$emit('left-check-change', val, movedKeys);
      },

      onTargetCheckedChange(val, movedKeys) {
        this.rightChecked = val;
        if (movedKeys === undefined) return;
        this.$emit('right-check-change', val, movedKeys);
      },

      addToLeft() {
        let currentValue = this.value.slice();
        this.rightChecked.forEach(item => {
          const index = currentValue.indexOf(item);
          if (index > -1) {
            currentValue.splice(index, 1);
          }
        });
        this.$emit('input', currentValue);
        this.$emit('change', currentValue, 'left', this.rightChecked);
      },

      addToRight() {
        let currentValue = this.value.slice();
        const itemsToBeMoved = [];
        const key = this.props.key;

        let leftCheckedKeyPropsObj = {};
        this.leftChecked.forEach((item) => {
          leftCheckedKeyPropsObj[item] = true;
        });

        let valueKeyPropsObj = {};
        this.value.forEach((item) => {
          valueKeyPropsObj[item] = true;
        });
        this.data.forEach((item) => {
          const itemKey = item[key];
          if ( leftCheckedKeyPropsObj[itemKey] && !valueKeyPropsObj[itemKey] ) {
            itemsToBeMoved.push(itemKey);
          }
        });

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

        this.$emit('input', currentValue);
        this.$emit('change', currentValue, 'right', this.leftChecked);
      },

      clearQuery(which) {
        if (which === 'left') {
          this.$refs.leftPanel.query = '';
        } else if (which === 'right') {
          this.$refs.rightPanel.query = '';
        }
      }
    }
  };
</script>

transfer-panel.vue

<template>
  <div class="el-transfer-panel">
    <p class="el-transfer-panel__header">
      <el-checkbox
        v-model="allChecked"
        @change="handleAllCheckedChange"
        :indeterminate="isIndeterminate">
        {{ title }}
        <span>{{ checkedSummary }}</span>
      </el-checkbox>
    </p>

    <div :class="['el-transfer-panel__body', hasFooter ? 'is-with-footer' : '']">
      <el-input
        class="el-transfer-panel__filter"
        v-model="query"
        size="small"
        :placeholder="placeholder"
        @mouseenter.native="inputHover = true"
        @mouseleave.native="inputHover = false"
        v-if="filterable">
        <i slot="prefix"
          :class="['el-input__icon', 'el-icon-' + inputIcon]"
          @click="clearQuery"
        ></i>
      </el-input>
      <el-checkbox-group
        v-model="checked"
        v-show="!hasNoMatch && data.length > 0"
        :class="{ 'is-filterable': filterable }"
        class="el-transfer-panel__list">
        <div class="checkbox-container_background"></div>
        <div class="checkbox-container">
          <el-checkbox
            class="el-transfer-panel__item"
            :label="item[keyProp]"
            :disabled="item[disabledProp]"
            v-for="item in viewData"
            :key="item[keyProp]">
            <option-content :option="item"></option-content>
          </el-checkbox>
        </div>
      </el-checkbox-group>
      <p
        class="el-transfer-panel__empty"
        v-show="hasNoMatch">{{ t('el.transfer.noMatch') }}</p>
      <p
        class="el-transfer-panel__empty"
        v-show="data.length === 0 && !hasNoMatch">{{ t('el.transfer.noData') }}</p>
    </div>
    <p class="el-transfer-panel__footer" v-if="hasFooter">
      <slot></slot>
    </p>
  </div>
</template>

<script>
  import ElCheckboxGroup from 'element-ui/packages/checkbox-group';
  import ElCheckbox from 'element-ui/packages/checkbox';
  import ElInput from 'element-ui/packages/input';
  import Locale from 'element-ui/src/mixins/locale';

  export default {
    mixins: [Locale],

    name: 'ElTransferPanel',

    componentName: 'ElTransferPanel',

    components: {
      ElCheckboxGroup,
      ElCheckbox,
      ElInput,
      OptionContent: {
        props: {
          option: Object
        },
        render(h) {
          const getParent = vm => {
            if (vm.$options.componentName === 'ElTransferPanel') {
              return vm;
            } else if (vm.$parent) {
              return getParent(vm.$parent);
            } else {
              return vm;
            }
          };
          const panel = getParent(this);
          const transfer = panel.$parent || panel;
          return panel.renderContent
            ? panel.renderContent(h, this.option)
            : transfer.$scopedSlots.default
              ? transfer.$scopedSlots.default({ option: this.option })
              : <span>{ this.option[panel.labelProp] || this.option[panel.keyProp] }</span>;
        }
      }
    },

    props: {
      data: {
        type: Array,
        default() {
          return [];
        }
      },
      renderContent: Function,
      placeholder: String,
      title: String,
      filterable: Boolean,
      format: Object,
      filterMethod: Function,
      defaultChecked: Array,
      props: Object,
      className: String
    },

    data() {
      return {
        checked: [],
        allChecked: false,
        query: '',
        inputHover: false,
        checkChangeByUser: true,
        viewData: [],
        listData: []
      };
    },

    watch: {
      checked(val, oldVal) {
        this.updateAllChecked();
        if (this.checkChangeByUser) {
          const movedKeys = val.concat(oldVal)
            .filter(v => val.indexOf(v) === -1 || oldVal.indexOf(v) === -1);
          this.$emit('checked-change', val, movedKeys);
        } else {
          this.$emit('checked-change', val);
          this.checkChangeByUser = true;
        }
      },

      filteredData(val, oldVal) {
        if (!oldVal.length || oldVal.length < 10 || val.length < 20) {
          this.viewData = val.slice(0, val.length < 20 ? val.length : 20)
        }
        this.movedRefreshView(val, oldVal)

        this.filterViewData()

        this.listData = [...this.filteredData]
        this.setVirtualBackHeight()
      },

      data() {
        const checked = [];
        const filteredDataKeys = this.filteredData.map(item => item[this.keyProp]);
        this.checked.forEach(item => {
          if (filteredDataKeys.indexOf(item) > -1) {
            checked.push(item);
          }
        });
        this.checkChangeByUser = false;
        this.checked = checked;
      },

      checkableData() {
        this.updateAllChecked();
      },

      defaultChecked: {
        immediate: true,
        handler(val, oldVal) {
          if (oldVal && val.length === oldVal.length &&
            val.every(item => oldVal.indexOf(item) > -1)) return;
          const checked = [];
          const checkableDataKeys = this.checkableData.map(item => item[this.keyProp]);
          val.forEach(item => {
            if (checkableDataKeys.indexOf(item) > -1) {
              checked.push(item);
            }
          });
          this.checkChangeByUser = false;
          this.checked = checked;
        }
      }
    },

    computed: {
      filteredData() {
        return this.data.filter(item => {
          if (typeof this.filterMethod === 'function') {
            return this.filterMethod(this.query, item);
          } else {
            const label = item[this.labelProp] || item[this.keyProp].toString();
            return label.toLowerCase().indexOf(this.query.toLowerCase()) > -1;
          }
        }).map((item, index) => {
          return {
            ...item,
            top: 30 * index
          }
        });
      },

      checkableData() {
        return this.filteredData.filter(item => !item[this.disabledProp]);
      },

      checkedSummary() {
        const checkedLength = this.checked.length;
        const dataLength = this.data.length;
        const { noChecked, hasChecked } = this.format;
        if (noChecked && hasChecked) {
          return checkedLength > 0
            ? hasChecked.replace(/${checked}/g, checkedLength).replace(/${total}/g, dataLength)
            : noChecked.replace(/${total}/g, dataLength);
        } else {
          return `${ checkedLength }/${ dataLength }`;
        }
      },

      isIndeterminate() {
        const checkedLength = this.checked.length;
        return checkedLength > 0 && checkedLength < this.checkableData.length;
      },

      hasNoMatch() {
        return this.query.length > 0 && this.filteredData.length === 0;
      },

      inputIcon() {
        return this.query.length > 0 && this.inputHover
          ? 'circle-close'
          : 'search';
      },

      labelProp() {
        return this.props.label || 'label';
      },

      keyProp() {
        return this.props.key || 'key';
      },

      disabledProp() {
        return this.props.disabled || 'disabled';
      },

      hasFooter() {
        return !!this.$slots.default;
      }
    },
    mounted() {
      this.$nextTick(() => {
        this.initVirtualScroll({
          backEl: `.${this.className} .checkbox-container_background`,
          childEl: `.${this.className} ` + '.el-transfer-panel__item',
          contentEl: `.${this.className} ` + '.checkbox-container',
          outEl: `.${this.className} ` + '.el-transfer-panel__list',
        })
      })
    },
    methods: {

      initVirtualScroll({ contentEl, outEl }) {
        const MAX_COUNT = 10
        let switchScrollScale = []
        let ITEM_HEIGHT = 30
        const contentDom = document.querySelector(contentEl)
        const outDom = document.querySelector(outEl)

        let tick = false // 节流开关
        outDom.addEventListener('scroll', (e) => {
          if (!tick) {
            tick = true
            window.requestAnimationFrame(() => {
              tick = false
            })
            getRunDataList(e.target.scrollTop, { contentDom })
          }
        })

        const getRunDataList = (distance, { contentDom }) => {
          // 因为有beforeList和afterList的数据兜底
          // 所以可以在scrollTop处于nowList的数据上的时候不渲染数据
          // 等到滚动出了安全区的时候再渲染数据
          if (!switchScroll(distance)) {
            const startIndex = getStartIndex(distance)
            this.startIndex = startIndex

            const beforeList = this.listData.slice(getBeforeIndex(startIndex), startIndex)
            // const nowList = this.listData.slice(startIndex, startIndex + MAX_COUNT)
            // const afterList
            //   = this.listData.slice(getAfterIndex(startIndex), getAfterIndex(startIndex) + MAX_COUNT)

            const renderData = this.listData.slice(getBeforeIndex(startIndex), getAfterIndex(startIndex) + MAX_COUNT)
            changeListTop(contentDom, startIndex, beforeList[0] || this.listData[startIndex])

            // 改变安全区
            changeSwitchScale(startIndex, getBeforeIndex(startIndex), getAfterIndex(startIndex))

            // 修改试图区数据
            this.changeViewData(renderData)
          }
        }

        // 移动list元素到指定位置
        function changeListTop (contentDom, startIndex, { top }) {
          contentDom.style.transform = `translate3d(0, ${top}px, 0)`
        }

        // 判断是否在安全区
        function switchScroll (scrollTop) {
          return scrollTop > switchScrollScale[0] && scrollTop < switchScrollScale[1]
        }

        // 改变安全区
        function changeSwitchScale (startIndex, beforeIndex, afterIndex) {
          const beforeScale = Math.ceil(startIndex) * ITEM_HEIGHT
          const afterScale = Math.floor((afterIndex)) * ITEM_HEIGHT
          switchScrollScale = [beforeScale, afterScale]
        }

        // 二分法查找
        const getStartIndex = (scrollTop) => {
          let start = 0
          let end = this.listData.length - 1
          while (start < end) {
            const mid = Math.floor((end + start) / 2)
            const { top } = this.listData[mid]
            if (scrollTop >= top && scrollTop < top + ITEM_HEIGHT) {
              start = mid
              break
            } else if (scrollTop >= top + ITEM_HEIGHT) {
              start = mid + 1
            } else if (scrollTop < top) {
              end = mid - 1
            }
          }
          return start < 0 ? 0 : start
        }

        function getBeforeIndex (startIndex) {
          return startIndex - MAX_COUNT < 0 ? 0 : startIndex - MAX_COUNT
        }

        const getAfterIndex  = (startIndex) => {
          return startIndex + MAX_COUNT > this.listData.length ? this.listData.length : startIndex + MAX_COUNT
        }

      },

      setScrollTop(index) {
        document.querySelector(`.${this.className} ` + '.el-transfer-panel__list').scrollTop = index * 30
      },

      changeViewData(data) {
        this.viewData = [...data]
      },

      movedRefreshView(val, oldVal) {

        const getViewIndex = () => {
          const viewLength = this.viewData.length
          let result = 0
          if (viewLength <= 10)  {
            result = 0
          } else if (viewLength <= 20) {
            result = viewLength - 11
          } else if (viewLength <= 30) {
            result = 10
          }
          return val.length > oldVal.length ? 0 : result
        }

        const viewIndex = getViewIndex()
        const index = this.filteredData.findIndex((item) => item.id === this.viewData[viewIndex]?.id)
        if (index !== -1) {
          this.setScrollTop(index)
        }
      },

      setVirtualBackHeight() {
        document.querySelector(`.${this.className} .checkbox-container_background`).style.height
          = this.listData.length * 30  + 'px'
      },

      filterViewData() {
        const filteredDataKeys = this.filteredData.map(item => item[this.keyProp]);
        this.viewData = this.viewData.filter((item) => filteredDataKeys.includes(item[this.keyProp]))
      },

      updateAllChecked() {
        let allKeyProps = {};
        const checkAbleDataKeys = this.checkableData.map(item => {
          let keyProps = {};
          keyProps[item[this.keyProp]] = true;
          allKeyProps[item[this.keyProp]] = true;
          return keyProps;
        });
        this.allChecked =
          checkAbleDataKeys.length > 0 &&
          this.checked.length > 0 &&
          this.checked.every((item) => allKeyProps[item]);
      },

      handleAllCheckedChange(value) {

        this.checked = value
          ? this.checkableData.map(item => item[this.keyProp])
          : [];
      },

      clearQuery() {
        if (this.inputIcon === 'circle-close') {
          this.query = '';
        }
      }
    }
  };
</script>

<style scope>
.el-transfer-panel {
  width: 220px;
}
.el-transfer-panel__filter {
  margin: 15px 30px;
}
.el-checkbox-group {
  position: relative;
}
.checkbox-container_background {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  z-index: -1;
}
.checkbox-container {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
}
</style>

优化想法以及思路来自大佬的文章(www.cnhackhy.com/84221.htm