✨前端十万条数据渲染(上) -- 时间分片

3,376 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情

序言

在最近的秋招面试中被面试官问到了这样一个经典问题:“如果后端返回了十万条数据要你插入到页面中,你会怎么处理?”

虽然在实际工作中可能很少会遇到这样的场景,但为了丰富和拓展我们的知识面,还是有必要了解一下这种情况下应当如何优化页面数据的渲染,使得页面不被这大量数据的渲染弄得卡顿

对于这种十万条数据插入的场景,常见的解决方案有两种:

  1. 时间分片
  2. 虚拟列表

本篇文章会着重介绍一下时间分片的解决方案,,而虚拟列表的解决方案则会放到下一篇文章讲解

常规做法 -- 直接插入

在理解什么是时间分片之前,我们先来看看常规的思路,直接根据后端的十万条数据创建DOM元素并插入到容器元素中试试

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>直接插入十万条数据</title>
  </head>
  <body>
    <ul id="list-container"></ul>

    <script>
      const oListContainer = document.getElementById('list-container')

      // 模拟请求后端接口返回十万条数据
      const fetchData = () => {
        return new Promise(resolve => {
          const response = {
            code: 0,
            msg: 'success',
            data: [],
          }

          for (let i = 0; i < 100000; i++) {
            response.data.push(`content-${i + 1}`)
          }

          setTimeout(() => {
            resolve(response)
          }, 100)
        })
      }

      fetchData().then(res => {
        console.time('DOM操作耗时')
        console.time('浏览器渲染耗时')

        res.data.forEach(item => {
          const oItem = document.createElement('li')
          oItem.innerText = item
          oListContainer.appendChild(oItem)
        })

        console.timeEnd('DOM操作耗时')

        setTimeout(() => {
          console.timeEnd('浏览器渲染耗时')
        })
      })
    </script>
  </body>
</html>

这里我们模拟了一下后端返回的接口数据场景,并在分别记录了一下js层面操作DOM的耗时,以及通过setTimeout记录一下浏览器渲染结束的整个耗时(利用宏任务被放到了渲染线程结束后运行的特点),结果如下

直接插入数据耗时

可以看到,js层面操作DOM的耗时是184ms,还算能接受,但是浏览器渲染这十万个li元素,竟然花费了3604ms,这个耗时是十分长的,而且能明显感觉到页面卡顿了三秒多

时间分片

什么是时间分片呢?首先思考一下,前面那种做法之所以会导致页面卡顿的原因在哪?

在于一次性将十万个DOM插入到页面中

这个一次性插入的操作十分耗时,那么我们是否可以将这个一次性的操作拆分成多次去进行呢?

我们可以将十万个数据拆成多个页,每次渲染时插入一页,降低一次性插入大量数据的这个情况,改成多次少量地插入DOM元素,这时候可以利用setTimeout去完成,在每一次宏任务中插入一页数据,然后设置多个这样地宏任务,直到把所有数据都插入为止

  // 渲染 total 条数据中的第 page 页,每页 pageCount 条数据
  const renderData = (data, total, page, pageCount) => {
    // base case -- total 为 0 时没有数据要渲染 不再递归调用
    if (total <= 0) return

    // total 比 pageCount 少时只渲染 total 条数据
    pageCount = Math.min(pageCount, total)

    setTimeout(() => {
      const startIdx = page * pageCount
      const endIdx = startIdx + pageCount
      const dataList = data.slice(startIdx, endIdx)

      // 将 pageCount 条数据插入到容器中
      for (let i = 0; i < pageCount; i++) {
        const oItem = document.createElement('li')
        oItem.innerText = dataList[i]
        oListContainer.appendChild(oItem)
      }

      renderData(data, total - pageCount, page + 1, pageCount)
    }, 0)
  }

  fetchData().then(res => {
    renderData(res.data, res.data.length, 0, 200)
  })

现在重新刷新一下页面,就不会再出现之前那种一进去卡住几秒钟才出来数据的情况了,这次是秒出数据,因为我们将耗时任务拆分到多个小的时间片中执行,避免了一次性插入多条数据的浏览器渲染性能开支

setTimeout时间分片.gif

但是很明显能看到的问题是,快速拖动滚动条时,数据列表中会有闪烁的情况,这是怎么回事呢?

这是因为setTimeout并没有办法保证每一帧的DOM操作之间的间隔能在16.7ms这个时间间隔内完成,这就导致有的画面前后两帧之间间隔时间过长的时,画面出现明显的丢帧现象

这个时候我们可以用requestAnimationFrame去改善

使用requestAnimationFrame改善

前面我们用setTimeout去完成每一页数据的渲染时,虽然我们指定了定时器的延迟时间为0,但是真正执行时并不能保证一定是0ms就执行(实际上会有一个最小间隔4ms)

这是因为setTimeout中的任务的执行时机是有可能比设置的0ms晚的,如果这个延迟超出了16.7ms就会出现上面的那种闪烁的情况

而改为使用requestAnimationFrame,则能够保证每帧之间更新的时候去执行我们的回调,比如我们的屏幕如果是60hz的话,那么两次requestAnimationFrame中回调的执行间隔能够保证一定是1000ms / 60 === 16.7ms执行,也就是在帧更新的时候执行

这样就能够避免丢帧导致的闪烁情况的发生了

requestAnimationFrame时间分片.gif

总结

直接将十万条数据插入到页面中时,需要浏览器一次性进行大量的渲染操作,导致渲染性能比较差,而如果能够把单次的耗费渲染性能的任务拆分成多个任务,放到多个时间片中去执行的话,就不会出现进入网页时卡顿长时间的现象,反而数据能够秒出,这就是时间切片解决十万条数据渲染的主要思路

而其实现可以直接简单粗暴地使用setTimeout去完成,但是考虑到setTimeout无法保证在帧刷新时执行我们的回调,从而容易出现丢帧闪烁的现象,通过requestAnimationFrame就可以很好地解决这个问题,能够保证我们的单页数据渲染回调在每次帧更新时被执行到

以上就是时间分片解决十万条数据渲染的实现啦,下一篇文章我们会一起来研究下另外一种解决方案 -- 虚拟列表