JavaScript高性能渲染:时间分片与DocumentFragment实战

作者:安全边界时间:2026-10-30 19:24:52浏览:524

本文深入解析JavaScript中处理大量数据渲染的性能优化方案。通过对比一次性渲染与分批渲染,指出DOM操作是性能瓶颈。文章详细讲解了如何利用setTimeout和requestAnimationFrame实现时间分片,并结合DocumentFragment减少重排重绘,最终实现十万条数据的高效流畅渲染。

前言

在实际开发场景中,虽然一次性向页面插入海量数据的情况并不常见,但为了完善技术知识体系,掌握在大数据量下如何避免页面卡顿并理解其底层原理显得尤为重要。

针对一次性插入大量数据的需求,通常有两种主流解决方案:

  • 时间分片
  • 虚拟列表

作为系列开篇,本文重点阐述如何利用时间分片技术来渲染海量数据。关于虚拟列表的具体实现,可参考「前端进阶」系列文章《高性能渲染十万条数据(虚拟列表)》。

最粗暴的做法(一次性渲染)

首先,我们观察最直观且低效的做法:一次性将海量数据插入页面。

<ul id="container"></ul>
// 记录任务开始时间
let now = Date.now();
// 插入十万条数据
const total = 100000;
// 获取容器
let ul = document.getElementById('container');
// 将数据插入容器中
for (let i = 0; i < total; i++) {
    let li = document.createElement('li');
    li.innerText = ~~(Math.random() * total)
    ul.appendChild(li);
}
console.log('JS运行时间:',Date.now() - now);
setTimeout(()=>{
  console.log('总运行时间:',Date.now() - now);
},0)
// print: JS运行时间: 187
// print: 总运行时间: 2844

通过对十万条记录进行循环操作,JS引擎的执行耗时约为187ms,速度尚可。然而,最终页面渲染完成的总耗时却高达2844ms。

这里简要解释为何两次console.log输出的时间存在巨大差异,以及如何简单统计JS运行时间与总渲染时间:

  • 在JS的Event Loop机制中,只有当JS引擎管理的执行栈中的事件以及所有微任务全部执行完毕后,渲染线程才会介入并对页面进行重绘。
  • 第一个console.log在页面渲染之前触发,此时获取的时间间隔即为JS代码运行所需的时间。
  • 第二个console.log被置于setTimeout回调中,其触发时机是在渲染完成后,进入下一次Event Loop时执行。

关于Event Loop的深入细节,请参阅文章《JS进阶之从多线程到Event Loop全面梳理》。

根据上述两次console.log的结果,我们可以得出一个关键结论:

在处理大量数据渲染时,JS的计算过程并非性能瓶颈,真正的性能瓶颈在于DOM的渲染阶段。

使用定时器

从上述案例可知,页面卡顿源于同时渲染大量DOM节点。因此,我们可以考虑将渲染过程拆分为多个批次进行。

在此,我们利用setTimeout来实现分批渲染逻辑。

<ul id="container"></ul>
//需要插入的容器
let ul = document.getElementById('container');
// 插入十万条数据
let total = 100000;
// 一次插入 20 条
let once = 20;
//总页数
let page = total/once
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal,curIndex){
    if(curTotal <= 0){
        return false;
    }
    //每页多少条
    let pageCount = Math.min(curTotal , once);
    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)
    },0)
}
loop(total,index);

通过一张GIF图来直观展示效果。

前言

可以看到,页面加载速度显著提升,每次刷新时能迅速呈现首屏数据。然而,在快速滚动页面时,会出现明显的闪屏或白屏现象。

为什么会出现闪屏现象呢?

首先,需要厘清几个核心概念。FPS指的是每秒钟画面更新的次数。我们感知到的连续动态画面,实际上是由一系列静止画面拼接而成,每一幅静止画面被称为一帧,而FPS则是描述帧变化速度的物理量。

大多数电脑显示器的刷新频率为60Hz,即每秒重绘约60次,此时FPS约为60frame/s。该值的具体设定受屏幕分辨率、尺寸及显卡性能的影响。

因此,即使你面对电脑屏幕没有任何操作,显示器仍会以每秒60次的频率持续更新屏幕图像。

为何你察觉不到这种变化?

这是因为人眼存在视觉停留效应。前一幅画面在大脑中留下的印象尚未消失,后一幅画面便已跟上,两者间隔仅约16.7ms(1000/60≈16.7),从而让你产生图像静止的错觉。

这种感知是正确的。试想,若刷新频率降至1次/秒,屏幕图像将出现严重闪烁,极易引发眼睛疲劳、酸痛甚至头晕。

大多数浏览器会对重绘操作进行限制,使其不超过显示器的刷新频率。因为即使超过该频率,用户体验也不会得到提升。因此,实现最平滑动画的最佳循环间隔为1000ms/60,即约16.6ms。

直观感受不同帧率的体验:

  • 帧率维持在50~60 FPS的动画将非常流畅,给人舒适感;
  • 帧率在30~50 FPS之间的动画,舒适度因个人敏感度而异;
  • 帧率低于30 FPS的动画,会让人感到明显的卡顿与不适;
  • 帧率波动剧烈的动画,同样会引发卡顿感。

简单聊一下 setTimeout 和闪屏现象

  • setTimeout的实际执行时间并非绝对确定。在JS中,setTimeout任务被放入事件队列,只有当主线程执行完毕后,才会检查事件队列中的任务是否需要执行,因此setTimeout的实际执行时间可能会晚于设定时间。
  • 刷新频率受屏幕分辨率和尺寸影响,不同设备的刷新频率可能不同。而setTimeout只能设置固定的时间间隔,该间隔未必与屏幕刷新时间完全同步。

上述两种情况均可能导致setTimeout的执行步调与屏幕刷新步调不一致。

在setTimeout中对DOM进行操作,必须等待屏幕下次绘制时才能更新到屏幕上。如果两者步调不一致,可能导致中间某一帧的操作被跳过,直接更新下一帧元素,从而引发丢帧现象。

使用 requestAnimationFrame

与setTimeout相比,requestAnimationFrame的最大优势在于由系统决定回调函数的执行时机。

若屏幕刷新率为60Hz,回调函数每16.7ms执行一次;若刷新率为75Hz,则间隔变为1000/75=13.3ms。换言之,requestAnimationFrame的执行节奏跟随系统刷新步伐。它能确保回调函数在屏幕每次刷新间隔中仅执行一次,从而避免丢帧。

我们使用requestAnimationFrame来进行分批渲染:

<ul id="container"></ul>
//需要插入的容器
let ul = document.getElementById('container');
// 插入十万条数据
let total = 100000;
// 一次插入 20 条
let once = 20;
//总页数
let page = total/once
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal,curIndex){
    if(curTotal <= 0){
        return false;
    }
    //每页多少条
    let pageCount = Math.min(curTotal , once);
    window.requestAnimationFrame(function(){
        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);

看下效果

前言

结果显示,页面加载速度依然很快,且在滚动时非常流畅,未出现闪烁或丢帧现象。

这就结束了么,还可以再优化么?

当然可以。

使用 DocumentFragment

先解释一下什么是 DocumentFragment,文献引用自MDN。

DocumentFragment(文档片段接口)表示一个没有父级节点的最小文档对象。它被用作轻量版的Document,用于存储已排版或尚未整理格式的XML片段。其最大区别在于,DocumentFragment并非真实DOM树的一部分,其变化不会触发DOM树的重新渲染,也不会导致性能问题。

可以使用document.createDocumentFragment方法或构造函数来创建一个空的DocumentFragment。

从MDN的说明中可知,DocumentFragments是DOM节点,但并非DOM树的一部分,可视为存在于内存中。因此,将子元素插入到文档片段时不会引起页面回流。

当向document中append元素时,样式表的计算是同步发生的,此时调用getComputedStyle可获得样式的计算值。而向documentFragment中append元素时,不会计算元素的样式表,因此documentFragment性能更优。当然,现代浏览器的优化已非常完善,当向document中append元素后,若未访问getComputedStyle等方法,浏览器也可将样式表计算推迟至脚本执行之后。

最后修改代码如下:

<ul id="container"></ul>
//需要插入的容器
let ul = document.getElementById('container');
// 插入十万条数据
let total = 100000;
// 一次插入 20 条
let once = 20;
//总页数
let page = total/once
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal,curIndex){
    if(curTotal <= 0){
        return false;
    }
    //每页多少条
    let pageCount = Math.min(curTotal , once);
    window.requestAnimationFrame(function(){
        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);

到此这篇关于JavaScript利用时间分片实现高性能渲染数据详解的文章就介绍到这了,更多相关JavaScript渲染数据内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!

作者最新文章