vue 中使用 v-for 渲染大量数据的优化方案
前端中我们难免会遇到需要展示大量数据的情况,如果基础数据量过大,那么在初始化组件时,可能会造成严重卡顿,影响用户体验。在我参与的开源项目 swanlab 中,某些情况下需要大量渲染程序日志,最近对其进行了一下优化,将使用方案记录于此。
swanlab 是我们团队开源的一款关于人工智能训练相关的工具,包含且不限于日志记录、实验数据图表、多实验对比图表、硬件信息记录等众多功能。
实验日志相关页面位于 这里。
你可以 fork 项目查看相关代码,也欢迎提 issue 和 pr
接下来就以渲染大量日志为例,说一下我的解决方案。
这是相关页面的前端效果:
一、解决思路
浏览器的解析、渲染操作都是在主线程中完成的,而当一次性需要解析的 dom 树过多则会导致一系列问题,原理可以看 这篇博客 - JS执行过程与浏览器渲染原理 ,总之,结果就是卡顿。
所以自然而然会想到,如果我减少渲染量,即减少渲染主线程一次任务的工作量,是不是就能缓解卡顿?那怎么减少渲染量呢?
可以想到几个粗略的方案:
-
加载方案
刚进入页面时,发起初始化请求拿到日志数据,这个日志数据可多可少,但是在初始化中,将所有日志数据都保存在origin_data
中,仅取出合适大小的一部分放到render_data
中,使用v-for="item in render_data"
渲染限定部分,当用户滚动到最底部或者最顶部(取决于你的组件是怎么个渲染交互逻辑),触发加载,将origin_data
中的下一部分加载到render_data
中,如果缓存的数据加载完了就继续请求就行。
但是有个缺点,滚动起来不够流畅,得做好加载过渡(不过再怎么过渡,用户体验的瓶颈在那)。 -
分页方案
分成多页,简单粗暴,操作简单,唯一麻烦的地方就是要用分页器组件,如果定制化要求比较高,自己写起来还得费点功夫。
上面两种方法理解起来比较简单,但是这篇博客想说的是参考 wandb 日志加载方案后的另一种方法:动态渲染视窗部分及附近的区域。
二、动态渲染
如果日志总共有数万条,怎么保证在每个渲染主线程中的任务开销在可控范围?我们可以只渲染视窗及其上下部分,在滚动时计算应该在新的窗口位置渲染的部分。
画一个简单的模型图,也可以结合 代码 中的标签层级理解:
日志窗口
日志窗口是日志展示部分的最外层容器,在我的项目中给其固定了宽高,如果你的项目中不需要固定宽高,而是直接让浏览器窗口出现滚动条,那么只需要更改后续滚动事件的绑定位置即可,思路是一样的。总之,他的作用就是当“日志区域”高度大于该容器高度时,自动显示纵向的滚动条。
日志区域
对于日志区域,不限制高度,即有多少日志就撑开多高。但是我们仅渲染部分在视窗及其附近区域的日志,怎么能让日志区域的高度为所有日志都渲染时的高度?这个就需要动态计算,在获取到日志行高后,同时获取日志条数,计算得到总高度。
日志渲染区域
日志渲染区域是最重要的一部分,我们在这个元素中使用 v-for
。
在我的例子中,页面初始化的时候就已经获取到了全部日志数据。为了渲染,需要有一个计算函数,这个函数需要做这么一些工作:
- 获取行高 (如果行高固定可以写死,但是考虑到缩放等等因素,可以从dom动态获取行高)
- 计算视窗部分可以展示多少条数据:视窗部分高度 / 行高 =>
Math.ceil(e.clientHeight / line_height)
- 视窗顶部,第一条日志的索引:滚动条到容器顶部距离 / 行高 =>
Math.floor(e.scrollTop / line_height)
- 视窗底部,最后一条日志的索引:将上面两个加起来即可
我们肯定不能仅仅只渲染看得到的部分,还需要渲染一部分看不到的但临近视窗的部分,这样用户在滚动时,即使计算稍慢 (在我的代码中,给滚动事件添加了一个 100ms 的防抖),也能保持流畅的渲染,不至于一滚动就出现空白部分。至于留多少条的范围,可以自己设定合理的条数,这里我们额外使用一个变量 addition
保存在视窗上下分别额外渲染的条数。
那么在计算的时候:
- 真正需要渲染的第一条日志索引为:视窗中第一条日志的索引 - 额外向上渲染的条数 =>
Math.floor(e.scrollTop / line_height)
-addition
- 真正需要渲染的第一条日志索引为:视窗中第一条日志的索引 + 视窗中能渲染的条数 + 额外向下渲染的条数 =>
Math.floor(e.scrollTop / line_height)
+Math.ceil(e.clientHeight / line_height)
-addition
其实实现起来也不困难:
// 这个 debounce 是自己实现的防抖函数
const handleScroll = debounce((event) => {
const e = event.target
computeRange(e)
}, 100)
// 计算 log 的渲染范围
const computeRange = (e) => {
const line_height = lineHeight.value
// 计算应该渲染多少条数据
const pageSize = Math.ceil(e.clientHeight / line_height)
// 计算第一条 log 的索引
const startIndex = Math.floor(e.scrollTop / line_height)
// 计算最后一条 log 的索引
const endIndex = startIndex + pageSize
// 如果距离顶部很近,把顶部到第一条中的log也渲染了
if (e.scrollTop <= addition * line_height) range.value[0] = 0
// 如果距离顶部比较远,仅渲染第一条上的 addition 条 log
else range.value[0] = startIndex - addition
// 如果距离底部很近,把最后一条到底部的log也渲染了
if (e.scrollTop + e.clientHeight >= e.scrollHeight - addition * line_height) range.value[1] = lines.value.length - 1
// 如果距离底部较远,仅渲染最后一条下的 addition 条 log
else range.value[1] = endIndex + addition
}
至于这个函数绑定到哪:哪儿个元素负责滚动条,就给他绑定滚动事件 @scroll="handleScroll"
,所以很明显,上面的计算函数接收的第一个参数是滚动事件对象对应的 dom 实例。
渲染区定位
看到这,我们大概知道层级结构,但是有个关键问题没有解决:渲染日志区域只有那么大,怎么让他一直定位到日志窗口中心?
在这里,我们可以将日志区域设置为相对定位,再给日志渲染区域设置为绝对定位,并且使用计算属性实时计算日志渲染区域应该距离日志区域顶部多远:整个渲染区域第一条日志的索引 * 行高
三、结尾
具体实现代码可见 swanlab - LogPage,欢迎体验或参与该项目。