前端虚拟滚动的魔法:如何提高你的应用性能
前端虚拟滚动的魔法:如何提高你的应用性能
在前端开发中,当需要展示大量数据时,如何保持页面的流畅性和响应速度是一个重要的挑战。虚拟滚动技术提供了一个高效的解决方案,通过只渲染用户当前视口内的元素,显著减少了DOM节点的数量,从而提高了页面性能。本文将详细介绍虚拟滚动技术的原理和实现方法。
什么是虚拟滚动技术?
前端虚拟滚动技术是一种高效的网页性能优化方案,特别适用于需要展示大量数据的场景,例如长列表、表格或图像库。它通过仅渲染用户当前视口内可见的元素,来显著减少DOM节点的数量,从而提高页面加载速度和用户交互的流畅性。
1. 为什么需要虚拟滚动
对于绝大部分场景来说,前端都是从服务端获取指定页码的数据,然后进行渲染,一般也就几十条,此时当然不会出现性能问题。但是在某些特定的场景或者应用中,需要加载大数据进行分析,一次性可能要获取几万甚至几十万条数据进行渲染(笔者真的做过此类项目,是关于心电大数据分析的),此时虚拟滚动技术就派上用场了!
首先使用谷歌开发者工具测试一下一次性渲染 10 万条数据时的性能如何,具体如何进行测试可参考这篇文章:如何查找和解决前端内存泄漏问题? - 排查和分析技巧详解
从图中可以看出,Rendering 也就是渲染的时间达到了 3094ms,渲染的节点 Nodes 也达到了惊人的 50多万个。所以我们可以得到结论:一次性渲染大量的内容对性能的影响是致命的,即便渲染出来滚动时都是卡顿的,严重影响用户体验!根据谷歌的
RAIL性能模型
:加载时页面的基础内容应当在 1 秒以内加载完成,并且应保持用户的输入响应速度。
2. 实现
从上图可知,最耗时的步骤是渲染,我们可以从这里下手,只动态渲染可见部分内容即可。
由下图可知,虽然元素总个数有100000个,但是用户看到的实际上只有3-12共10个,那么干嘛要全部一次性都渲染出来呢?直接按需渲染不就能大大降低渲染开销,减少渲染时长了吗!
分为下面几个步骤,具体实现请参照第 2.1-2.3 小节:
- 在容器中放置一个和10万个元素总高度一样高的盒子,作用是撑开容器,让容器拥有和10万条数据一样的滚动条,方便滚动事件的操作
- 滚动事件时动态计算需要实际渲染的起始索引,从而获取实际渲染的元素
- 在滚动的过程中动态计算实际渲染元素的偏移量
2.1 实现步骤1
在父盒子中放置一个子盒子,它的作用是撑开容器,方便触发原生滚动事件,对应下面类名为
virtual_content
的盒子,高度根据元素个数 * 每个元素高度获得,下面采用 Vue3 的语法来进行演示,结构如下:
<div
class="content_wrapper"
@scroll="contentWrapperScroll"
:style="{ height: contentWrapperHeight + 'px' }"
>
<div class="virtual_content" :style="virtualStyle"></div>
<div class="real_content" :style="realContentStyle">
<ul>
<li :style="{ height: rowHeight + 'px' }" v-for="(item, index) in renderList" :key="index">
{{ item.name }}
</li>
</ul>
</div>
</div>
<script setup>
...
const contentWrapperHeight = ref(400) // 容器高度
const listData = ref([]) // 数据列表
const rowHeight = 40 // 单个元素高度
const virtualStyle = computed(() => {
const rows = listData.value.length
const height = rows * rowHeight
return {
height: height + 'px'
}
})
</script>
2.2 实现步骤2
根据第2节中的图来分析,滚动出去2个半的元素时,应该从第3个元素开始渲染,第3个元素对应的索引为2,
const startRow = Math.floor(virtualScrollTop.value / rowHeight)
中应该向下取整。同理,结束行的索引计算也应该向下取整:
const endRow = startRow + Math.floor(contentWrapperHeight.value / rowHeight)
。然后再根据索引截取渲染的元素即可。
<div
class="content_wrapper"
@scroll="contentWrapperScroll"
:style="{ height: contentWrapperHeight + 'px' }"
>
<div class="virtual_content" :style="virtualStyle"></div>
<div class="real_content" :style="realContentStyle">
<ul>
<li :style="{ height: rowHeight + 'px' }" v-for="(item, index) in renderList" :key="index">
{{ item.name }}
</li>
</ul>
</div>
</div>
<script setup>
...
const renderList = ref([]) // 实际渲染的列表
renderList.value = listData.value.slice(0, 15) // 初始渲染条数
const contentWrapperScroll = (e) => {
// 获取滚动出去的距离
virtualScrollTop.value = e.target.scrollTop
// 截取的开始行, 由图可知应向下取整
const startRow = Math.floor(virtualScrollTop.value / rowHeight)
// 截取的结束行
const endRow = startRow + Math.floor(contentWrapperHeight.value / rowHeight)
// 第1行到开始行的总高, startRow从0开始,所以要加上1
const startRowHeight = (startRow + 1) * rowHeight
// 渲染区域第1行的偏移量
startRowOffset.value = virtualScrollTop.value - (startRowHeight - rowHeight)
// 截取实际渲染的数据
renderList.value = listData.value.slice(startRow, endRow)
}
</script>
2.3 实现步骤3
大家可以先思考一下如果不给实际渲染元素添加偏移量,滚动时会出现什么情况。因为实际渲染的
real_content
采用的是绝对定位,当内容区向上滚动时,绝对定位的
real_content
也会跟着滚动,直到完全离开可视区域。
所以我们需要给它加一个 translateY 的偏移量,那么,偏移量等于滚动出去的距离
virtualScrollTop
就行了吗?这样会导致总是从第1个元素的头部开始渲染,不符合实际情况。由下图可知,要渲染的第1个元素可能会有一个向上的偏移量,我们把它记作
startRowOffset
,从下面代码可求得实际渲染元素整体的偏移量,求得
startRowOffset
后应用到
real_content
元素的样式中即可。
<div
class="content_wrapper"
@scroll="contentWrapperScroll"
:style="{ height: contentWrapperHeight + 'px' }"
>
<div class="virtual_content" :style="virtualStyle"></div>
<div class="real_content" :style="realContentStyle">
<ul>
<li :style="{ height: rowHeight + 'px' }" v-for="(item, index) in renderList" :key="index">
{{ item.name }}
</li>
</ul>
</div>
</div>
<script setup>
// 渲染区域第1行的偏移量, 渲染区域总偏移要减去它
const startRowOffset = ref(0)
// 滚动出去的距离
const virtualScrollTop = ref(0)
const realContentStyle = computed(() => {
return { transform: `translateY(${virtualScrollTop.value - startRowOffset.value}px)` }
})
const contentWrapperScroll = (e) => {
...
// 获取滚动出去的距离
virtualScrollTop.value = e.target.scrollTop
// 第1行到开始行的总高, startRow从0开始,所以要加上1
const startRowHeight = (startRow + 1) * rowHeight
// 渲染区域第1行的偏移量
startRowOffset.value = virtualScrollTop.value - (startRowHeight - rowHeight)
...
}
</script>
<style lang="scss">
.content_wrapper {
overflow-y: scroll;
position: relative;
width: 500px;
margin: 200px auto;
.virtual_content {
}
.real_content {
position: absolute;
left: 0;
top: 0;
ul {
margin: 0;
padding: 0;
li {
border: 1px solid #000;
width: 200px;
display: flex;
align-items: center;
}
}
}
}
</style>
3. 结果
如下图可知,Rendering 渲染时长由 3094ms 降到了 38ms,渲染的节点 Nodes 也从50多万个降到了 1780个,岂不是美滋滋!