手记

【业务实例】vue2用虚拟列表解决万条数据勾选卡顿问题

为什么需要虚拟列表

日常开发中,经常需要处理一个大数据量的列表,可能是需要展示、勾选等;

如果我们用html原生的标签实现,性能到还好。但是现在大多都是用第三方组件库开发(例如element-ui),以便提升开发效率;

如果我们同时展示、勾选千条数据的时候,页面将会卡主,甚至崩掉;

虚拟列表的方案正是为了解决这类前端大数据量展示、操作卡顿的问题;

虚拟列表原理

虚拟列表只对部分区域进行渲染,对区域外的DOM进行销毁操作。

如果用户上下滚动,那DOM元素就会一直创建、销毁(做好笔记,这是考试重点)。

实现多选框组件(万条数据不卡顿)

网上插件很多,不重复造轮子了,本次案例使用的插件 vue-virtual-scroll-list,详细参数配置请查看官网

要实现大数据量的展示和勾选
1.核心点在vue的 mixins 中注册事件 dispatch (这是官网的案例代码);
2.在组件中使用 $on 订阅虚拟滚动插件 data-component 的勾选事件;
3.在虚拟列表的子组件中,在mounted钩子改变勾选状态,因为随着列表滚动,子组件在不断被创建、销毁;’

tips:相关知识,请查看相应链接。本案例未实现默认值、全选等功能,你可以根据实际业务,对组件二次改造。

调用效果及代码

<!--
 * @Date: 2022-05-13 13:55:59
 * @Author: surewinT 840325271@qq.com
 * @LastEditTime: 2022-05-13 22:09:43
 * @LastEditors: surewinT 840325271@qq.com
 * @Description: 调用页面
-->

<template>
  <div class="">
    <p-virtual-check :dataSources="dataList" @check-change="checkChange" />
  </div>
</template>

<script>
import PVirtualCheck from "@/components/p-virtual-check";
export default {
  components: {
    "p-virtual-check": PVirtualCheck,
  },
  props: [],
  data() {
    return {
      dataList: [],
    };
  },
  mounted() {
    this.getData();
  },
  watch: {},
  methods: {
    // 模拟数据获取
    getData() {
      let arr = [];
      for (let i = 0; i < 10000; i++) {
        arr.push({
          label: `名称:${i + 1}`,
          value: `value${i + 1}`,
        });
      }
      this.dataList = arr;
    },
    checkChange(result) {
      console.log("勾选:", result);
    },
  },
};

</script>
<style lang="scss" scoped></style>

全局混入mixins(核心代码)

/*
 * @Date: 2022-01-07 16:21:56
 * @Author: surewinT 840325271@qq.com
 * @LastEditTime: 2022-05-13 22:25:00
 * @LastEditors: surewinT 840325271@qq.com
 * @Description: mixin 混入
 */
 
import Vue from 'vue'

Vue.mixin({
    data() {
        return {}
    },
    methods: {
        // 组件事件传递
        dispatch(componentName, eventName, ...rest) {
            let parent = this.$parent || this.$root
            let name = parent.$options.name
            while (parent && (!name || name !== componentName)) {
                parent = parent.$parent
                if (parent) {
                    name = parent.$options.name
                }
            }
            if (parent) {
                parent.$emit.apply(parent, [eventName].concat(rest))
            }
        }
    }
})

组件源码(p-virtual-check)

目录结构

├── p-virtual-check            
      ├── index.vue        # 组件入口
      ├── listItem.vue     # 子组件-勾选框

index.vue

<!--
 * @Date: 2022-05-13 14:01:24
 * @Author: surewinT 840325271@qq.com
 * @LastEditTime: 2022-05-13 22:10:20
 * @LastEditors: surewinT 840325271@qq.com
 * @Description: 虚拟列表-多选框组件
-->

<template>
  <div class="">
    <virtual-list
      class="check-virtual-list"
      :keeps="40"
      data-key="value"
      :data-sources="dataSources"
      :data-component="dataComponent"
      :extra-props="extraProps"
    />
  </div>
</template>

<script>
import VirtualList from "vue-virtual-scroll-list";
import ListItem from "./listItem.vue";
export default {
  name: "p-virtual-check",
  components: {
    "virtual-list": VirtualList,
  },
  props: {
    dataSources: Array,
  },
  data() {
    return {
      dataComponent: ListItem,
      // 传入组件的额外参数
      extraProps: {},
      checkMap: {},
    };
  },
  mounted() {
    this.setExtraProps();
    // 订阅勾选事件
    this.$on("virtual-check-change", (key, val) => {
      this.checkMap[key] = val;
      this.setExtraProps();
      this.$emit("check-change", this.checkMap);
    });
  },
  watch: {},
  methods: {
    setExtraProps() {
      this.extraProps["checkMap"] = this.checkMap;
    },
  },
};
</script>

<style lang="scss" scoped>
.check-virtual-list {
  height: 400px;
  width: 200px;
  overflow: auto;
  padding: 0 10px;
}
</style>

listItem.vue


<!--
 * @Date: 2022-01-11 16:54:51
 * @Author: surewinT 840325271@qq.com
 * @LastEditTime: 2022-05-13 22:09:50
 * @LastEditors: surewinT 840325271@qq.com
 * @Description: 勾选框
-->

<template>
  <div class="">
    <el-checkbox v-model="checked" @change="change">
      {{ source.label }}
    </el-checkbox>
  </div>
</template>

<script>
export default {
  components: {},
  props: {
    source: {
      type: Object,
      default() {
        return {};
      },
    },
    // 列表的全部勾选情况
    checkMap: Object,
  },
  data() {
    return {
      checked: false,
    };
  },
  mounted() {
    // 回显勾选状态
    this.checkChange();
  },
  watch: {},
  methods: {
    // 勾选改变
    change(val) {
      // 通过全局混入函数,调用对应组件的函数
      this.dispatch(
        "p-virtual-check",
        "virtual-check-change",
        this.source.value,
        val
      );
    },
    // 选择改变
    checkChange() {
      this.checked = this.checkMap[this.source.value] === true ? true : false;
    },
  },
};
</script>

<style lang="scss" scoped></style>

实现穿梭框组件(万条数据不卡顿)

穿梭框组件的核心代码跟上述的多选框组件一致,只不过多了搜索、勾选去重等功能,整体的功能更为复杂;

你可以根据实际业务,添加插槽、修改默认值等;

调用效果及代码

<!--
 * @Date: 2022-05-13 13:55:59
 * @Author: surewinT 840325271@qq.com
 * @LastEditTime: 2022-05-13 22:10:59
 * @LastEditors: surewinT 840325271@qq.com
 * @Description: 调用页面
-->

<template>
  <div class="">
    <p-virtual-transfer
      data-value="id"
      data-label="name"
      :basedata="basedata"
      v-model="result"
    />
  </div>
</template>

<script>
import PVirtualTransfer from "@/components/p-virtual-transfer";
export default {
  components: {
    "p-virtual-transfer": PVirtualTransfer,
  },
  props: [],
  data() {
    return {
      basedata: [],
      result: ["value1", "value2", "value4"],
    };
  },
  mounted() {
    this.getData();
  },
  watch: {
    result() {
      console.log("结果:", this.result);
    },
  },
  methods: {
    // 模拟数据获取
    getData() {
      let arr = [];
      for (let i = 0; i < 10000; i++) {
        arr.push({
          name: `名称:${i + 1}`,
          id: `value${i + 1}`,
        });
      }
      this.basedata = arr;
    },
  },
};
</script>

<style lang="scss" scoped>
</style>

组件源码(p-virtual-transfer)

目录结构

├── p-virtual-transfer          
      ├── index.vue          # 组件入口
      ├── listItem.vue       # 子组件-勾选框
      ├── virtualList.vue     # 子组件-列表

index.vue

<!--
 * @Date: 2022-01-11 11:02:27
 * @Author: surewinT 840325271@qq.com
 * @LastEditTime: 2022-05-13 22:10:27
 * @LastEditors: surewinT 840325271@qq.com
 * @Description: 虚拟滚动-穿梭框组件
-->

<template>
  <div class="root">
    <!-- 左侧 -->
    <virtual-list
      :data-value="dataValue"
      :data-label="dataLabel"
      ref="Middle"
      title="标题1"
      :basedata="leftData"
      @check-change="leftChange"
    />
    <!-- 中间按钮 -->
    <div class="game-btn">
      <el-button
        icon="el-icon-arrow-left"
        type="primary"
        title="移到左边"
        @click="move('left')"
      ></el-button>
      <el-button
        icon="el-icon-arrow-right"
        type="primary"
        title="移到右边"
        @click="move('right')"
      ></el-button>
    </div>
    <!-- 右侧 -->
    <virtual-list
      ref="Right"
      title="标题2"
      :data-value="dataValue"
      :data-label="dataLabel"
      :basedata="rightData"
      @check-change="rightChange"
    />
  </div>
</template>

<script>
import VirtualList from "./virtualList.vue";
export default {
  components: {
    "virtual-list": VirtualList,
  },
  model: {
    prop: "value",
    event: "input",
  },
  props: {
    value: {
      type: Array,
      default: () => {
        return [];
      },
    },
    basedata: {
      type: Array,
      default: () => {
        return [];
      },
    },
    dataValue: {
      type: String,
      default: "value",
    },
    dataLabel: {
      type: String,
      default: "label",
    },
  },
  data() {
    return {
      leftData: [],
      leftSelect: [],
      rightData: [],
      rightSelect: [],
    };
  },
  mounted() {
    this.initData(this.value);
  },
  watch: {
    basedata() {
      this.initData(this.value);
    },
  },
  methods: {
    // 初始化数据
    initData(value) {
      let middleArr = [];
      let rightArr = [];
      let valueMap = {};
      value.forEach((res) => {
        valueMap[res] = true;
      });
      this.basedata.forEach((res) => {
        if (valueMap[res[this.dataValue]]) {
          rightArr.push(res);
        } else {
          middleArr.push(res);
        }
      });
      this.leftData = middleArr;
      this.rightData = rightArr;
    },
    // 左侧勾选
    leftChange(list) {
      this.leftSelect = list;
    },
    // 右侧勾选
    rightChange(list) {
      this.rightSelect = list;
    },
    // 左右移动
    async move(type) {
      let value = [];
      if (type == "right") {
        if (this.leftSelect.length == 0) {
          this.$message("未勾选任何数据");
          return;
        }
        value = [...this.value, ...this.leftSelect];
      }
      if (type == "left") {
        if (this.rightSelect.length == 0) {
          this.$message("未勾选任何数据");
          return;
        }
        value = this.value.filter((res) => {
          return !this.rightSelect.includes(res);
        });
      }
      this.initData(value);
      this.$emit("input", value);
    },
  },
};
</script>

<style lang="scss" scoped>
.root {
  display: flex;
  flex: 1;
  width: 910px;
  .game-left {
    border-right: 1px dashed #ccc;
  }
  .game-btn {
    display: flex;
    align-items: center;
    margin: 0 10px;
  }
}
</style>

listItem.vue

<!--
 * @Date: 2022-01-11 16:54:51
 * @Author: surewinT 840325271@qq.com
 * @LastEditTime: 2022-05-13 21:50:26
 * @LastEditors: surewinT 840325271@qq.com
 * @Description: 勾选框
-->

<template>
  <div class="">
    <el-checkbox v-model="checked" @change="change">
      {{ source[dataLabel] }}
    </el-checkbox>
  </div>
</template>

<script>
export default {
  components: {},
  props: {
    source: Object,
    dataLabel: String,
    dataValue: String,
    checkMap: Object,
  },
  data() {
    return {
      checked: false,
    };
  },
  mounted() {
    // 回显勾选状态
    this.checkChange();
  },
  watch: {},
  methods: {
    // 勾选改变
    change(val) {
      this.dispatch(
        "p-virtual-transfer",
        "virtual-transfer-check-change",
        this.source[this.dataValue],
        val
      );
    },
    // 选择改变
    checkChange() {
      this.checked =
        this.checkMap[this.source[this.dataValue]] === true ? true : false;
    },
  },
};
</script>

<style lang="scss" scoped></style>

virtualList.vue

<!--
 * @Date: 2022-01-11 11:02:27
 * @Author: surewinT 840325271@qq.com
 * @LastEditTime: 2022-05-13 21:50:46
 * @LastEditors: surewinT 840325271@qq.com
 * @Description: 列表
-->

<template>
  <div class="check-root">
    <!-- 头部 -->
    <div class="header">
      <el-checkbox v-model="checkAll" @change="changeAll">
        {{ title }}
      </el-checkbox>
      <div class="num">{{ checkNum }}</div>
    </div>
    <div class="search" v-if="showSearch">
      <el-input
        size="small"
        clearable
        placeholder="支持 lable / value 搜索"
        v-model="searchName"
        @input="inputChange"
      >
      </el-input>
    </div>
    <!-- 列表 -->
    <div class="main" :style="{ width: width }">
      <virtual-list
        class="check-virtual-list"
        :keeps="50"
        :key="virtualKey"
        :data-key="dataValue"
        :data-sources="dataSources"
        :data-component="dataComponent"
        :extra-props="extraProps"
      />
    </div>
  </div>
</template>

<script>
import VirtualList from "vue-virtual-scroll-list";
import List from "./listItem.vue";
export default {
  name: "p-virtual-transfer",
  components: {
    "virtual-list": VirtualList,
  },
  props: {
    basedata: {
      type: Array,
      default: () => {
        return [];
      },
    },
    width: {
      type: String,
      default: "280px",
    },
    title: {
      type: String,
      default: "",
    },
    showSearch: {
      type: Boolean,
      default: true,
    },
    dataValue: {
      type: String,
      default: "value",
    },
    dataLabel: {
      type: String,
      default: "label",
    },
  },
  data() {
    return {
      // 头部
      checkAll: false,
      searchName: "",
      // 列表
      dataSources: [],
      dataComponent: List,
      extraProps: {
        checkMap: {},
        dataLabel: this.dataLabel,
        dataValue: this.dataValue,
      },
      virtualKey: 0, // 用于强制更新视图
      // 其他
      checkMap: {},
      allCheckMap: {},
    };
  },
  mounted() {
    this.initData();
    // 订阅勾选事件
    this.$on("virtual-transfer-check-change", (key, val) => {
      this.checkMap[key] = val;
      this.extraProps["checkMap"] = this.checkMap;
      this.handleResult();
    });
  },
  watch: {
    basedata() {
      this.initData();
    },
  },
  computed: {
    checkNum() {
      let num = 0;
      for (let key in this.checkMap) {
        this.checkMap[key] ? num++ : "";
      }
      return `${num}/${this.basedata.length}`;
    },
  },
  methods: {
    // 初始化数据
    initData() {
      this.$nextTick(() => {
        this.checkAll = false;
        this.allCheckMap = {};
        this.checkMap = {};
        this.extraProps["checkMap"] = this.checkMap;
        this.basedata.forEach((res) => {
          this.allCheckMap[res[this.dataValue]] = true;
        });
        this.dataSources = this.basedata;
        this.virtualKey++;
      });
    },
    // 全选
    changeAll(val) {
      val
        ? (this.checkMap = JSON.parse(JSON.stringify(this.allCheckMap)))
        : (this.checkMap = {});
      this.extraProps["checkMap"] = this.checkMap;
      this.virtualKey++;
      this.handleResult();
    },
    // 搜索
    inputChange() {
      this.$nextTick(() => {
        this.checkAll = false;
        this.allCheckMap = {};
        // 正则匹配
        let arr = [];
        let searcReg = new RegExp(String(this.searchName));
        this.basedata.forEach((res) => {
          if (
            searcReg.test(String(res[this.dataLabel])) ||
            searcReg.test(String(res[this.dataValue]))
          ) {
            this.allCheckMap[res[this.dataValue]] = true;
            arr.push(res);
          }
        });
        this.dataSources = arr;
        this.virtualKey++;
      });
    },
    // 处理结果
    handleResult() {
      let arr = [];
      for (let key in this.checkMap) {
        this.checkMap[key] === true ? arr.push(key) : "";
      }
      this.$emit("check-change", arr);
    },
  },
};
</script>

<style lang="scss" scoped>
.check-root {
  border: 1px solid #ebeef5;
  .header {
    padding: 0 20px;
    height: 40px;
    line-height: 40px;
    background-color: #f5f7fa;
    display: flex;
    justify-content: space-between;
    .num {
      color: #909399;
    }
  }
  .search {
    padding: 5px 20px;
    ::v-deep .el-input__inner {
      border-radius: 10px;
    }
  }
  .main {
    background-color: #fff;
    padding: 0 10px;
    .check-virtual-list {
      height: 400px;
      overflow: auto;
      padding: 0 10px;
    }
  }
}
</style>

仓库源码

1人推荐
随时随地看视频
慕课网APP

热门评论

写的不错,赞一个

查看全部评论