稀土掘金技术社区 05月14日 10:07
如何流畅地在浏览器中渲染100万个元素?深入浏览器底层渲染原理图文分享
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文深入探讨了浏览器页面卡顿的根本原因,从JS单线程、事件循环到页面渲染,逐层剖析了造成卡顿的底层机制。文章详细介绍了如何通过setTimeout、requestAnimationFrame等方法优化性能,保证页面交互流畅。最终目标是在处理大量元素添加时,也能保持页面的正常渲染和交互。

🚦JS的单线程特性是导致页面卡顿的根源,JS在主线程上运行,同步任务会阻塞渲染,异步任务则通过Web API交由其他线程处理。

🔄事件循环是理解页面渲染的关键,它包括宏任务、微任务和渲染任务的执行顺序,微任务优先于宏任务,渲染任务则在每16.6ms执行一次。

🛠️setTimeout和requestAnimationFrame是解决卡顿的有效手段,通过分批处理元素渲染任务,避免了主线程的长时间阻塞,从而保证了页面的流畅性。

💡requestAnimationFrame的优势在于与屏幕刷新率同步,能更精准地控制渲染时机,而setTimeout则更容易受到主线程繁忙程度的影响。

原创 Ryan今天学习了吗 2025-05-12 08:30 重庆

点击关注公众号,“技术干货”及时达!

点击关注公众号,“技术干货” 及时达!

前言:

本文从表象深入到浏览器底层原理,逐步剖析造成页面卡顿的原因,总共 6000 字,文字和配图都是自己手打和制作的,只为了更准确的展示思路,但也难免会有我没发现的疏漏和错误,如果有欢迎指出,大家一起学习。

「先来看看下面的场景:」

我们首先用「创建了一个按钮」,点击按钮后 js 会「循环创建 100 万个元素并添加到 body 中」 (PS:为了时长只渲染了 10 万个),我们明显看到「动画中的小球在执行了创建元素的 js 代码后直接卡住不动了,而且整个页面的别的交互也全都卡住了,然后在几秒之后恢复。」

接下来我们就从表象到「浏览器底层原理」逐步分析造成「页面卡顿的原因」是什么以及「如何进行解决」「最终目标:使用多种方法在循环创建 100 万甚至无数个元素到页面上时,也能保证页面其他部分的交互、渲染也能保持正常。」

PS:上面的小球动画没有使用 「transform」 进行位移,而是使用了 「margin-left」「因为 transform 属性会将小球提升为一个独立渲染层并在另一个线程使用 GPU 进行渲染,并且 transform 也不会影响整体的布局重新计算,所以也不需要主线程 (主线程主要用于计算),也就不会被主线程的状态影响」。详情可以查看:脱离 UI 线程的 css 动画[1] 或知乎上的 为什么有的 css 不会被 js 阻塞[2]

    分析网页卡顿原因

1.1. 先说结论

当我们同步创建巨量元素到页面上时,本质是 「JS 代码一次性添加了太多的元素,导致下一帧的渲染任务无法在短时间内计算全部元素的布局和样式并将他们绘制到网页上,造成了阻塞,占用了主线程」「使其他任务(包括下一帧的渲染任务)无法及时执行」

如果你对上面的结论表示看不懂,别着急,跟着我下面的步骤走,看到最后了解了浏览器事件循环和页面渲染的原理后,所有问题迎刃而解。

1.2. 事件循环与页面渲染

在了解事件循环之前我们先简单学习一下下面的知识点

1.2.1.JS 的单线程特性

    同一个时间只能做一件事。程序执行时,需要按顺序依次执行任务,前面的任务执行完,才能执行后面的任务。

PS:为什么 JS 不使用多线程执行?这主要与 JS 的用途有关,JS 最初被设计为浏览器脚本语言,用来编写用户与页面的交互逻辑,以及操作 DOM。如果以多线程的方式来操作 DOM,就会出现以下情况:线程 1 要求删除该 DOM 元素,线程 2 要求修改该 DOM 元素,那么这时应该执行哪一个操作?当然你可能会说可以引入 “锁” 的机制来解决这些冲突,但这会大大提高复杂性,所以 JS 从诞生开始就选择了单线程执行,避免了多线程的复杂性(如资源竞争和死锁),但需要通过「异步机制」处理耗时操作(如网络请求、定时器)

1.2.2. 浏览器 / Node.js 的运行时环境

上面提到 JS 是一门单线程执行的语言,但这并不意味着该线程只执行 JS 代码,在不同的运行环境下会有不同的情况,而最常见的两个运行环境就是浏览器和 Node:

    「浏览器」
    浏览器是一个「多进程多线程」的应用程序。(进程:简单理解为一块内存空间;线程:进程内运行代码的 “人”)
    进程之间相互独立但又可以互相通信,浏览器内的多个网页应用位于不同进程,就是为了防止一个网页崩坏引起连环崩坏,一个进程至少有一个线程,在进程开启后会自动创建一个线程来运行代码,该线程称为主线程,「如果程序需要同时执行多块代码,主线程就会启动更多线程来执行代码。」
    最主要的浏览器进程有:浏览器进程 (负责界面显示、用户交互、子进程管理等)、网络进程(负责加载网络资源。网络进程内部会启动多个线程来处理不同的网络任务)、「渲染进程 (渲染进程启动后,会开启一个主线程,而这个主线程正是我们执行 JS 代码的地方,而该线程除了执行 JS 还负责解析 HTML、解析 CSS、计算样式、计算布局、每秒把页面画 60 次等等)」
    渲染进程的主线程可以理解为 GUI 渲染线程 + JS 引擎线程,但他们并不独立,只是主线程的两个功能模块,重要的是,「他们的运行是互斥的,也就是说他们不能在同一时间同时执行,」 上面提到的「解析 HTML、解析 CSS、计算样式、计算布局、每秒把页面画 60 次都是 GUI 渲染线程执行的任务,而运行全局 JS 代码、运行 JS」 「回调函数都是 JS 引擎线程执行的」
    我把上面的知识点全部总结为一张流程图,便于理解
    「Node.js」:基于 Libuv 库实现事件循环,通过线程池处理 I/O 等阻塞操作

1.2.3. 异步机制

了解完上面的浏览器大致运行原理后,我们把目光聚焦于渲染进程内的主线程和其他线程,因为引起网页卡死的原因就出在这里,所以请让我详细讲解一下这部分的运行过程。

看着上面的模型,我们已知:GUI 渲染线程需要进行网页的渲染工作,也就是把我们的网页通过一个一个像素点 “画” 出来,并且每秒要将网页绘制 60 次来使我们的网页“动起来”,但是 GUI 渲染线程与 JS 引擎线程又不能同时执行(因为在 「JS 中可以直接操作 DOM 和 CSSOM」,如果在渲染过程中 JS 删除或增加了某个元素,那执行了一半的渲染线程应该怎么做?我是应该无视呢还是重新进行渲染呢?怎么选都有问题,是不是就冲突了,所以最好的方法就是他们不能同时执行),让我们试想一个情况,如果某段 JS 代码运行了很久,那么在这段时间里是不是就无法通过 GUI 绘制网页了?我们的网页是不是就卡死了?对吧,所谓的卡死不就是网页停留在某一帧没有继续画下去了,本质原因就是 「JS 的运行阻塞了网页的渲染。」

而 JS 为了避免这种情况,就引入了我们要讲的异步机制,这个机制使得 JS 能够在遇到需要耗时的任务时不会阻塞主线程,那么它具体是怎么实现的?

JS 将所有的任务分为了:同步任务和异步任务

    同步任务:主线程遇到就立马执行的任务,并且在该任务执行完之前后面的任务都无法执行;
    异步任务:主线程遇到就立马挂起并移交给其他线程进行执行的任务,在其他线程完成后通过回调或 Promise 通知主线程让主线程进行执行,主线程遇到异步任务后不会阻塞后续代码执行

异步任务一般是调用浏览器提供的 Web API 创建的,并且通常都无法在短时间内完成,常见的 Web API 有:

    DOM 的事件,例如当用户点击按钮后才执行的函数
    定时器:setTimeout 和 setInterval
    网络请求(Ajax、fetch)

下面是我画的异步机制运行流程图,请根据提示一步步跟着流程走,你一定可以理解异步机制的运作原理。

1.2.4. 宏任务和微任务

若你能够理解上图所示的运行过程,那么你就了解了事件循环的大致流程,但这时又出现一个问题:已知我们可以通过其他线程执行异步任务,然后再通过回调函数推回任务队列,但是试想一个情况,我现在有很多由不同线程返回的回调函数在任务队列等待执行,但此时我点击了页面的一个按钮触发了回调函数,推入到任务队列中,在用户视角,为了即时响应用户操作我作为主线程是不是应该尽量马上执行这个回调函数?但前面还有这么多回调任务没执行,岂不是还要排队慢慢等?为了解决这个情况,「我们需要将任务队列里的回调任务进行一个优先级的区分」,让某些重要的回调任务能够更快被执行。于是就有了宏任务和微任务。

    微任务:由 JS 引擎自身发起的优先级最高的回调函数任务,它们被添加到微任务队列中,并「在当前宏任务执行结束后立即执行。」
    宏任务:宏任务是由宿主环境(如浏览器或 Node.js)发起的异步任务,通常需要较长时间执行。它们被添加到宏任务队列中,等待事件循环按顺序处理。
    宏队列:宏队列里根据任务类型划分为多个子队列,不同队列的优先级由事件循环的阶段决定。
    宏队列执行时机:每个宏任务执行完毕后,「事件循环会检查微任务队列并清空所有微任务,然后进入下一轮循环」
    微队列:一个单一队列,存放由 JS 引擎发起的微任务
    微队列执行时机:在「当前宏任务执行完毕后的 “检查点”,引擎会依次执行所有微任务,直到队列为空。若微任务中产生新微任务,也会一并执行」
    同一队列内:任务按入队顺序执行。
    宏队列中的延迟任务队列:会优先于其他宏任务执行,例如 setTimout 的回调相对于其他宏任务往往具有更高优先级。

具体请看下面的流程图

1.2.5 事件循环与页面渲染的协作

至此,你几乎已经可以将一开始遇到的问题解释一大半了,但是先别着急,我们还有最后一个问题,相信你也发现了,我一直在解释有关 JS 引擎线程内的任务运作原理,但没有提及 GUI 渲染线程内部任务的执行时机,也就是我们通过事件循环里的异步机制和宏任务微任务能够有效地处理耗时的 JS 代码以及更快的执行重要的 JS 代码,但「主线程不只是执行 JS 代码,它还要执行渲染页面的任务」,但是它到底是什么时候执行的呢?

首先「浏览器需要在 1 秒内绘制 60 帧的网页画面来使网页动起来,平均每 16.6ms 就要重新计算并渲染一遍网页」,也就是平均每隔 16.6ms 该渲染任务就要执行一遍,如果因为 JS 代码阻塞而超过了这个时间,就会出现 “丢帧”,也就是每秒画不出 60 张画面,看起来卡卡的。

那么结合网页渲染的时机与事件循环,我们可以将它们归结到一个总的过程中:

    「执行全局 JS 代码」:同步代码逐行执行,遇到异步代码交给其他线程执行,继续执行后续同步代码。
    「推入消息队列」:其他线程执行完任务后将回调函数根据任务类型推入相应的消息队列。
    「微任务优先」:待当前主线程正在执行的任务完成后,立即依次执行「所有微任务」直到微任务队列空。
    「取宏任务执行」:所有微任务完成后从宏任务队列中取出「一个最早的任务」(如定时器回调),交给主线程执行,如果当前执行的宏任务产生了新的微任务,则将这些微任务全部执行完毕,才进入到第 5 步。
    「检查渲染时机」:若距离上次渲染超过 16ms(60Hz 屏幕),或页面内容发生变化,则执行渲染任务,若没有,回到步骤 4 继续执行下一个宏任务。
    「循环往复」:重复上述步骤,直到所有队列为空。

我画了一个流程图,展示了浏览器事件循环结合页面渲染的具体流程方便理解

1.2.5. 小结

恭喜你,至此你已经深入了解浏览器运行网页的底层原理,现在你完全可以自己回答上面的问题并自己分析出为什么在使用 JS 向网页添加很多个元素时为什么网页会卡死了吧,就是因为 「JS 代码一次性生成了太多元素导致渲染的的执行时间过长,从而占用了主线程」「导致下一次渲染页面的任务无法及时执行,造成了网页卡死。接下来我们就用不同的方案来解决这个问题」 (画图好累,求点个赞赞鼓励鼓励🥲)

    解决方案

2.1. 方案一:setTimeout(宏任务分片, 推荐)

这个方案的原理就是「将一次性渲染 100 万个元素的任务进行分组」,比如按照一次任务只添加 10 个元素,然后把他们放在一个回调函数里交给能将代码变为异步执行的 webAPI,然后推入消息队列慢慢等待执行,这样每次新的渲染任务就只用渲染几十或几百个元素 (具体要看宏任务在下一次渲染前能被执行多少次, 不固定),压力大大减少,但要注意,我们不能在一次任务中通过循环一次性将所有 setTimeout 回调推入队列, 因为这样的话 10 万次的循环操作同样会阻塞主线程, 我们要使用递归来在当前任务生成下一个 setTimeout 回调

为了方便理解,我画了下面的原理图:

阻塞的情况:

解决原理:
 代码和效果图:

可以肉眼看到元素是分批被渲染到页面上的, 并且没有引起网页的卡顿, 证实了我们的想法, 解决了问题

2.2. 方案二:requestAnimationFrame(帧空闲期渲染, 推荐)

方案二的原理其实和方案一类似, 也是将任务进行分批处理, 然后使用 webAPI 把添加元素的代码变为异步推入到消息队列执行, 而与方案一不同的是, 我们这次换了一个 webAPI 来将任务推入消息队列; 使用 requestAnimationFrame 推入的回调函数会进入宏队列中的 UI 渲染队列, 这个队列里的任务会在 GUI 渲染线程将要执行渲染任务的前后执行, 具体来说, 「requestAnimationFrame 产生的回调函数会在 GUI 渲染线程将要执行渲染任务前执行。」

那么我们就可以利用 requestAnimationFrame 产生的回调函数会在下一次渲染前执行的特性, 在这一次的回调函数执行完后, 产生下一个添加元素的 requestAnimationFrame 任务, 精准地控制每次新渲染的元素数量, 而不会出现方案一的不确定情况, 具体看以下原理图:

为了验证我的想法, 我使用该 API 每次只渲染一个元素, 如果理想的话, 我们应该可以看到元素是一个一个被渲染到页面上的, 并且与渲染周期是同步的, 也就是每隔 16.6ms 渲染一次

可以明显看到, 元素是一个一个依次被进行渲染的, 证实了我的想法

PS: 方案一和方案二还有以下的区别:

    setTimeout 的执行会受到主线程繁忙程度影响,  真实的代码情况下我们不可能只有 setTimeout 这一个任务, 我们可能会有很多其他优先级比它高的任务要执行, 所以在一次事件循环中我们可能执行不到这个添加元素的回调, 从而无法严格的实现与屏幕刷新率同步, 而 requestAnimationFrame 却能够在每次浏览器进行重新渲染前严格地执行回调函数, 与屏幕刷新率 (60HZ) 同步,更加稳定
    setTimeoutAPI 的兼容性更好,能够在 node 环境下运行,而 requestAnimation 是浏览器独有的 API,所以无法在 node 环境下运行

2.3. 其他方案

2.3.1.MessageChannel(高优先级宏任务, 不推荐)

这个方案和方案一几乎一模一样, 只是换了一个 webAPI, 但是执行优先级比 setTimeout 的更高, 所以它会在一帧内执行更多次, 渲染速度相对最快, 但由于添加元素的不可控性, 所以在渲染时可能会因为元素过多而卡顿

2.3.2.requestIdleCallback(空闲时段处理, 不推荐)

这个方案与方案二几乎一模一样, 只是换了一个执行时机, requestAnimationFrame 是在渲染前, 而它是在渲染完成后如果有空闲时间才会执行, 直到这一帧 (16.6ms) 的时间走完, 所以它的执行与否要看每次的空闲时间剩多少, 但是它即不如方案一兼容性好也不如方案二执行的准确, 所以也不推荐

参考网站

juejin.cn/post/693711…[3]juejin.cn/post/725217…[4]


关注更多AI编程资讯请去AI Coding专区:https://juejin.cn/aicoding

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

浏览器 页面卡顿 事件循环 性能优化
相关文章