面试官:给你成千上万条数据该怎么渲染?

当被问到要再网页渲染大量数据时,如果直接暴力去渲染数以万计的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-listdiv 是列表实际渲染的部分,它的高度不固定,而是通过 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 根据 startend 计算出当前屏幕上需要渲染的那部分数据。只对当前可见部分的数据进行 slice,避免了对整个数据列表进行渲染。

以及设计了这个滚动逻辑,用 scrollTop 获取滚动条当前滚动的距离,用它来计算 start,表示当前显示区域的起始数据条目。 end 根据 startvisibleCount 计算出结束索引,确保只渲染当前可视区域的数据。

这样看上去是完成了,但是效果会如下

因为 infinte-list 会滚动出去,因此要限制这个列表的滚动,添加了 listOffSet 作为记录滚动的偏移量,通过 scrollTop 计算,用于在 infinite-list 容器上应用 translateY 的位移,模拟完整列表的滚动。 getTransform 用于计算 infinite-list 容器的 translateY 值,这个值控制了列表的实际位置,使得内容在滚动时能够保持视觉上的平滑效果。state.listOffSet 保证了列表的位移总是精确地按照滚动的距离进行调整。

此外,我们还在 .infinte-list-item 的样式,使用了 box-sizing: border-box 确保边框不会影响整体高度。

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

  • 性能优化 :这个虚拟列表的设计思想是尽可能减少 DOM 节点的数量。通过 scrollHandle 实时更新渲染数据,使得页面只渲染可见的数据部分,而不是整个数据集。这样可以极大提升性能,尤其在面对成千上万条数据时。

  • 视觉一致性 :通过 empty 容器来控制滚动条的正常显示,用户可以滚动整个列表,即便实际上只渲染了一部分数据;而通过 infinite-listtranslateY 位移来模拟整个列表的滚动过程,使得用户的体验是无缝的。

总结

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

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

相关推荐
sg_knight2 分钟前
VSCode如何修改默认扩展路径和用户文件夹目录到D盘
前端·ide·vscode·编辑器·web
一个处女座的程序猿O(∩_∩)O12 分钟前
完成第一个 Vue3.2 项目后,这是我的技术总结
前端·vue.js
mubeibeinv12 分钟前
项目搭建+图片(添加+图片)
java·服务器·前端
逆旅行天涯19 分钟前
【Threejs】从零开始(六)--GUI调试开发3D效果
前端·javascript·3d
m0_7482552640 分钟前
easyExcel导出大数据量EXCEL文件,前端实现进度条或者遮罩层
前端·excel
长风清留扬1 小时前
小程序毕业设计-音乐播放器+源码(可播放)下载即用
javascript·小程序·毕业设计·课程设计·毕设·音乐播放器
web147862107231 小时前
C# .Net Web 路由相关配置
前端·c#·.net
m0_748247801 小时前
Flutter Intl包使用指南:实现国际化和本地化
前端·javascript·flutter
飞的肖1 小时前
前端使用 Element Plus架构vue3.0实现图片拖拉拽,后等比压缩,上传到Spring Boot后端
前端·spring boot·架构
青灯文案11 小时前
前端 HTTP 请求由 Nginx 反向代理和 API 网关到后端服务的流程
前端·nginx·http