Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

前端瓶子君,关注公众号

回复算法,加入前端编程面试算法每日一题群

前言

今年随着 Vue3 的成为正式版本,我们的 Element-plus 也有了稳定版,那今天我们主要是讲一个功能。我们先来看一下 Element-plus 新出现的一个玩意:

image.png

虚拟列表选择器?这是啥玩意,还能虚拟?

大家都知道 Vue 的虚拟 dom,我用简单的话讲述一下:大概就是一棵以 JavaScript 对象作为基础的树,每一个节点称为 VNode ,用对象属性来描述节点,实际上它是一层对真实 DOM 的抽象,最终可以通过渲染操作使这棵树映射到真实环境上。啥事抽象,我也不懂,就跟你问我啥是面向对象一样。

让我们来看一张皇帝选妃图:

image.png

这么多的妃子,皇帝肯定一下子不可能全看完,总的一排一排来。再次大殿上装下三千嫔妃,是不是很拥堵。

这就好比我们的页面,后端接口一下给了你 10 万条数据,如果你一次性渲染到 DOM 上,会出现很严重的卡顿问题,简称为页面阻塞,这里就暂不讲解 url 从输入到浏览器渲染的问题,留着下次跟大家分享。

那我们应该怎么解决这种既有 10 万数据,又可以渲染时不卡顿的现象?

这时候虚拟列表跑出来了,它说:哎,我可以,快用我,快用我,快用我,重要的事情说三遍。

那行呗,那咱就用他,本次呢,我用Vue2element Ui的提供的select组件来实现 Vue2版select虚拟列表选择器

思路

利用滚动事件,去计算可视窗口内的第一项和最后一项,利用总数据数组分割的方法slice截取到可视列表数据,计算每条数据的height(高度)和offset(距离顶部的位置)缓存到一个数组上来保存信息,利用每条高度求出总数据的应该有的高度listTotalHeight,最后利用相对定位和绝对定位的结合,使用transform控制translateY使可视列表位置保持在可视窗口,如下图:

image.png

实现

首先我们来实现一个纯页面输出的虚拟列表,根据上面的思路实现一下,基本组件select-v2.vue

<template>    <div        ref="wrapper"        @scroll="refreshView()"        style="width: 100%; height: 100%; overflow: auto; position: relative; margin: 0; padding: 0; border: none;"    >        <div :style="{ height: listTotalHeight + 'px' }" style="width: 100%; padding: 0; margin: 0;"></div>        <div            ref="item-wrapper"            style="position: absolute; top: 0; left: 0; width: 100%; padding: 0; margin: 0;"        >            <div v-for="(d) in listViewWithInfo" :key="d.index" :style="{ height: d.height + 'px' }">                <slot :item="d.item" :height="d.height" :offset="d.offset" :index="d.index"></slot>            </div>        </div>    </div></template><script>export default {    props: {        list: Array, // 列表数据        itemHeightGetter: Function, // 获取列表高度的函数        defaultItemHeight: Number, // 默认item高度    },    data() {        return {            listView: [], // 可视列表数据            listTotalHeight: 0, // 列表总高度            itemOffsetCache: [], // item信息缓存            topItemIndex: 0, // 可视窗口的第一项        };    },    computed: {        listViewWithInfo() { // 封装listView,提供index、height、offset数据            return this.listView.map((item, viewIndex) => {                const index = this.topItemIndex + viewIndex;                const { height, offset } = this.getItemInfo(index);                return {                    index,                    item,                    height,                    offset,                }            });        }    },    watch: {        list() {            this.refreshView();        },    },    mounted() {        this.refreshView({ resize: true });    },    methods: {        // 重渲染可视列表(可供组件外部调用)        refreshView(config) {            if (config) {                if (config.resize) { // 只有resize为true时对wrapper高度重新取值,减少DOM取值操作                    this._viewHeight = this.$refs.wrapper.clientHeight;                }                if (config.clearCache) { // 清空缓存                    this.itemOffsetCache = [];                }            }            const scrollTop = this.$refs.wrapper.scrollTop; // 当前scrollTop            const viewHeight = this._viewHeight; // 可视窗口高度            const topItemIndex = this.findItemIndexByOffset(scrollTop); // 可视窗口的第一项            const bottomItemIndex = this.findItemIndexByOffset(scrollTop + viewHeight); // 可视窗口的最后项            this.topItemIndex = topItemIndex;            this.listView = this.list.slice(topItemIndex, bottomItemIndex + 1); // 可视列表            // 列表总高度            // 若提供了默认item高度(defaultItemHeight),则高度 = 已计算item的高度总合 + 未计算item数 * 默认item高度;否则全部使用计算高度            // 这里已计算过的item会缓存,所有item只会计算一次            const listTotalHeight = this.defaultItemHeight                ? this.getItemInfo(this.itemOffsetCache.length - 1).offset + (this.list.length - this.itemOffsetCache.length) * this.defaultItemHeight                : this.getItemInfo(this.list.length - 1).offset;            this.listTotalHeight = listTotalHeight;            console.log(listTotalHeight)            this.$refs['item-wrapper'].style.transform = `translateY(${this.getItemInfo(topItemIndex - 1).offset}px)`; // 控制translateY使可视列表位置保持在可视窗口            // 对外抛出scroll事件            this.$emit('scroll', {                topItemIndex,                bottomItemIndex,                listTotalHeight,                scrollTop            });        },        // 根据offset获取item的在列表中的index        findItemIndexByOffset(offset) {            // 如果offset大于缓存数组的最后项,按序依次往后查找(调用getItemInfo的过程也会缓存数组)            if (offset >= this.getItemInfo(this.itemOffsetCache.length - 1).offset) {                for (let index = this.itemOffsetCache.length; index < this.list.length; index++) {                    if (this.getItemInfo(index).offset > offset) {                        return index;                    }                }                return this.list.length - 1;            } else { // 如果offset小于缓存数组的最后项,那么在缓存数组中二分法查找                let begin = 0;                let end = this.itemOffsetCache.length - 1;                while (begin < end) {                    let mid = (begin + end) / 2 | 0;                    let midOffset = this.getItemInfo(mid).offset;                    if (midOffset === offset) {                        return mid;                    } else if (midOffset > offset) {                        end = mid - 1;                    } else {                        begin = mid + 1;                    }                }                if (this.getItemInfo(begin).offset < offset && this.getItemInfo(begin + 1).offset > offset) {                    begin = begin + 1;                }                return begin;            }        },        // 获取item信息(有缓存则取缓存,无缓存则计算并缓存)        getItemInfo(index) {            // 超出取值范围,返回默认值            if (index < 0 || index > this.list.length - 1) {                return {                    offset: 0,                    height: 0,                };            }            let cache = this.itemOffsetCache[index];            // 如果没有缓存,进行计算并缓存结果            if (!cache) {                // 优先用itemHeightGetter计算高度,无itemHeightGetter则取defaultItemHeight作为高度                let height = (this.itemHeightGetter ? this.itemHeightGetter(this.list[index], index) : this.defaultItemHeight);                cache = this.itemOffsetCache[index] = {                    height, // item高度                    offset: this.getItemInfo(index - 1).offset + height, // 递归得出item的bottom距离列表顶部的距离,item的offset = 上个item的offset + 自己的height                };            }            // 如果已有缓存,直接返回缓存的结果            return cache;        },    },}</script>复制代码

使用select-v2.vue组件:

<template>  <div id="app">    <div class="m-container">      <div class="m-header">我是头部</div>      <div class="m-list">        <div class="m-list-container">          <select-v2            ref="list-view"            @scroll="listScroll"            :list="list"            :item-height-getter="itemHeightGetter"            :default-item-height="defaultItemHeight"          >            <div slot-scope="scope" class="item">              <div                :style="{ color: scope.item.color }"              >NO: {{ scope.item.no }}, height: {{ scope.height }}px, offset: {{ scope.offset }}px</div>            </div>          </select-v2>        </div>      </div>    </div>  </div></template><script>import selectv2 from './components/select-v2.vue'export default {  name: 'App',  components: {    'select-v2': selectv2,  },  data() {    return {      list: [],      page: 0,      itemHeightGetter(item) {        if (item.no % 33 === 0) {          return 100;        }        return 20 + item.no % 10;      },      defaultItemHeight: 30,    }  },  created() {    this.getData().then(d => {      this.list = d;    });  },  methods: {    listScroll(data) {      if (!this._getting && data.bottomItemIndex >= this.list.length - 3) {        this._getting = true;        this.getData().then(d => {          this.list.push(...d);          this.page++;          this._getting = false;        });      }    },    getData() {      return new Promise(resolve => {        setTimeout(() => {          const baseIndex = this.page * 2000;          resolve(new Array(2000).fill(0).map((i, index) => {            return {              no: baseIndex + index,              color: ['#33d', '#3d3', '#d33', '#333'][Math.random() * 4 | 0],            };          }));        }, 100);      })    },  },}</script><style lang="scss">html,body,#app {  margin: 0;  width: 100vw;  height: 100vh;  padding: 0;}.m-container {  width: 100%;  height: 100%;  display: flex;  flex-direction: column;  .m-header {    height: 40px;    background: greenyellow;  }  .m-list {    flex: 1;    position: relative;    .m-list-container {      position: absolute;      width: 100%;      height: 100%;      .item {        height: 100%;        display: flex;        align-items: center;      }    }  }}</style>复制代码

最后如果你的效果是这样的,那么说明你就成功了一大步了

cvb13-6ldc7.gif

代码中是有 2000 条的数据的,但是我们在渲染的时候刚好满足可视窗口高度的列表,因为代码中为了显示特别一点,还特意加了一个独特的高度的,所以在滚动的时候无法精确到每次都有一定的条数。item-height-getter可以通过这个来添加个别Item特别的高度。default-item-height这个可以设置 item 默认的高度,在没有设置item-height-getter情况下,就可以固定每次显示多少条,因为高度都是一样的,可视窗口就那么宽。有兴趣的朋友可以自己去试一下,这里就不做展示了。

但是有伙伴会说我不知道一个 item 的内容有多少字,可能会有很多的字,但是我又不想单独的设置item-height-getter,譬如我们将App.vue改写成这样:

...<select-v2  ref="list-view"  @scroll="listScroll"  :list="list"  :item-height-getter="itemHeightGetter"  :default-item-height="defaultItemHeight">  <div slot-scope="scope" class="item">    <div v-if="scope.item.no === 1">代码中是有2000条的数据的,但是我们在渲染的时候刚好满足可视窗口高度的列表,因为代码中为了显示特别一点,还特意加了一个独特的高度的,所以在滚动的时候无法精确到每次都有一定的条数。`item-height-getter`可以通过这个来添加个别`Item`特别的高度。`default-item-height`这个可以设置item默认的高度,在没有设置`item-height-getter`情况下,就可以固定每次显示多少条,因为高度都是一样的,可视窗口就那么宽。有兴趣的朋友可以自己去试一下,这里就不做展示了。</div>    <div    v-else      :style="{ color: scope.item.color }"    >NO: {{ scope.item.no }}, height: {{ scope.height }}px, offset: {{ scope.offset }}px</div>  </div> ...复制代码

页面就变成了这样:

image.png

我们可以将select-v2.vue改一个小地方就可以了:

...<div    ref="item-wrapper"    style="position: absolute; top: 0; left: 0; width: 100%; padding: 0; margin: 0;">    <div v-for="(d) in listViewWithInfo" :key="d.index" :style="{ 'min-height': d.height + 'px' }">        <slot :item="d.item" :height="d.height" :offset="d.offset" :index="d.index"></slot>    </div></div>...复制代码

细心的伙伴可能就已经发现了,就是将item的高度设置时,改为min-height即可, 页面就回归正常了,有兴趣了伙伴可以尝试一下:

image.png

现在我们已经完成了我们最基本的虚拟列表组件,但是我们最终的目标是:虚拟列表选择器

虚拟列表选择器

那来呗,我们先安装和引入 ElementUI:

npm i element-ui -S
复制代码
//在main.js引入import ElementUI from 'element-ui';import 'element-ui/lib/theme-chalk/index.css';Vue.use(ElementUI);复制代码

我们来将select-v2.vue组件改写:

<template>  <el-select    @change="handleChange"    :placeholder="placeholder"    clearable    filterable    remote    :remote-method="remoteMethod"    :loading="loading"    v-model="selectValue"    :popper-class="`m-el-select-v2 ${popperClass ? popperClass : ''}`"    @visible-change="visibleChange"  >    <div      v-if="isShow"      ref="wrapper"      class="m-virtual-wrapper"      @scroll="refreshView()"      style="        width: 100%;        height: 100%;        overflow: auto;        position: relative;        margin: 0;        padding: 0;        border: none;      "    >      <div        :style="{ height: listTotalHeight + 'px' }"        ref="listTotalHeightRef"        style="width: 100%; padding: 0; margin: 0"      ></div>      <div        ref="item-wrapper"        style="position: absolute; top: 0; left: 0; width: 100%; padding: 0; margin: 0"      >        <div v-for="d in listViewWithInfo" :key="d.index" :style="{ height: d.height + 'px' }">          <slot :item="d.item" :height="d.height" :offset="d.offset" :index="d.index">            <el-option :label="d.item.label" :value="d.item.value"> </el-option>          </slot>        </div>      </div>    </div>  </el-select></template><script>export default {  name: "m-select-v2",  props: {    list: Array, // 列表数据    itemHeightGetter: Function, // 获取列表高度的函数    defaultItemHeight: {      type: Number,      default: 45,    }, // 默认item高度    placeholder: {      type: String,      default: "请选择",    },    popperClass: String,    value: [String, Number],  },  data() {    return {      listView: [], // 可视列表数据      listTotalHeight: 0, // 列表总高度      itemOffsetCache: [], // item信息缓存      topItemIndex: 0, // 可视窗口的第一项      isShow: true,//模糊搜索筛选时,会出现偏移出错问题,添加这个重新渲染解决问题      loading: false,      allList: [],      //   selectValue:""    };  },  computed: {    selectValue: {      get() {        return this.value;      },      set(newValue) {        return newValue;      },    },    listViewWithInfo() {      // 封装listView,提供index、height、offset数据      return this.listView.map((item, viewIndex) => {        const index = this.topItemIndex + viewIndex;        const { height, offset } = this.getItemInfo(index);        return {          index,          item,          height,          offset,        };      });    },  },  watch: {    list() {      this.allList = this.list;      //   console.log("传进来的数据列表",this.list)      this.refreshView();    },  },  mounted() {    // console.log("我进来了", this.list);    this.refreshView({ resize: true });  },  methods: {    handleChange(val) {      this.$emit("input", val);      this.$emit("change", val);    },    visibleChange(status) {      console.log(status);      this.allList = this.list;      this.refreshView({ clearCache: true });      this.$emit("visible-change", status);    },    remoteMethod(query) {      //   console.log("我进来了", query);      if (query.trim() !== "") {        this.loading = true;        this.isShow = false;        setTimeout(() => {          this.loading = false;          this.isShow = true;          //   console.log("搜索输的值",this.list)          // this.$emit('filter',query)          var list = this.list.filter((item) => {            return item.label && item.label.indexOf(query) > -1;          });          //   console.log("搜索输的值", list);          this.allList = list;          this.$nextTick(() => {            this.refreshView({ clearCache: true });          });        }, 200);      } else {        this.allList = this.list;        this.refreshView({ clearCache: true });      }    },    // 重渲染可视列表(可供组件外部调用)    refreshView(config) {      //   console.log("滚动了吗");      if (config) {        if (config.resize) {          // 只有resize为true时对wrapper高度重新取值,减少DOM取值操作          this._viewHeight = this.$refs.wrapper.clientHeight;        }        if (config.clearCache) {          // 清空缓存          this.itemOffsetCache = [];        }      }      //   console.log("当前scrollTop",this.$refs.wrapper.scrollTop)      const scrollTop = this.$refs.wrapper.scrollTop; // 当前scrollTop      const viewHeight = this._viewHeight || 274; // 可视窗口高度      const topItemIndex = this.findItemIndexByOffset(scrollTop); // 可视窗口的第一项      const bottomItemIndex = this.findItemIndexByOffset(scrollTop + viewHeight); // 可视窗口的最后项      this.topItemIndex = topItemIndex;      this.listView = this.allList.slice(topItemIndex, bottomItemIndex + 1); // 可视列表      // 列表总高度      // 若提供了默认item高度(defaultItemHeight),则高度 = 已计算item的高度总合 + 未计算item数 * 默认item高度;否则全部使用计算高度      // 这里已计算过的item会缓存,所有item只会计算一次      const listTotalHeight = this.defaultItemHeight        ? this.getItemInfo(this.itemOffsetCache.length - 1).offset +          (this.allList.length - this.itemOffsetCache.length) * this.defaultItemHeight        : this.getItemInfo(this.allList.length - 1).offset;      this.listTotalHeight = listTotalHeight;      this.$refs["item-wrapper"].style.transform = `translateY(${        this.getItemInfo(topItemIndex - 1).offset      }px)`;      console.log(this.scrollTop, scrollTop, topItemIndex, bottomItemIndex);      // 对外抛出scroll事件      this.$emit("scroll", {        topItemIndex,        bottomItemIndex,        listTotalHeight,        scrollTop,      });      this.$forceUpdate();    },    // 根据offset获取item的在列表中的index    findItemIndexByOffset(offset) {      // 如果offset大于缓存数组的最后项,按序依次往后查找(调用getItemInfo的过程也会缓存数组)      if (offset >= this.getItemInfo(this.itemOffsetCache.length - 1).offset) {        for (let index = this.itemOffsetCache.length; index < this.allList.length; index++) {          if (this.getItemInfo(index).offset > offset) {            return index;          }        }        return this.allList.length - 1;      } else {        // 如果offset小于缓存数组的最后项,那么在缓存数组中二分法查找        let begin = 0;        let end = this.itemOffsetCache.length - 1;        while (begin < end) {          let mid = ((begin + end) / 2) | 0;          let midOffset = this.getItemInfo(mid).offset;          if (midOffset === offset) {            return mid;          } else if (midOffset > offset) {            end = mid - 1;          } else {            begin = mid + 1;          }        }        if (          this.getItemInfo(begin).offset < offset &&          this.getItemInfo(begin + 1).offset > offset        ) {          begin = begin + 1;        }        return begin;      }    },    // 获取item信息(有缓存则取缓存,无缓存则计算并缓存)    getItemInfo(index) {      // 超出取值范围,返回默认值      if (index < 0 || index > this.allList.length - 1) {        return {          offset: 0,          height: 0,        };      }      let cache = this.itemOffsetCache[index];      // 如果没有缓存,进行计算并缓存结果      if (!cache) {        // 优先用itemHeightGetter计算高度,无itemHeightGetter则取defaultItemHeight作为高度        let height = this.itemHeightGetter          ? this.itemHeightGetter(this.allList[index], index)          : this.defaultItemHeight;        cache = this.itemOffsetCache[index] = {          height, // item高度          offset: this.getItemInfo(index - 1).offset + height, // 递归得出item的bottom距离列表顶部的距离,item的offset = 上个item的offset + 自己的height        };      }      // 如果已有缓存,直接返回缓存的结果      return cache;    },  },};</script><style lang="scss">.m-el-select-v2 {  .el-select-dropdown__wrap {    overflow: hidden;    margin-bottom: 0px !important;    margin-right: 0px !important;    .el-select-dropdown__list {      width: 100%;      height: 274px;      overflow: hidden;      .m-virtual-wrapper {        &::-webkit-scrollbar {          width: 6px;        }        &::-webkit-scrollbar-thumb {          background-color: #a1a3a9;          border-radius: 3px;        }        &::-webkit-scrollbar-track {          // background: #f5f7fa;          background: transparent;        }        &::-webkit-scrollbar-corner {          background: #f5f7fa;        }      }    }  }}</style>复制代码

其中默认添加了 elementUI 组件提供的属性,其中的功能主要有: 清空、筛选(远程搜索的相应事件修改过来的)、插槽(可以修改选择列表中展示的内容)、滚动事件回调可以方便你增加页面去请求数据。功能都就可以根据自己的需要做出增加和修改。

这里我就举一个插槽的效果:

//App.vue//......其他的就省略不写了,因为都一样<select-v2 v-model="selectValue" @scroll="listScroll" :list="list" :itemHeightGetter="itemHeightGetter" :default-item-height="defaultItemHeight">  <template #default="{ item }">    <el-option :label="item.label" :value="item.value">      <span>{{ item.label }}({{ item.no }})</span>    </el-option>  </template></select-v2>//......其他的就省略不写了,因为都一样复制代码

最终效果:

f5mhw-b92ki.gif

好了,最终的就这样完成了。 但是这个做的过程中我发现一个问题,我用来最不好的办法去解决的,我把问题告诉伙伴们,伙伴们可以自己感受一下,可以自己琢磨一下。

问题:当模糊搜索的时候,我们肯定是要从搜索到的结果的第一项去展示出来的,但是事实并不是这样的,可以看下面效果图:

f9wwp-7e7wb.gif

通过上面效果图可以得知设置的translateY 偏移量去展示的已经归零了,但是我们滚动上去的部分就回不来了。这是为什么呢?这个问题有小伙伴们去思考吧。

我的解决方案就是:通过isShow重新渲染。可以没有上面的问题了。

总结

一张图诠释虚拟列表所有的想法

20220228_pic_0.gif

关于本文

来源:前端周星星

https://juejin.cn/post/7069681651789332493

最后

欢迎关注【前端瓶子君】✿✿ヽ (°▽°) ノ✿

回复「算法」,加入前端编程源码算法群,每日一道面试题(工作日),第二天瓶子君都会很认真的解答哟!

回复「交流」,吹吹水、聊聊技术、吐吐槽!

回复「阅读」,每日刷刷高质量好文!

如果这篇文章对你有帮助,「在看」是最大的支持

》》面试官也在看的算法资料《《

“在看和转发” 就是最大的支持