直接渲染就不说了 直接说推荐的做法:
requestAnimationFrame + fragment(时间分片)
既然定时器的执行时间和浏览器的刷新率不一致,那么我就可以用requestAnimationFrame
来解决
requestAnimationFrame
也是个定时器,不同于setTimeout
,它的时间不需要我们人为指定,这个时间取决于当前电脑的刷新率,如果是 60Hz ,那么就是 16.7ms 执行一次,如果是 120Hz 那就是 8.3ms 执行一次
requestAnimationFrame
也是个宏任务
这么一来,每次电脑屏幕 16.7ms 后刷新一下,定时器就会产生 20 个li
,dom
结构的出现和屏幕的刷新保持了一致
<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)
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>
目前这个代码还可以优化一下,每一次appendChild
都是新增一个新的li
,也就意味着需要回流一次,总共十万条数据就需要回流十万次
此前讲回流的时候提出过虚拟片段fragment
来解决这个问题
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)
window.requestAnimationFrame(() => {
let fragment = document.createDocumentFragment() // 创建一个虚拟文档碎片
for (let i = 0; i < pageCount; i++) {
let li = document.createElement('li')
li.innerHTML = ~~(Math.random() * total)
fragment.appendChild(li) // 挂到fragment上
}
ul.appendChild(fragment) // 现在才回流
loop(curTotal - pageCount)
})
}
loop(total)
</script>
这个方案被称之为时间分片,解决了定时器执行时机与屏幕刷新刷新不匹配的问题,并且用fragment
优化了回流次数过多问题,同样会有闪屏,这个闪屏是下拉太快导致的,无法规避.
虚拟列表
核心思想:在可视窗口维护一个列表,可视窗口上下都会有个缓存区域,真实dom只会在可视窗口和上下缓存区存在
虚拟列表
核心思想:在可视窗口维护一个列表,可视窗口上下都会有个缓存区域,真实dom只会在可视窗口和上下缓存区存在
考虑到js代码会比较多,这里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>
<style>
*{
margin: 0;
padding: 0;
}
.v-scroll{
width: 300px;
height: 400px;
border: 1px solid #000;
margin: 100px 0 0 100px;
overflow-y: scroll;
}
li{
list-style: none;
padding-left: 20px;
height: 40px;
line-height: 40px;
box-sizing: border-box;
}
</style>
</head>
<body>
<div id="app">
<div class="v-scroll">
<ul>
<li v-for="(item, index) in currentList">{{index + 1}} -- {{item}}</li>
</ul>
</div>
</div>
<script>
const { createApp, ref } = Vue
createApp({
setup() {
const allList = ref([]) // 所有数据
const currentList = ref([]) // 可视区域要渲染的数据
return {
allList,
currentList
}
}
})
</script>
</body>
</html>
接下来模拟一个接口请求,拿到数据后push
到allList
中去
这个接口请求我直接写在setup
全局内,按道理接口请求写在生命周期中,其实vue3的setup
也是个生命周期,它顶替掉了vue2的beforeCreated
和Created
const getAllList = (count) => { // 接口请求
for (let i = 0; i < count; i++) {
allList.value.push(`我是列表${allList.value.length + 1}项`)
}
}
getAllList(400)
另外需要拿到包裹ul
的滚动容器,vue拿到dom是通过打ref
标记实现的,并且想要拿到dom需要放到生命周期onMounted
中
拿到一个dom的高度可以用offSetHeight
,还有个clientHeight
,前者加上了边框,这里必然选择后者
onMounted(() => { // 挂载后才能拿到dom
boxHeight.value = scrollBox.value.clientHeight // clientHeight只包含内容,不含边框
// console.log(scrollBox.value.offsetHeight);
})
然后需要清楚可视区要放下多少个li
,这里向下取整 + 2
是因为,最上面和最下面可能都会露出一点li
,因此需要加两个,用计算属性实现
const itemNum = computed(() => { // 可视区放下多少个li
return ~~(boxHeight.value / itemHeight.value) + 2
})
再记录一个列表开始的索引,然后监听页面的滚动事件,监听的时候需要更新好列表开始的索引
const startIndex = ref(0) // 索引
// 页面滚动
const doScroll = () => { // div内部滚动距离 / 每项的高度 = 滚了多少项
const index = ~~(scrollBox.value.scrollTop / itemHeight.value)
if (index === startIndex.value) return // 滚到最开始的位置
startIndex.value = index // 可视区的第一条数据下标
}
然后记录好最后的索引,这个索引一定是在下缓存区的最后一个index
,这里写下缓存区的长度等同于可视区,因此乘以2
const endIndex = computed(() => { // 可视区最后一个下标
let index = startIndex.value + itemNum.value * 2 // 考虑用户体验,准备可视区一倍的li
if (!allList.value[index]) { // 已经滚超了,回来一个位置
index = allList.value.length - 1
}
return index
})
重写下currentList
,拿到初始下标和最后的下标进行截取
const currentList = computed(() => {
let index = 0
if (startIndex.value <= itemNum.value) { // [0, 21] [0, 22] …… [0, 30] [1, 31]
index = 0
} else {
index = startIndex.value - itemNum.value
}
return allList.value.slice(index, endIndex.value + 1)
})
再写入一个blankStyle
动态样式确保滚动的平滑性,这个样式返回的paddingTop
和paddingBottom
的值根据当前可见区域之前和之后的空白区域高度来动态计算
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"
};
});
我再引入下lodash
的节流进行优化doScroll
函数
最终代码如下,大家可以拿到代码自行运行下,效果很棒~
<!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);
const itemNum = computed(() => {
return ~~(boxHeight.value / itemHiehgt.value) + 2;
});
const startIndex = ref(0);
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>
实现
- 拿到所有数据
- 获取可视区域的高度
- 滚动页面的过程中实时计算可视区域可以展示数据的
起始下标
和结束下标
,去原数组截取要展示的数据
优点
无论数据多少都不会卡顿.
其实关于前端渲染大量数据的问题是个非常经典的面试题,本文实现了两种可行方案,一种是通过requestAnimationFrame + Fragment
时间分片,还有一种就是虚拟列表,时间分片写起来很简单,虚拟列表写起来复杂点,但是实现起来非常优雅,不会产生任何卡顿
作者:Dolphin_海豚
链接:juejin.cn/post/735494…
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。