新手也能看懂的虚拟滚动实现方法

60,433 阅读7分钟

本篇文章致力于小白也能懂的虚拟滚动实现原理,一步一步深入比较以及优化实现方案,内容浅显易懂,但篇幅可能较长。 如果你只想了解实现思路,那么可以直接看图或者跳到文章最后。

话不多说,直接开始好吧。

为什么需要虚拟滚动

想像一下,当你有10万数据需要展示的时候,咋办呢?我们来试一下将它全部加载出来。 我们再用chrome的性能功能测试一下,得到下图:

截屏20200604 下午5.35.48.png

渲染时长长达4.5s,DOM节点有55万个!!!虽然在chrome中的滚动性能还可以,应该是有做过优化。但是safari上直接打不开。

实事告诉我们暴力做法是行不通的,我们需要其他方式,也有人说了我们可以采用分页的方法。但是某些情景下(或者某些产品经理的压迫下),比如联系人列表,聊天列表,我们还是需要采用滚动的交互方式。

而且就算是正常大小的列表(几百或几千),使用虚拟滚动对于页面性能的提升也是可以感知的。

而且在谷歌的Lighthouse开发推荐中有写到:

  • Have more than 1,500 nodes total.
  • Have a depth greater than 32 nodes.
  • Have a parent node with more than 60 child nodes.

这里表示DOM节点过多会影响页面性能,并且给出了推荐的最多节点数量。

基本思想

好了,在确定了我们需要优化长列表的渲染性能之后,那么接下来就是,怎么做呢?

从上面我们测试的例子来看,长列表渲染过程中耗时最长的就是Rendering,浏览器渲染这一部分。而且我们也看到了,它会生成万级的DOM节点。这些都是导致性能变差的主要原因。如果我们能让浏览器渲染时长降到ms级,那肯定会流畅很多。而如何降低这一耗时呢?答案就是让浏览器只渲染看的见得DOM节点。

按需渲染

我们的数据量很庞大,但是我们同一时间能看见的却只有那么十几二十几个,那么干嘛要全部一次性都渲染出来呢是吧。当我们同一时间只渲染我们看的见的这些DOM节点的时候,浏览器需要渲染的节点就会非常非常少了,这会极大的降低渲染时长!

xxxr.jpg

如上图,我们只渲染可视区域能见到的3,4,5,6这几个元素,而其他的都不会被渲染。

模拟滚动

我们只渲染能看见的元素,这就意味着我们没有原生滚动的功能。我们需要去模拟滚动行为,在用户滚动滑轮或者滑动屏幕时,相应的滚动列表。我们这里的滚动列表不是真正的滚动列表,而是根据滚动的位置重新渲染可见的列表元素。

当这个操作时间跨度足够小时,它看起来就像是在滚动一样。

mngd.jpg

这有点像我们在画帧动画一样,每次用户滑动造成偏移量改变,我们都会根据这个偏移量去渲染新的列表元素。就像是在一帧一帧的播放动画一样,当两帧间隔足够小时,动画看起来就会很流畅,就像是在滚动一样。

代码实现

没错,上面这两个就能基本实现长列表的按需显示以及滚动功能,话不多说我们直接来实现一下。 首先我们来看看我们有什么:一个存放列表的父元素(视口元素),一个列表数组。

然后我们来实现第一个思路:按需渲染。我们只渲染视口能看见的元素,这里有几个问题:

  1. 视口能渲染几个列表元素? 视口的高度我们已经知道了(父元素的高度),假设偏移量为0,我们从第一个元素开始渲染,那么它能装几个列表元素呢?这里就需要我们给每一个列表元素设置一个高度。通过累加高度计算找到第一个加完它的高度后总高度超出视口高度的列表元素。 未命名作品 3.jpg 由上图我们可以看见,假如每个元素都是30px,视口高度为100px,那么通过累加计算,我们可以知道视口最多能看到第四个元素。

  2. 怎么知道该渲染哪几个元素? 当用户没有滚动时,偏移量为0,我们知道从第一个元素开始渲染。那么假如当用户累计滚动了x像素后,又该从哪个元素开始渲染呢? 未命名作品 2.jpg 我们要做的第一件事是记录用户操作的列表的滚动总距离virtualOffset,然后我们通过从第一个元素累加高度得到heightSum,当heightSumvirtualOffset大时,最后一个累加高度的元素,就是视口需要渲染的第一个元素!图中我们看到第一个元素是3。 并且!你可以从图中看到,3并不是完整可见的,他向上偏移了一段距离,我们称其为renderOffset。其计算公式为:renderOffset = virtualOffset - (heightSum - 元素3的高度)。从这里看出我们需要一个元素包裹住列表元素,以便整体偏移。 再根据第1个问题,我们知道我们需要渲染的是3,4,5,6,这里需要注意的是计算的时候要减去renderOffset

  3. 列表元素咋渲染成我想要的? 对于每一个列表元素,我们调用一个itemElementGenerator函数来创建DOM,它接受对应的列表项作为参数,返回一个DOM元素。该DOM元素会被作为列表元素加载到视口元素中。

OK,让我们直接敲代码吧!

1. 构造函数,我们先确定我们需要的参数。

class VirtualScroll {
  constructor({ el, list, itemElementGenerator, itemHeight }) {
    this.$list = el // 视口元素
    this.list = list // 需要展示的列表数据
    this.itemHeight = itemHeight // 每个列表元素的高度
    this.itemElementGenerator = itemElementGenerator // 列表元素的DOM生成器
  }
}

为了方便,这里我们假设每个元素的高度都是一样的。当然也可以每个都不一样。 接下来我们需要做一些初始化的操作。

2. 初始化操作

class VirtualScroll {
  constructor({ el, list, itemElementGenerator, itemHeight }) {
    // ...
    this.mapList()
    this.initContainer()
  }
  initContainer() {
    this.containerHeight = this.$list.clientHeight
    this.$list.style.overflow = "hidden"
  }
  mapList() {
    this._list = this.list.map((item, i) => ({
      height: this.itemHeight,
      index: i,
      item: item,
    }))
  }
}

我们记录视口元素的高度,然后将传入的列表数据转化为方便我们计算的数据结构。

3. 监听事件

class VirtualScroll {
  constructor(/* ... */) {
    // ...
    this.bindEvents()
  }
  bindEvents() {
    let y = 0
    const updateOffset = (e) => {
      e.preventDefault()
      y += e.deltaY
    }
    this.$list.addEventListener("wheel", updateOffset)
  }
}

我们监听视口的滚轮事件,该事件对象有一个属性叫做deltaY,记录的是滚轮滚动的方向以及滚动量。向下为正,向上为负。

4. 渲染列表

class VirtualScroll {
  render(virtualOffset) {
    const headIndex = findIndexOverHeight(this._list, virtualOffset)
    const tailIndex = findIndexOverHeight(this._list, virtualOffset + this.containerHeight)

    this.renderOffset = offset - sumHeight(this._list, 0, headIndex)

    this.renderList = this._list.slice(headIndex, tailIndex + 1)

    const $listWp = document.createElement("div")
    this.renderList.forEach((item) => {
      const $el = this.itemElementGenerator(item)
      $listWp.appendChild($el)
    })
    $listWp.style.transform = `translateY(-${this.renderOffset}px)`
    this.$list.innerHTML = ''
    this.$list.appendChild($listWp)
  }
}
// 找到第一个累加高度大于指定高度的序号
export function findIndexOverHeight(list, offset) {
  let currentHeight = 0
  for (let i = 0; i < list.length; i++) {
    const { height } = list[i]
    currentHeight += height

    if (currentHeight > offset) {
      return i
    }
  }

  return list.length - 1
}

// 获取列表中某一段的累加高度
export function sumHeight(list, start = 0, end = list.length) {
  let height = 0
  for (let i = start; i < end; i++) {
    height += list[i].height
  }

  return height
}

这里我们的渲染方法主要依赖于用户的总滚动量virtualOffset,每一个virtualOffset都对应着一个固定的渲染帧。 我们先计算出可视的子列表,再计算出偏移量。最后根据该子列表生成DOM,替换掉视口元素中的DOM。

5. 视图更新

滚动记录以及渲染方法都已经实现,那么最后一步就很简单了,就是在滚动记录变更时执行渲染方法。

class VirtualScroll {
  constructor(/* ... */) {
    // ...
    this._virtualOffset = 0
    this.virtualOffset = this._virtualOffset
  }
  set virtualOffset(val) {
    this._virtualOffset = val
    this.render(val)
  }
  get virtualOffset() {
    return this._virtualOffset
  }
  initContainer($list) {
    // ...
+   this.contentHeight = sumHeight(this._list)
  }
  bindEvents() {
    let y = 0
+   const scrollSpace = this.contentHeight - this.containerHeight
    const updateOffset = (e) => {
      e.preventDefault()
      y += e.deltaY
+     y = Math.max(y, 0)
+     y = Math.min(y, scrollSpace)
+     this.virtualOffset = y
    }
    this.$list.addEventListener("wheel", updateOffset)
  }
}

OK,到这里,我们的虚拟滚动就已经实现了基础功能。谢谢大家观看,下一篇再见👋!

性能测试

首先我们来看看基础功能究竟有没有解决大数据量加载问题。

截屏20200608 下午4.36.59.png

我们再次使用Chrome性能页面测试了一下。就俩字儿丝滑!从图中我们可以看出渲染耗时从原来的4.5s降到了5ms!!! 接着我们用Safari打开试试,成功!对比原来,10万的数据量,Safari可是打不开的啊。

当然了,这只是初始渲染,考虑到我们的渲染帧做法,在滚动的时候一定有性能问题。

截屏20200608 下午5.25.45.png

我们持续滚动10秒后测试其滚动性能,发现其脚本执行时间过长,达到了40%。渲染/绘图耗时也显著增加。但是有个好处就是,在消耗这么多资源的情况下,页面FPS确实还不错,在基本50-70之间,使得画面没有卡顿现象,十分的流畅。

性能优化

在了解了滚动时性能后,我想你也知道问题所在。我们在每次触发wheel事件时都会重新渲染整个列表。并且wheel在触摸板上触发的频率是相当的高!

所以我们来看看怎么来优化一下这些问题。

  1. 首先事件触发频率,我们需要做一下节流。
  2. 每次滚动都要重新渲染,我们需要控制一下这个重新渲染的频率,消耗太高了。

事件节流

简单点来说就是降低事件触发导致的函数调用频率。当然,这里我们只对消耗高的函数做节流。

class VirtualScroll {
   bindEvents() {
    let y = 0
    const scrollSpace = this.contentHeight - this.containerHeight
    const recordOffset = (e) => {
      e.preventDefault()
      y += e.deltaY
      y = Math.max(y, 0)
      y = Math.min(y, scrollSpace)
    }
    const updateOffset = () => {
      this.virtualOffset = y
    }
    const _updateOffset = throttle(updateOffset, 16)

    this.$list.addEventListener("wheel", recordOffset)
    this.$list.addEventListener("wheel", _updateOffset)
  }
}

可以看到我们将更新virtualOffset的操作剥离了出来,因为它会涉及到render操作。但是记录偏移量我们可以一直触发。 所以我们把更新virtualOffset的操作频率通过节流函数throttle降低了。

当我们将间隔设为16ms的时候,再一次进行测试,得到了以下结果:

截屏20200608 下午6.19.27.png

可以发现脚本执行耗时减少了一半,渲染/重绘时长也相应的减少了。可以看到效果十分明显,但是,页面的FPS降到了30左右,页面的滚动流畅度就没有那么丝滑了。但是也是没有明显卡顿的。

列表缓存

就算我们将事件的触发频率减少了,但是保证滚动流畅的情况下这个渲染间隔还是太太太太短了。那么怎么把渲染间隔变长呢?也就是说在两次重新渲染之间,不用重新渲染也能满足用户的滚动需求。

解决方法就是我们在可视元素列表前后预先多渲染几个列表元素。这样我们在少量滚动时可以偏移这些已渲染的元素而不是重新渲染,当滚动量超过缓存元素时,再进行重新渲染。

比起重新渲染,修改列表的样式属性消耗就小多了。

virtualscroll1.jpg

粉色区域就是我们的缓存区,在这个区域滚动时我们只需要改动列表的translateY就好了。注意这里我们不用ymargin-top两个属性,因为transform拥有更好的动画体验。

class VirtualScroll {
  constructor(/* ... */) {
    // ...
    this.cacheCount = 10
    this.renderListWithCache = []
  }
  render(virtualOffset) {
    const headIndex = findIndexOverHeight(this._list, virtualOffset)
    const tailIndex = findIndexOverHeight(this._list, virtualOffset + this.containerHeight)

    let renderOffset
    
    // 当前滚动距离仍在缓存内
    if (withinCache(headIndex, tailIndex, this.renderListWithCache)) {
      // 只改变translateY
      const headIndexWithCache = this.renderListWithCache[0].index
      renderOffset = virtualOffset - sumHeight(this._list, 0, headIndexWithCache)
      this.$listInner.style.transform = `translateY(-${renderOffset}px)`
      return
    }

    // 下面的就和之前做法基本一样,但是列表增加了前后缓存元素
    const headIndexWithCache = Math.max(headIndex - this.cacheCount, 0)
    const tailIndexWithCache = Math.min(tailIndex + this.cacheCount, this._list.length)

    this.renderListWithCache = this._list.slice(headIndexWithCache, tailIndexWithCache)

    renderOffset = virtualOffset - sumHeight(this._list, 0, headIndex)

    renderDOMList.call(this, renderOffset)

    function renderDOMList(renderOffset) {
      this.$listInner = document.createElement("div")
      this.renderListWithCache.forEach((item) => {
        const $el = this.itemElementGenerator(item)
        this.$listInner.appendChild($el)
      })
      this.$listInner.style.transform = `translateY(-${renderOffset}px)`
      this.$list.innerHTML = ""
      this.$list.appendChild(this.$listInner)
    }

    function withinCache(currentHead, currentTail, renderListWithCache) {
      if (!renderListWithCache.length) return false

      const head = renderListWithCache[0]
      const tail = renderListWithCache[renderListWithCache.length - 1]
      const withinRange = (num, min, max) => num >= min && num <= max

      return withinRange(currentHead, head.index, tail.index) && withinRange(currentTail, head.index, tail.index)
    }
  }
}

我们设置缓存量大约为可视元素的两倍,经测试得到下图:

截屏20200608 下午9.22.05.png

脚本执行时长在之前的基础上又少了近一半,渲染时长也是有相应的降低。

优化结果

我们从最开始的40%的脚本执行耗时降到了现在的13%。效果还是蛮显著的,当然还有更多的优化空间,比如我们现在采用的是全部列表重新替换掉,其实这中间有很多一样或者相似的DOM,我们可以复用部分DOM,从而减少创建DOM的时间。

进度条

进度条的话,其实就很简单了。这里讲几个需要注意的点。

  1. 由于进度条按照比例来算过小,我们需要给一个最小高度。
  2. 当拖动进度条时,只需要按照比例更新virtualOffset即可。
  3. 当然,拖动进度条也需要进行事件节流。

思路整理

  1. 监听滚轮事件/触摸事件,记录列表的总偏移量。
  2. 根据总偏移量计算列表的可视元素起始索引。
  3. 从起始索引渲染元素至视口底部。
  4. 当总偏移量更新时,重新渲染可视元素列表。
  5. 为可视元素列表前后加入缓冲元素。
  6. 在滚动量比较小时,直接修改可视元素列表的偏移量。
  7. 在滚动量比较大时(比如拖动滚动条),会重新渲染整个列表。
  8. 事件节流。

原文 -- 我的小破站(未适配PC端)

源码 - VirtualScroll.js

历史精选

  1. 如何在10分钟之内完成一个业务页面 - Vue的封装艺术
  2. 新手代码进阶之路 - 控制反转和依赖注入
  3. Axios源码分析