• 作者:老汪软件技巧
  • 发表时间:2024-10-14 00:02
  • 浏览量:

当被问到要再网页渲染大量数据时,如果直接暴力去渲染数以万计的DOM元素,可能会导致页面卡顿甚至崩溃。外面需要采取优化措施去提高浏览器的性能和用户体验。本文将深入讲解虚拟列表的实现原理,并逐步介绍如何优化大数据量渲染,最终实现高效流畅的页面渲染。

直接渲染大量数据的问题

首先,我们来看一个简单的示例代码,直接渲染10000条数据:

html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Documenttitle>
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 个 元素时,主要的性能压力来自于浏览器在解析 DOM、计算样式和渲染页面的过程,而不是 JavaScript 本身。这也是为什么直接渲染大量数据会导致页面卡顿的原因。

时间分片渲染引入时间分片优化

为了减轻浏览器一次性渲染大量数据带来的性能压力,我们可以将数据的渲染分批次进行。通过 定时器 来包裹每次的渲染,利用任务队列机制,将渲染拆解成多个宏任务,从而避免页面在一次性处理大量数据时卡顿。

html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Documenttitle>
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>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Documenttitle>
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认,所有的 元素都先添加到这个碎片上,最后一次性挂载到 DOM 上,这样可以避免每次添加元素时触发重绘和回流,提高渲染效率。

虽然肉眼是很难看出和定时器的区别,但是理论上还是优化了很多。

虚拟列表的实现

虚拟列表的核心思想是:对于大量的数据,只渲染可视区域内的部分数据,而非一次性将所有数据渲染到页面上。随着用户的滚动,动态地更新可视区域内的内容,保持 DOM 节点数量固定,减少 DOM 操作,提高性能。




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存放了:

还定义了 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 确保边框不会影响整体高度。

总的来说虚拟列表主要达成两点:

总结

当我们面对大量数据渲染的挑战时,直接暴力渲染所有数据显然不是一个合适的方案。通过时间分片、requestAnimationFrame、虚拟列表等优化方法,我们可以显著提升页面的性能。虚拟列表的核心在于只渲染可视区域内的内容,避免一次性渲染所有数据,减少不必要的 DOM 操作,极大地提高了性能和用户体验。

这些技术不仅可以应用于常规的前端开发中,在面对需要处理大量数据、长列表、或者类似场景时尤为有效。在实际项目中,结合这些技术,能够帮助我们更好地应对数据密集型应用的性能挑战。编写文章不易,如果对你有帮助可以个给文章点个赞哦!