虚拟滚动列表助力性能优化
我所理解的是,虚拟滚动需要一次性获取所有数据,但是只渲染显示屏幕可见范围内的那些。
要做到这些我需要知道:
- 一行的高度
- 屏幕范围内能显示的行数
- 列表在页面中距离网页顶部的位置
- 滚动条高度
假设一次只需要展示 10 条数据,需要加载的数据是一个数组listData
,只需要裁剪数据范围listData.slice(0, 10)
随着滚动条向下,将滚动条高度/一行的高度
可以计算出当前行数。
而要模拟滚动条高度就要在页面挂载时就手动设置页面的高度为一行高度*listData.length
。
最后也是最关键的是保持列表一直保持在当前位置上,手动设置列表容器padding-top
等于当前滚动条高度。
<template>
<ul ref="ulRef">
<li v-for="(listItem, listIndex) in listData" :key="`list-${listIndex}`" :data-idx="listItem.idx">
<slot :listItem="listItem"></slot>
</li>
</ul>
</template>
<script lang="ts" setup>
import { ref, computed, nextTick, reactive, watchEffect, onUnmounted } from 'vue'
const props = defineProps<{
listData: Array<any>
}>()
// 列表HTMLElementDom
const ulRef = ref<any>(null)
// 屏幕高度
const screenH = document.documentElement.clientHeight
const data = reactive<any>({
// 列表第一项的高度(起始高度)
initH: 0,
// 一行的高度
unitH: 0,
// 屏幕范围内能显示个数
displayCount: 10,
// 列表起始值
startIdx: 0
})
const listData = computed(() => {
let endIdx = data.startIdx + data.displayCount
if (endIdx >= props.listData.length) endIdx = props.listData.length
return props.listData.slice(data.startIdx, endIdx).map((v,) => {
v.idx = data.startIdx + k + 1
return v
})
})
function scrollHandler() {
// 当前滚动高度
const curScrollTop = document.documentElement.scrollTop
if (curScrollTop > data.initH) {
const addCount = Math.floor((curScrollTop - data.initH) / data.unitH)
ulRef.value.style.setProperty('padding-top', `${addCount * data.unitH}px`)
data.startIdx = addCount
} else {
ulRef.value.style.setProperty('padding-top', '0px')
data.startIdx = 0
}
}
watchEffect(() => {
if (props.listData.length > 0) {
nextTick(() => {
// 列表距离顶部距离
data.initH = ulRef.value.getBoundingClientRect().top + document.documentElement.scrollTop
// 计算每行高度
data.unitH = ulRef.value.children[0].offsetHeight
// 计算屏幕内能显示的行数
data.displayCount = Math.ceil(screenH / data.unitH)
// 设置列表总高度 = 一行高度 * 行数
const listH = data.unitH * props.listData.length
ulRef.value.style.setProperty('height', `${listH}px`)
window.removeEventListener('scroll', scrollHandler)
window.addEventListener('scroll', scrollHandler)
})
}
})
onUnmounted(() => {
window.removeEventListener('scroll', scrollHandler)
})
</script>
如何使用?
<template>
<infinite-list :listData="songs">
<template #default="{ listItem }">
<div>{{ listItem.title }}</div>
<!-- ... -->
</template>
</infinite-list>
</template>
<script lang="ts" setup>
import InfiniteList from './InfiniteList.vue'
const songs = [] // 列表数据
</script>
CSS虚拟滚动
这个概念忘了在哪听到的了。其实我认为它更像是一种内容的懒加载。
他需要用到一个css属性:content-visibility
:控制一个元素是否渲染其内容,它允许用户代理(浏览器)潜在地省略大量布局和渲染工作,直到需要它为止。
其值为auto
时的作用是,如果该元素不在屏幕上,并且与用户无关,则不会渲染其后代元素。
假如我们有这样一段代码:
<div class="g-wrap">
<div class="textarea-p">...</div>
// ...
<div class="textarea-p">...</div>
</div>
基于这种场景,其实我们非常希望对于仍未看到而且还未滚动到的区域,可以延迟加载,只有到我们需要展示、滚动到该处时,页面内容才进行渲染。
所以,content-visibility: auto
就应运而生了,它允许浏览器对于设置了该属性的元素进行判断,如果该元素当前不处于视口内,则不渲染该元素。
我们基于上述的代码,只需要最小化,添加这样一段代码:
.textarea-p {
content-visibility: auto;
}
可以看到,在利用 content-visibility: auto
处理长文本、长列表的时候。在滚动页面的过程中,滚动条一直在抖动,这不是一个好的体验。
当然,这也是许多虚拟列表都会存在的一些问题。(当然,目前大多数人会选择“无视”它)
好在,规范制定者也发现了这个问题。这里我们可以使用另外一个 CSS 属性 —— contain-intrinsic-size
来解决这个问题。
contain-intrinsic-size
用来控制由 content-visibility
指定的元素的自然大小。
其实就是给需要隐藏的元素一个默认(至少是大概的)高度,让滚动条能够提前知道有这些东西。从而一开始就显示出它应该显示的大小。
比如上面的代码:
.textarea-p {
content-visibility: auto;
contain-intrinsic-size: 320px;
}
一些其它思考
这两天想到了一个场景:后端分页下支持删除功能的前端列表如何维持其length
?
针对这个问题其实去年想到了一个方案,在学姐的认可下终于试着写出来,非常简单:『滚动列表分片请求,区别于一般的“重新请求这一页甚至是回到第一页”的做法。新开一个存储。每次请求10条。后端新增一个字段给出第11-20数据。如果碰上删除,直接从前端存储里拿新的一条接上,保证了一定有10条还不用多发请求。新的请求后更新存储区』
但是这样终究觉得不够高端。高~ 端 ~
于是就在想怎么能够更流批一点。但是这又有一个问题:目前来说这个虽然很简单的方案已经完全能够解决问题。有了一丝头绪的新方案又没有想到能够支撑的真实的案例。离谱了。