vue轻松实现虚拟滚动的示例代码

前言

移动端网页的日常开发中,偶尔会包含一些渲染长列表的场景.比如某旅游网站需要完全展示出全国的城市列表,再有将所有通讯录的姓名按照A,B,C...首字母依次排序展示.

长列表的数量一般在几百条范围内不会出现意外的效果,浏览器本身足以支撑.可一旦数量级达到上千,页面渲染过程会出现明显的卡顿.数量突破上万甚至十几万时,网页可能直接崩溃了.

为了解决长列表造成的渲染压力,业界出现了相应的应对技术,即长列表的虚拟滚动.

虚拟滚动的本质,不管页面如何滑动,HTML 文档只渲染当前屏幕视口展现出来的少量Dom元素.

假设长列表有10万条数据,对用户而言,他永远只会看到屏幕展现出的那十几条数据.因此页面滑动时,通过监听滚动事件快速切换视口的数据,就能高度模拟滚动效果.

虚拟滚动最终只需要渲染少量的Dom元素就能模拟出相似的滚动效果,这让前端工程师开发几万甚至十几万条的长列表都成为了可能.

下图是手机上实测滑动一张涵盖全球所有城市的长列表页面(源代码贴在了文章结尾).

滚动原理

为了理解虚拟滚动的实现原理,首先观察下面图片.手指向下滑动时,HTML页面也会随之向上滚动.
通过图片标记的距离,我们可以得出这样的结论.当屏幕视口的上边沿和id为item的div元素上边沿重合时,item元素距离长列表顶部的距离刚好等于页面的滚动距离scrollTop(这个结论会在后面计算距离时用到).

虚拟滚动为了模拟出逼真的滚动效果,首先应该满足以下两个要求.

vue轻松实现虚拟滚动的示例代码_第1张图片

  • 虚拟滚动列表的滚动条和普通列表保持一致.比如列表包含1000条数据,当浏览器使用普通渲染的方式,假设滚动条需要向下滚动5000px才能贴底.那么应用虚拟滚动技术后,滚动条也应该保证具备相同的特征,向下滚动5000px才能贴底.
  • 虚拟滚动只会渲染视口以及上下两侧的部分Dom元素.随着滚动条往下滑动,视图的内容要实时更新,保证同普通渲染长列表时,看到的内容一致.

为了满足上面的要求,html设计结构如下.

.wrapper是最外层的容器元素,position设置成absolute或relative,子元素依据它做定位.

子元素.background和.list是实现虚拟滚动的关键..background是一个空的div,但它需要设置高度,高度值等于长列表所有列表项高度相加的总和.另外还要将其设置成绝对定位,z-index的值置为-1.

.list内部负责动态渲染视口观察到的Dom元素,position设置成absolute.



假如上面代码total_height等于10000px,页面运行效果图如下所示.
由于子元素.background设置了高度,父元素.wrapper就会被子元素支撑起来,同时会出现滚动条.
如果此时向下滑动,两个子元素.background和.list会同时向上滚动.当滚动距离达到了9324px,滚动条也抵达了底部.
这是因为父元素.wrapper本身高度为676px,加上滑动距离9324px,结果就刚好等于列表总高度10000px.
通过观察以上行为可知,.background虽然只是一个空的div,但是通过给它赋予列表的总高度,可以让右侧的滚动条和普通长列表渲染产生的滚动条保持外观和行为上一致.

vue轻松实现虚拟滚动的示例代码_第2张图片

滚动条的问题解决了,但随着滚动条往下滑,数据列表随之上移,列表全部移出了屏幕之后,接下来的滑动全是白屏.
为了解决白屏问题,视口必须始终展现出滑动的数据.那么.list元素要根据滑动的距离动态更新自身绝对定位的top值,这样就能确保.list不被划出屏幕之外.同时还要依据滑动的距离动态渲染当前视口应该展示的数据.

观察下面动效图,右侧Dom结构展示了滑动时的变化.

滚动条往下快速滑动后,列表的Dom元素快速渲染刷新.此时除了.list内部的Dom元素不断的更换,.list元素自身也在不断修改transform: translate3d(0, ? px ,0)样式值(修改translate3d能达到和修改top属性值相似的效果).

经过上面的讲解,虚拟滚动的实现逻辑已经清晰.首先js监听滚动条的滑动事件,再通过滑动距离计算出.list元素要渲染哪些子元素,其次更新.list元素位置.滚动条不断滑动时,子元素和位置也不断更新,视口上便模拟出了滚动效果.

实现

开发的Demo页面如下图所示.列表项包含了以下三种结构:

  • 小型列表项,城市首字母单独成一行,高度为50px;
  • 普通列表项,左侧英文名,右侧中文名,高度为100px;
  • 大型列表项,左侧英文名,中间中文名,右侧一张图片,高度为150px;

列表数据city_data的json结构类似如下,type为1代表采用小型列表项的样式结构渲染,2代表普通列表项,3代表大型列表项.

[{"name":"A","value":"","type":1},{"name":"Al l'Ayn","value":"艾因","type":2},{"name":"Aana","value":"阿纳","type":3} ... ]

vue轻松实现虚拟滚动的示例代码_第3张图片

city_data包含了长列表的所有数据,city_data获取后先遍历调整每一项的数据结构(代码如下).

通过以下方法处理,每一个列表项最终都包含一个top和height值.top表示该项距离长列表顶部的长度,而height值指该项的高度.
total_height即整个列表的总高度,最终要赋予上文提及的.background元素.处理完后的数据赋予this.list存储,并记录下最小列表项的高度this.min_height.

  mounted () {
     function getHeight (type) { // 根据 type 值返回高度
        switch (type) {
          case 1: return 50;
          case 2: return 100;
          case 3: return 150;
          default:
            return "";
        }
      }
      let total_height = 0;
      const list = city_data.map((data, index) => {
        const height = getHeight(data.type);
        const ob = {
          index,
          height,
          top: total_height,
          data
        }
        total_height += height;
        return ob;
      })
      this.total_height = total_height; //  列表总高度
      this.list = list;
      this.min_height = 50; // 最小高度是50
      //屏幕最大能容纳的列表项数量,containerHeight是父容器高度,按照最小高度来计算
      this.maxNum = Math.ceil(containerHeight / this.min_height);
  }

html根据type值渲染不同的样式结构(代码如下).父容器.wrapper绑定一个滑动事件onScroll,列表元素.list内部不是遍历this.list数组,因为this.list是原始数据,包含了所有的列表项.