长列表优化之 --「虚拟滚动」

2,878 阅读4分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

前言

对于前端页面来说,用户体验永远是排在第一位的,只有用户体验提升了,别人才会愿意去使用、去推荐你的产品。而 长列表 就是必不可少的优化项目之一。通常,像表格之类的长列表项,都是通过分页来提升页面速度。对于下拉选项那种情况,虽然也可以通过前端监听滚动条分片加载,但加载多了之后还是避免不了出现卡顿等情况。今天文章就是要介绍另外一种丝滑般的体验 -- 虚拟滚动列表

正题

首先话不多说,先上效果对比图。这里是使用了 vue-virtual-scroll-list 这个库来优化,并使用 element 中的穿梭框组件 transfer 作为例子,也是我实际项目中遇到的问题。看图:

优化之前: (大概生成了7000条数据,从动图中可以看到,无论是进行过滤或者是勾选复选框,响应速度极其慢,滚动也不是很流畅)

动画.gif

优化之后:(可以看到,优化之后,无论是搜索还是复选框都响应都非常快,滚动更是吃了德芙一般丝滑)

动画1.gif

原理探究

虚拟滚动这个词听着有点高大上,其实原理并不复杂。首先我们知道,造成页面卡顿响应慢的原因是页面上一次性渲染了太多的 DOM 元素。所以只需要减少页面上的渲染的元素不就行了,那该怎么减少呢?

虽然后端接口返回的数据可能有成千上万条,但最终呈现给用户,用户所看到的就只是容器内的一部分数据,仔细想想是不是可以只渲染用户看到的部分,其他还没滚动到的数据就不渲染了? 虚拟滚动列表 就是这样一个原理。看图:

image.png

假设知道每个列表项的高度,我们就可以很容易计算出可滚动的区域有多大,当用户开始滚动时,监听滚动事件,获取滚动的值 scrollTop,根据每个列表项的高度就可以计算出当前应该展示数据项的范围,并且根据这个范围计算出 paddingToppaddingBottom ,使滚动条处于一个正确的位置。

改造穿梭框

接下来还是以开头那个为例子,el-transfer 配合 vue-virtual-scroll-list 来达到优化长列表的目的。我这边是直接复制了 transfer 组件出来稍微改造下成为一个新的组件。

先看下 vue-virtual-scroll-list 的使用方法

// 根组件
<virtual-list style="height: 360px; overflow-y: auto;" // make list scrollable
  :data-key="'uid'"
  :data-sources="items"
  :data-component="itemComponent"
/>

<script>
  import Item from './Item'
  import VirtualList from 'vue-virtual-scroll-list'

  export default {
    name: 'root',
    data () {
      return {
        itemComponent: Item,
        items: [{uid: 'unique_1', text: 'abc'}, {uid: 'unique_2', text: 'xyz'}, ...]
      }
    },
    components: { 'virtual-list': VirtualList }
  }
</script>
// 渲染的每一项的item组件

<template>
  <div>{{ index }} - {{ source.text }}</div>
</template>

<script>
  export default {
    name: 'item-component',
    props: {
      index: { // index of current item
        type: Number
      },
      source: { // here is: {uid: 'unique_1', text: 'abc'}
        type: Object,
        default () {
          return {}
        }
      }
    }
  }
</script>

可以看出, data-component 需要传递一个组件,来制定你每一项应该怎么渲染,下面开始改造。

下图是改造后的组件,主要是修改了 transfer-panel 里面的内容以及新增了 item 组件,这个 item 组件就是待会要传给 data-component

image.png

首先看下 transfer-panel 文件,主要改动 27~40 行:

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

// 修改成如下
<el-checkbox-group
    v-show="!hasNoMatch && data.length > 0"
    v-model="checked"
    :class="{ 'is-filterable': filterable }"
    class="el-transfer-panel__list"
>
    <virtual-list
      :data-key="keyProp"
      :data-sources="filteredData"
      :data-component="itemComponent"
      :extra-props="{ showOverflowTooltip: showOverflowTooltip }"
      style="height: 100%; overflow-y: auto"
    />
</el-checkbox-group>

把中间的渲染的项目替换成我们 virtual-list 组件,由它来控制,接着看下 item 文件里的内容,其实不多,只是把原来代码中的 optionContent 搬了过来。

// item
<script>
export default {
  name: 'ItemDetail',
  components: {
    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: {
    source: { // source是 vue-virtual-scroll-list 自动传给每项组件的
      type: Object,
      default() {
        return {}
      }
    }
  },
  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)
      return (
        <el-checkbox
          label={this.source[panel.keyProp]}
          class="el-transfer-panel__item"
        >
          <option-content option={this.source}></option-content>
        </el-checkbox>
      )
}
</script>

这里看源码的时候学习到了个小技巧,有些可能局部特殊用的小组件,可以直接写在 compoments 里面,不同于模板文件的是,要用 jsx 来渲染。

还有一个 getParent 方法,可以从本组件位置开始,往上找到指定组件名的父组件,就可以取到父组件里面相关的一些值来用。

以上就是将 el-transfer 穿梭框改造成支持虚拟列表滚动的过程,建议大家可以试试。

拓展

有时在想,有没有一种 css 属性可以直接做到这种效果呢,那会方便很多。其实,还真有。在 Chromium 85 中,增加了 content-visibility 属性,可跳过不在屏幕上的内容渲染,包括布局和渲染,直到真正需要布局渲染的时候为止。所以利用它可以使初始用户加载速度更快,还能与屏幕上的内容进行更快的交互。但是,兼容性,你们懂的 = =。

感兴趣的可以去了解了解这个属性