前言
后端一次性返回过多数据时,我们前端应该如何渲染,本文提供了两种比较适合方案供大家参考
直接渲染
先看下直接渲染会有什么问题吧
先模拟下这个数据过多时的情景,我生成十万条li
,然后每个li
都是随机生成数,生成一个append
挂载一下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<ul id="container"></ul>
<script>
const total = 100000
let ul = document.getElementById('container')
for (let i = 0; i < total; i++) {
let li = document.createElement('li')
li.innerHTML = ~~(Math.random() * total)
ul.appendChild(li)
}
</script>
</body>
</html>
这加载起来有点慢啊~
不防看下耗时多少,写个时间放进去
<script>
let prevTime = Date.now()
const total = 100000
let ul = document.getElementById('container')
for (let i = 0; i < total; i++) {
let li = document.createElement('li')
li.innerHTML = ~~(Math.random() * total)
ul.appendChild(li)
}
console.log('v8执行代码的时间:', Date.now() - prevTime);
setTimeout(() => {
console.log('渲染页面的时间:', Date.now() - prevTime);
}, 0)
</script>
代码从上往下执行,因此执行完代码就是 v8 的时间
为何这里写个定时器就能代表渲染页面的时间,大家可能都知道event-loop
中,同步、微任务结束之后就是宏任务,殊不知,这中间还有个页面渲染的过程,定时器前面的代码都是同步代码,定时器是个宏任务,但是浏览器执行宏任务之前会判断是否有需要渲染页面的任务,如果有就会先去渲染页面,因此这里写个定时器的打印就能代表页面渲染了多久时间
诺~,v8 执行下代码只需要0.4秒
,而页面渲染需要3.3秒
因此这么写,问题出现在页面渲染上,而非代码的执行,v8 性能是很高的
缺点
- 页面渲染很久
- 十万次的回流
定时器
这十万条数据,用户的屏幕又不能一次性全部展现出来,何苦一次性加载,那我就用个定时器,一次定时器加载一点
<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>
像这样,每次调用loop
都只会加载20个li
,这样一来,用户打开界面就比之前流畅了
但其实这样是有个问题的,上拉或者下拉会有个留白
为何闪屏
这跟电脑的刷新率和定时器回调的执行有关
电脑的刷新率一般都是 60Hz , 60Hz 就代表着 1s 内电脑连续播放 60 张图片,或者说刷新 60 次,这么设计的原因就是我们人眼的视觉反应时间就是 16.7ms ,1000ms 内刷新 60 次就是 16.7ms 刷新一次,这样人眼就感受不到电脑的卡顿了
然而,浏览器的定时器执行不受我们人为严格控制的。浏览器有个主线程用于处理更新界面等操作,执行完主线程才会执行其他线程,而定时器专门有个定时器线程,因此定时器里面回调的执行一般就会比你设置的时间晚一点,哪怕你设定的时间是 0ms
我们回到上面的代码,第一次执行的时候创建 20 个li
,此时浏览器的任务队列是干干净净的,因此去执行appendChild
,将 20 个li
去渲染到页面上去,渲染页面的过程一定是耗时的,等到你第二次执行递归的时候,又要去创建一个定时器,定时器里面的回调是创建 20 个li
,刚才说了,浏览器执行定时器的回调需要等前面的渲染执行完毕,因此第二次创建li
的时候,并不是我们预期的 0ms 创建 20 个li
,这中间有个时差,这才导致了闪屏问题
一句话解释:定时器的执行需要等待前面的渲染队列执行完毕,而定时器的执行又恰好是创建li,这才导致一个非预期时间产生li导致的闪屏问题
其实还有个原因也导致了这个闪屏:定时器在 0ms 时走回调去创建 20 个li
,但是浏览器的视图刷新需要等到 16.7ms 之后, 0ms 和 16.7ms 之间的时差就导致了闪屏,也就是说定时器的时间和屏幕的刷新率不一致
总结
优点
打开页面的时候不需要一次性渲染所有数据,打开首页很快
缺点
定时器的执行和屏幕的刷新时间不一致产生一个闪屏的问题,并且需要回流十万次
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只会在可视窗口和上下缓存区存在
考虑到js代码会比较多,这里cdn引入vue来实现
这里vue源码引入需要在前面引入,否则加载v-for中的item时会直接显示{{item}}一会儿
<!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_Fung
,我和我的小伙伴们有个面试群,可以进群讨论你面试过程中遇到的问题,我们一起解决
另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请”点赞+评论+收藏“一键三连,感谢支持!