✨前端十万条数据渲染(上) -- 时间分片
序言
在最近的秋招面试中被面试官问到了这样一个经典问题:“如果后端返回了十万条数据要你插入到页面中,你会怎么处理?”
虽然在实际工作中可能很少会遇到这样的场景,但为了丰富和拓展我们的知识面,还是有必要了解一下这种情况下应当如何优化页面数据的渲染,使得页面不被这大量数据的渲染弄得卡顿
对于这种十万条数据插入的场景,常见的解决方案有两种:
- 时间分片
- 虚拟列表
本篇文章会着重介绍一下时间分片的解决方案,,而虚拟列表的解决方案则会放到下一篇文章讲解
常规做法 -- 直接插入
在理解什么是时间分片之前,我们先来看看常规的思路,直接根据后端的十万条数据创建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
并没有办法保证每一帧的DOM
操作之间的间隔能在16.7ms
这个时间间隔内完成,这就导致有的画面前后两帧之间间隔时间过长的时,画面出现明显的丢帧现象
这个时候我们可以用requestAnimationFrame
去改善
使用requestAnimationFrame改善
前面我们用setTimeout
去完成每一页数据的渲染时,虽然我们指定了定时器的延迟时间为0,但是真正执行时并不能保证一定是0ms就执行(实际上会有一个最小间隔4ms)
这是因为setTimeout
中的任务的执行时机是有可能比设置的0ms
晚的,如果这个延迟超出了16.7ms
就会出现上面的那种闪烁的情况
而改为使用requestAnimationFrame
,则能够保证每帧之间更新的时候去执行我们的回调,比如我们的屏幕如果是60hz
的话,那么两次requestAnimationFrame
中回调的执行间隔能够保证一定是1000ms / 60 === 16.7ms
执行,也就是在帧更新的时候执行
这样就能够避免丢帧导致的闪烁情况的发生了
总结
直接将十万条数据插入到页面中时,需要浏览器一次性进行大量的渲染操作,导致渲染性能比较差,而如果能够把单次的耗费渲染性能的任务拆分成多个任务,放到多个时间片中去执行的话,就不会出现进入网页时卡顿长时间的现象,反而数据能够秒出,这就是时间切片解决十万条数据渲染的主要思路
而其实现可以直接简单粗暴地使用setTimeout
去完成,但是考虑到setTimeout
无法保证在帧刷新时执行我们的回调,从而容易出现丢帧闪烁的现象,通过requestAnimationFrame
就可以很好地解决这个问题,能够保证我们的单页数据渲染回调在每次帧更新时被执行到
以上就是时间分片解决十万条数据渲染的实现啦,下一篇文章我们会一起来研究下另外一种解决方案 -- 虚拟列表
转载自:https://juejin.cn/post/7148366703506767909