性能优化 | AntDesign Vue Select

8,537 阅读4分钟

组件现状

ant-design-vue 版本的select组件。本身并没有优化性能问题。这样,如果选项过多,一次渲染太多节点,页面就会炸掉。

处理方案

**(只想看最终方案请跳过此段)**而现在比较流行的性能处理方案,主要是虚拟滚动,仅渲染列表视域内的视图。虚拟滚动可以自己写也有成熟的插件可以使用。只是不巧的是,ant-design-vue 版本的select组件,内部结构并不支持现在流行的虚拟滚动插件vue-virtual-scroller,即便它隔壁的list组件都完美支持了vue-virtual-scroller,demo还被写在了官方示例上。

**(只想看最终方案请跳过此段)**以下就是list组件的demo,a-lista-list-item组件中间可以嵌入RecycleScroller来管理a-list-item的虚拟滚动。但是a-selecta-select-option中间并不能嵌入RecycleScrollera-select-option必须是a-select的子组件,否则会导致内置功能报错。

<a-list>
  <RecycleScroller
    v-infinite-scroll="handleInfiniteOnLoad"
    style="height: 400px"
    :items="data"
    :item-size="73"
    key-field="email"
    :infinite-scroll-disabled="busy"
    :infinite-scroll-distance="10"
  >
    <a-list-item slot-scope="{ item }">
      <a-list-item-meta :description="item.email">
        <a slot="title" :href="item.href">{{ item.name.last }}</a>
        <a-avatar
          slot="avatar"
          src="https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png"
        />
      </a-list-item-meta>
      <div>Content {{ item.index }}</div>
    </a-list-item>
  </RecycleScroller>
  <a-spin v-if="loading" class="demo-loading" />
</a-list>

**(只想看最终方案请跳过此段)**而自定义下拉框内容的APIdropdownRender也并不能自定义下拉列表的选项,他只能在下拉框的下拉区域将a-select-option的列表视为一个整体,在列表外添加一些类似于添加选项的额外功能按钮。

**(只想看最终方案请跳过此段)**如下图,含有滚动条在内,a-select-option的列表是一个整体,这个地方是没有办法重写的,这个API只能再在列表外添加类似于Add item这样的按钮。

于是我们最终决定,基于antdvselect组件,自己实现简单的虚拟滚动视图。

现在的虚拟滚动方案一般有两种:

一种是vue-virtual-scroller的实现方式,他完美的实现了超长列表的视图效果,包括滚动条的位置和大小,都与超长列表完全一致。

本文并没有采用这种实现方式,主要因为这种方式需要很多定制化处理,而ant-design-vue 版本的select比较难结合这些处理。考虑开发成本,我们选择了第二种。

第二种实现也达到了只显示滑动区域数据并无缝滚动的目的,唯一的不足是,这种方案没有实现滚动条位置的一致,即使有3万条数据,滑动的时候,滚动条还是会站到总体长度的一小半。

这主要因为该方案并没有模拟列表的长度,但也因此开发起来更加简易快速。 他不需要更加定制化的下拉框,更精细的操控。只需要简单的改变下拉框数据选项的数据,以及滚动条的位置即可。

代码实现

select.vue

组件属性

a-select(
    placeholder="Please select",
    :show-search="true",
    show-arrow,
    :options="selectOption",
    @search="handleSearch"
    @popupScroll="handleScroll"
    @dropdownVisibleChange="handleDropdown"
)

实现逻辑
1、【限制仅渲染视域内的数据条数】设定一个固定数量,如30条数据,最多只渲染30条数据。
2、【标记渲染数据的开始结束位置】设定一个开始结束标记(start,end),标记渲染的数据,从第几条开始,到第几条结束。end由start得出,start默认为0.
3、【根据滚动改变开始结束位置】选项列表滚动时,根据滚动的方向,当快滚动到底部或者顶部的时候,改变开始结束标记。(例如:向下滚动到一定位置,后面还有10条以上数据,则开始结束标记+10)
4、【调整滚动条位置】展示出的列表数据改变时,切换开始结束标记位置时,列表的滚动位置会改变,需要手动处理滚动位置到数据变动触发的原衔接处。
上滚、上滚到顶部、下滚,位置跳动的表现都不一样,具体处理参照代码中的计算方式。至于为什么会出现这样的情况,我目前解释不太清楚,完全是实践出的算法逻辑。如果有哪位大佬清楚麻烦分享一下!
- 以下gap代表此次滚动,数据变动的条数
- 32代表一条数据所占高度
下滚:
e.target.scrollTop -= 32 * gap;
上滚未一次滚到顶部:
// 不处理
上滚一次滚到顶部:
e.target.scrollTop += 32 * gap / 2;
5、如果不单独处理scrollTop = 0,会出现滚一半滚不动的bug。
6、处理滚动位置后,还会触发原生的滚动逻辑,若想获得最终滚动位置并记录,请记得$nextTick
7、处理滚动,可以使用select组件提供的popupScroll事件。处理后,需要再自定义搜索方法search。
8、demo中,itemObj是原始数据源,filterItemObj是搜索后的数据源,selectOption是最终显示出来的视域内的数据源。
具体代码

data() {
    return {
        start: 0,
        isScrolling: false,
        scrollLoc: 0,
        keyword: '',
        itemObj: [{
        	key: 'xxx', 
            value: 'xxx', 
            label: 'name'
        },
        ...
        ]
    };
},
computed: {
	filterItemObj() {
        return this.itemObj.filter(item => item.label.includes(this.keyword));
    },
	showLimit() {
        return this.filterItemObj.length > 30 ? 30 : this.filterItemObj.length;
    },
    end() {
        return (this.start + this.showLimit) > this.filterItemObj.length ? this.filterItemObj.length : (this.start + this.showLimit);
    },
    selectOption() {
        return this.filterItemObj.slice(this.start, this.end);
    },
},
methods: {
	handleSearch(e) {
        this.keyword = e;
    },
    async handleScroll(e) {
        if (this.isScrolling) return;
        this.isScrolling = true;
        let scrollTop = e.target.scrollTop;
        let scrollHeight = e.target.scrollHeight;
        let clientHeight = e.target.clientHeight;
        let scrollBottom = scrollHeight - clientHeight - scrollTop;

        if (scrollTop > this.scrollLoc) {
            if (scrollBottom <= 4 * 32) {
                let oldStart = this.start;
                this.start += 10;
                this.start = this.start > this.filterItemObj.length - this.showLimit ? this.filterItemObj.length - this.showLimit : this.start;
                let gap = this.start - oldStart;
                e.target.scrollTop -= 32 * gap;
            }
        } else if (scrollTop < this.scrollLoc) {
            if (scrollTop <= 4 * 32) {
                let oldStart = this.start;
                this.start -= 10;
                this.start = this.start > 0 ? this.start : 0;
                let gap = oldStart - this.start;
                if (scrollTop === 0) e.target.scrollTop += 32 * gap / 2;
            }
        }
		
        await this.$nextTick();

        this.scrollLoc = e.target.scrollTop;
        this.isScrolling = false;
    },
    handleDropdown(e) {
        this.keyword = '';
    },
}