当被问到要再网页渲染大量数据时,如果直接暴力去渲染数以万计的DOM元素,可能会导致页面卡顿甚至崩溃。外面需要采取优化措施去提高浏览器的性能和用户体验。本文将深入讲解虚拟列表的实现原理,并逐步介绍如何优化大数据量渲染,最终实现高效流畅的页面渲染。
直接渲染大量数据的问题
首先,我们来看一个简单的示例代码,直接渲染10000条数据:
html
<!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="ul"></ul>
<script>
let now = Date.now();
const total = 10000;
const ul = document.getElementById('ul');
for (let i = 0; i < total; i++) {
let li = document.createElement('li');
li.innerText = ~~(Math.random() * total);
ul.appendChild(li);
}
console.log('渲染时间:', Date.now() - now);
setTimeout(() => {
console.log('渲染后的时间:', Date.now() - now);
});
</script>
</body>
</html>
在上述代码中,循环创建并直接将10000个li
元素插入到ul
标签中。尽管V8引擎在处理JavaScript逻辑上非常高效,但浏览器在渲染大量DOM时会遇到性能瓶颈,导致页面的渲染时间变长,甚至会卡顿。
这里就涉及到事件循环 机制了,代码执行时,JavaScript 引擎会先执行所有同步代码,形成一个宏任务。渲染操作一般会在宏任务执行结束后,也就是微任务队列清空后才会进行。浏览器在完成所有的 JavaScript 逻辑之后才进行页面的渲染。这就是为什么我们在上面的代码中添加一个 setTimeout
,它的回调函数只有在页面渲染完后才会执行。可以看到时间间隔之大!
因此性能瓶颈 在渲染 10000 个 <li>
元素时,主要的性能压力来自于浏览器在解析 DOM、计算样式和渲染页面的过程,而不是 JavaScript 本身。这也是为什么直接渲染大量数据会导致页面卡顿的原因。
时间分片渲染
引入时间分片优化
为了减轻浏览器一次性渲染大量数据带来的性能压力,我们可以将数据的渲染分批次进行。通过 定时器 来包裹每次的渲染,利用任务队列机制,将渲染拆解成多个宏任务,从而避免页面在一次性处理大量数据时卡顿。
html
<!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="ul"></ul>
<script>
const total = 1000;
const ul = document.getElementById('ul');
const once = 20; // 每次渲染的数量
let index = 0;
function loop(curTotal, curIndex) {
if (curTotal <= 0) return;
const pageCount = Math.min(once, curTotal); // 每次最多渲染 20 条
setTimeout(() => {
for (let i = 0; i < pageCount; i++) {
let li = document.createElement('li');
li.innerText = curIndex + i + ': ' + ~~(Math.random() * total);
ul.appendChild(li);
}
loop(curTotal - pageCount, curIndex + pageCount);
});
}
loop(total, index);
</script>
</body>
</html>
要注意的是每次定时器触发都会进入新的宏任务,这样浏览器有时间在每次宏任务完成后渲染页面,避免了页面长时间的阻塞。loop
函数是逐步减少未渲染的数据量(curTotal
),并在下一次 setTimeout
调用时从 curIndex
继续渲染。这个设计将大任务拆分为多个小任务,减少浏览器的压力。
因此时间分片的概念 是利用 setTimeout
将渲染过程拆分成多个宏任务,避免长时间占用主线程。每次定时器触发时,只渲染一小部分数据(这里是 20 条),通过 loop
函数递归执行,逐步完成所有数据的渲染。
进一步优化:使用 requestAnimationFrame
尽管定时器可以一定程度上优化性能,但由于定时器的执行时间具有不稳定性,可能会导致屏幕闪烁等问题。为了更精准地控制渲染节奏,我们可以使用 requestAnimationFrame
(RAF),它会在每一帧绘制前被调用,从而保证平滑的渲染。
html
<!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="ul"></ul>
<script>
const total = 1000;
const ul = document.getElementById('ul');
const once = 20;
let index = 0;
function loop(curTotal, curIndex) {
if (curTotal <= 0) return;
const pageCount = Math.min(once, curTotal);
requestAnimationFrame(() => {
let fragment = document.createDocumentFragment(); // 使用文档碎片
for (let i = 0; i < pageCount; i++) {
let li = document.createElement('li');
li.innerText = curIndex + i + ': ' + ~~(Math.random() * total);
fragment.appendChild(li);
}
ul.appendChild(fragment); // 一次性挂载,减少回流
loop(curTotal - pageCount, curIndex + pageCount);
});
}
loop(total, index);
</script>
</body>
</html>
requestAnimationFrame
:RAF 是一个更适合与浏览器渲染同步的 API。它会在浏览器的每一帧绘制之前调用,因此能够避免定时器的不稳定问题,实现更平滑的渲染。
值得留意的是这里文档碎片的使用,document.createDocumentFragment()
创建了一个文档碎片,浏览器不认文档碎片但是JS认,所有的 <li>
元素都先添加到这个碎片上,最后一次性挂载到 DOM 上,这样可以避免每次添加元素时触发重绘和回流,提高渲染效率。
虽然肉眼是很难看出和定时器的区别,但是理论上还是优化了很多🤔。
虚拟列表的实现
虚拟列表的核心思想是:对于大量的数据,只渲染可视区域内的部分数据,而非一次性将所有数据渲染到页面上。随着用户的滚动,动态地更新可视区域内的内容,保持 DOM 节点数量固定,减少 DOM 操作,提高性能。
vue
<template>
<div ref = "listRef" class="infinte-list-container" @scroll="scrollHandle">
<div class="empty" :style="{height: props.itemSize * props.listData.length + 'px'}"></div>
<div class="infinte-list" :style="{transform: getTransform}">
<div class="infinte-list-item"
v-for="item in visibleData"
:key="item.id"
:style="{'height':props.itemSize + 'px', 'line-height':props.itemSize + 'px'}"
>
{{item.value}}
</div>
</div>
</div>
</template>
<script setup>
import {ref,reactive,computed,onMounted,defineProps} from 'vue'
const props = defineProps({
listData:{
type:Array,
default: ()=> []
},
itemSize:{
type:Number,
default:50
}
})
const state = reactive({
scrollHeight:0,
start:0,
end:0,
listOffSet:0
})
// 可视区域能展示几条
const visibleCount = computed(() =>{
return Math.ceil(state.scrollHeight / props.itemSize)
})
// 可视区域要展示的数据
const visibleData = computed(() =>{
return props.listData.slice(state.start,Math.min(state.end,props.listData.length))
})
// 列表被带出去后移回
const getTransform = computed(() =>{
return `translateY(${state.listOffSet}px)`
})
const listRef = ref(null)
onMounted(()=>{
state.scrollHeight = listRef.value.clientHeight
state.end = state.start + visibleCount.value
})
const scrollHandle = () =>{
// 实时计算 start 和 end
let scrollTop = listRef.value.scrollTop
state.start = Math.floor(scrollTop / props.itemSize)
state.end = state.start + visibleCount.value
state.listOffSet = scrollTop - (scrollTop % props.itemSize)
}
</script>
<style scoped>
.infinte-list-container{
height:100%;
overflow: auto;
position: relative;
}
.infinte-list{
position: absolute;
left:0;
right: 0;
top:0;
}
.infinte-list-item{
text-align: center;
border:1px solid #eee;
box-sizing: border-box;
}
</style>
listRef
通过 ref
绑定到 div
,用于直接获取滚动容器的 DOM 节点。其中的listRef.value.clientHeight
代表列表的可视区域高度,主要用于计算可展示的条目数(visibleCount
)。
infinte-list
的 div
是列表实际渲染的部分,它的高度不固定,而是通过 transform
来动态调整它的垂直位置。这个 div
被放置在占位容器 empty
之上,实际显示的数据仅包含可视区域的数据。getTransform
计算出来的 translateY
值控制了它的垂直移动位置,使得看起来列表一直在滚动,但实际上只渲染了部分数据。
infinte-list-item
这个 div
是具体的列表项,v-for
用于遍历当前 可视区域的数据 (visibleData
),并为每个数据项创建一个列表项。每个列表项的高度通过 props.itemSize
动态设置,与 empty
容器的高度保持一致,保证整个列表的布局是均匀的,且每个元素的 line-height
也保持一致,使得文本垂直居中。
如果只是这三个div
那会发现,滚动不了。所以使用了一个empty
。这个 div
是整个虚拟列表的 占位容器 ,它的高度是整个列表的总高度(所有数据项的总高度),但不会渲染所有的数据。这个容器实际上是用来确保滚动条的存在,使得用户可以滚动浏览整个列表。它的高度通过计算 props.itemSize * props.listData.length
得到,这样可以根据数据量动态变化。
定义了一个响应式状态state
存放了:
-
scrollHeight
:记录容器的可视区域高度,用于计算可见条目的数量。 -
start
:当前可视区域的第一个条目索引。由滚动事件实时计算,表示列表中的哪个数据项应当被渲染。 -
end
:当前可视区域的最后一个条目索引,取决于start
和可见条目数量。
还定义了 visibleData
根据 start
和 end
计算出当前屏幕上需要渲染的那部分数据。只对当前可见部分的数据进行 slice
,避免了对整个数据列表进行渲染。
以及设计了这个滚动逻辑,用 scrollTop
获取滚动条当前滚动的距离,用它来计算 start
,表示当前显示区域的起始数据条目。 end
根据 start
和 visibleCount
计算出结束索引,确保只渲染当前可视区域的数据。
这样看上去是完成了,但是效果会如下
因为 infinte-list
会滚动出去,因此要限制这个列表的滚动,添加了 listOffSet
作为记录滚动的偏移量,通过 scrollTop
计算,用于在 infinite-list
容器上应用 translateY
的位移,模拟完整列表的滚动。 getTransform
用于计算 infinite-list
容器的 translateY
值,这个值控制了列表的实际位置,使得内容在滚动时能够保持视觉上的平滑效果。state.listOffSet
保证了列表的位移总是精确地按照滚动的距离进行调整。
此外,我们还在 .infinte-list-item
的样式,使用了 box-sizing: border-box
确保边框不会影响整体高度。
总的来说虚拟列表主要达成两点:
-
性能优化 :这个虚拟列表的设计思想是尽可能减少 DOM 节点的数量。通过
scrollHandle
实时更新渲染数据,使得页面只渲染可见的数据部分,而不是整个数据集。这样可以极大提升性能,尤其在面对成千上万条数据时。 -
视觉一致性 :通过
empty
容器来控制滚动条的正常显示,用户可以滚动整个列表,即便实际上只渲染了一部分数据;而通过infinite-list
的translateY
位移来模拟整个列表的滚动过程,使得用户的体验是无缝的。
总结
当我们面对大量数据渲染的挑战时,直接暴力渲染所有数据显然不是一个合适的方案。通过时间分片、requestAnimationFrame
、虚拟列表等优化方法,我们可以显著提升页面的性能。虚拟列表的核心在于只渲染可视区域内的内容,避免一次性渲染所有数据,减少不必要的 DOM 操作,极大地提高了性能和用户体验。
这些技术不仅可以应用于常规的前端开发中,在面对需要处理大量数据、长列表、或者类似场景时尤为有效。在实际项目中,结合这些技术,能够帮助我们更好地应对数据密集型应用的性能挑战。编写文章不易,如果对你有帮助可以个给文章点个赞哦😊!