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

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

话很少说,直接开始好吧。web

为何须要虚拟滚动

想像一下,当你有10万数据须要展现的时候,咋办呢?咱们来试一下将它所有加载出来。 咱们再用chrome的性能功能测试一下,获得下图: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节点过多会影响页面性能,而且给出了推荐的最多节点数量。数据结构

基本思想

好了,在肯定了咱们须要优化长列表的渲染性能以后,那么接下来就是,怎么作呢?app

从上面咱们测试的例子来看,长列表渲染过程当中耗时最长的就是Rendering,浏览器渲染这一部分。并且咱们也看到了,它会生成万级的DOM节点。这些都是致使性能变差的主要缘由。若是咱们能让浏览器渲染时长降到ms级,那确定会流畅不少。而如何下降这一耗时呢?答案就是让浏览器只渲染看的见得DOM节点。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端)

相关文章
相关标签/搜索