1. 传统做法
对于长列表渲染,传统的方法是使用懒加载的方式,下拉到底部获取新的内容加载进来,其实就相当于是在垂直方向上的分页叠加功能。
<script>
const total = 100000
let ul = document.getElementById('container')
let once = 20
let page = total / once
function loop(curTotal) {
if (curTotal <= 0) return
let pageCount = Math.min(curTotal, once) // 最后一次渲染一定少于20条,因此取最小
setTimeout(() => {
for (let i = 0; i < pageCount; i++) {
let li = document.createElement('li')
li.innerHTML = ~~(Math.random() * total)
ul.appendChild(li)
}
loop(curTotal - pageCount)
}, 0)
}
loop(total)
</script>
但随着加载数据越来越多,浏览器的回流和重绘的开销将会越来越大,整个滑动也会造成卡顿,定时器的执行和屏幕的刷新时间不一致产生一个闪屏的问题,并且需要回流十万次。这个时候我们就可以考虑使用虚拟列表来解决问题
2.时间分片
requestAnimationFrame +fragment
requestAnimationFrame也是个定时器,不同于setTimeout,它的时间不需要我们人为指定,这个时间取决于当前电脑的刷新率,如果是 60Hz ,那么就是 16.7ms 执行一次,如果是 120Hz 那就是 8.3ms 执行一次
因此requestAnimationFrame也是个宏任务
这么一来,每次电脑屏幕 16.7ms 后刷新一下,定时器就会产生 20 个li,dom结构的出现和屏幕的刷新保持了一致
fragment是虚拟文档碎片,我们一次for循环产生 20 个li的过程中可以全部把真实dom挂载到fragment上,然后再把fragment挂载到真实dom上,这样原来需要回流十万次,现在只需要回流100000 / 20次
缺点
同样会有闪屏,这个闪屏是下拉太快导致的,无法规避
<script>
const total = 100000
let ul = document.getElementById('container')
let once = 20
let page = total / once
function loop(curTotal) {
if (curTotal <= 0) return
let pageCount = Math.min(curTotal, once) // 最后一次渲染一定少于20条,因此取最小
// 时间分片
window.requestAnimationFrame(()=>{
for(let i=0;i<pageCount;i++){
let li=document.createElement('li')
li.innerHTML=~~(Math.random()*total)
ul.appendChild(li)
}
loop(curTotal-pageCount)
})
}
loop(total)
</script>
3. 虚拟列表
其核心思想就是在处理用户滚动时,只改变列表在可视区域的渲染部分,具体步骤为:
先计算可见区域起始数据的索引值startIndex和当前可见区域结束数据的索引值endIndex,假如元素的高度是固定的,那么startIndex的算法很简单
即
startIndex = Math.floor(scrollTop/itemHeight)
endIndex = startIndex + (clientHeight/itemHeight) - 1
再根据startIndex 和endIndex取相应范围的数据,渲染到可视区域再计算startOffset(上滚动空白区域)和endOffset(下滚动空白区域)
这两个偏移量的作用就是来撑开容器元素的内容,从而起到缓冲的作用,使得滚动条保持平滑滚动,并使滚动条处于一个正确的位置
上述的操作可以总结成五步:
- 不把长列表数据一次性全部直接渲染在页面上
- 截取长列表一部分数据用来填充可视区域
- 长列表数据不可视部分使用空白占位填充(下图中的
startOffset和endOffset区域) - 监听滚动事件根据滚动位置动态改变可视列表
- 监听滚动事件根据滚动位置动态改变空白填充
// 虚拟列表DOM结构
<div className='container'>
// 监听滚动事件的盒子,该高度继承了父元素的高度
<div className='scroll-box' ref={containerRef} onScroll={boxScroll}>
// 该盒子的高度一定会超过父元素,要不实现不了滚动的效果,而且还要动态的改变它的padding值用于控制滚动条的状态
<div style={topBlankFill.current}>
{
showList.map(item => <div className='item' key={item.commentId || (Math.random() + item.comments)}>{item.content}</div>)
}
</div>
</div>
</div>
cdn引入vue来实现示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
}
.v-scroll {
width: 300px;
height: 400px;
border: 1px solid black;
overflow-y: scroll;
margin: 100px 0 0 100px;
}
li {
list-style: none;
padding-left: 20px;
line-height: 40px;
height: 40px;
box-sizing: border-box;
}
</style>
</head>
<body>
<div id="app">
<div class="v-scroll" @scroll="doScroll" ref="scrollBox">
<ul :style="blankStyle" style="height: 100%">
<li v-for="item in currentList" :key="item.id">
{{ item }}
</li>
</ul>
</div>
</div>
<script>
const { createApp, ref, onMounted, computed } = Vue
createApp({
setup() {
const allList = ref([]);
getAllList(300);
function getAllList(count) {
const length = allList.value.length;
for (let i = 0; i < count; i++) {
allList.value.push(`我是列表${length + i + 1}项`)
}
}
const scrollBox = ref(null);
const boxHeight = ref(0);
function getScrollBoxHeight() {
boxHeight.value = scrollBox.value.clientHeight;
}
onMounted(() => {
getScrollBoxHeight();
window.onresize = getScrollBoxHeight;
window.onorientationchange = getScrollBoxHeight;
})
const itemHiehgt = ref(40);
//需要清楚可视区要放下多少个li,这里向下取整 + 2是因为最上面和最下面可能都会露出一点li,
//因此需要加两个,用计算属性实现
const itemNum = computed(() => {
return ~~(boxHeight.value / itemHiehgt.value) + 2;
});
const startIndex = ref(0);
//引入lodash的节流进行优化doScroll函数
const doScroll = _.throttle(() => {
const index = ~~(scrollBox.value.scrollTop / itemHiehgt.value);
if (index === startIndex.value) return;
startIndex.value = index;
}, 200)
const endIndex = computed(() => {
let index = startIndex.value + itemNum.value * 2;
if (!allList.value[index]) {
index = allList.value.length - 1;
}
return index;
});
const currentList = computed(() => {
let index = 0;
if (startIndex.value <= itemNum.value) {
index = 0;
} else {
index = startIndex.value - itemNum.value;
}
return allList.value.slice(index, endIndex.value + 1);
});
const blankStyle = computed(() => {
let index = 0;
if (startIndex.value <= itemNum.value) {
index = 0;
} else {
index = startIndex.value - itemNum.value;
}
return {
paddingTop: index * itemHiehgt.value + "px",
paddingBottom: (allList.value.length - endIndex.value - 1) * itemHiehgt.value + "px"
};
});
return {
allList,
currentList,
boxHeight,
itemHiehgt,
scrollBox,
doScroll,
blankStyle
}
}
}).mount('#app')
</script>
</body>
</html>
步骤分析
-
初始化列表:
function getAllList(count) { const length = allList.value.length; for (let i = 0; i < count; i++) { allList.value.push(`我是列表${length + i + 1}项`) } } -
计算视口高度:
function getScrollBoxHeight() { boxHeight.value = scrollBox.value.clientHeight; } -
计算可见项数:
const itemNum = computed(() => { return ~~(boxHeight.value / itemHiehgt.value) + 2; }); -
滚动事件处理:
const doScroll = _.throttle(() => { const index = ~~(scrollBox.value.scrollTop / itemHiehgt.value); if (index === startIndex.value) return; startIndex.value = index; }, 200); -
计算当前显示的列表项:
const currentList = computed(() => { let index = 0; if (startIndex.value <= itemNum.value) { index = 0; } else { index = startIndex.value - itemNum.value; } return allList.value.slice(index, endIndex.value + 1); }); -
设置空白填充:
const blankStyle = computed(() => { let index = 0; if (startIndex.value <= itemNum.value) { index = 0; } else { index = startIndex.value - itemNum.value; } return { paddingTop: index * itemHiehgt.value + "px", paddingBottom: (allList.value.length - endIndex.value - 1) * itemHiehgt.value + "px" }; });