原创 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:
1.2.3. 异步机制
了解完上面的浏览器大致运行原理后,我们把目光聚焦于渲染进程内的主线程和其他线程,因为引起网页卡死的原因就出在这里,所以请让我详细讲解一下这部分的运行过程。
看着上面的模型,我们已知:GUI 渲染线程需要进行网页的渲染工作,也就是把我们的网页通过一个一个像素点 “画” 出来,并且每秒要将网页绘制 60 次来使我们的网页“动起来”,但是 GUI 渲染线程与 JS 引擎线程又不能同时执行(因为在 「JS 中可以直接操作 DOM 和 CSSOM」,如果在渲染过程中 JS 删除或增加了某个元素,那执行了一半的渲染线程应该怎么做?我是应该无视呢还是重新进行渲染呢?怎么选都有问题,是不是就冲突了,所以最好的方法就是他们不能同时执行),让我们试想一个情况,如果某段 JS 代码运行了很久,那么在这段时间里是不是就无法通过 GUI 绘制网页了?我们的网页是不是就卡死了?对吧,所谓的卡死不就是网页停留在某一帧没有继续画下去了,本质原因就是 「JS 的运行阻塞了网页的渲染。」
而 JS 为了避免这种情况,就引入了我们要讲的异步机制,这个机制使得 JS 能够在遇到需要耗时的任务时不会阻塞主线程,那么它具体是怎么实现的?
JS 将所有的任务分为了:同步任务和异步任务
异步任务一般是调用浏览器提供的 Web API 创建的,并且通常都无法在短时间内完成,常见的 Web API 有:
下面是我画的异步机制运行流程图,请根据提示一步步跟着流程走,你一定可以理解异步机制的运作原理。
1.2.4. 宏任务和微任务
若你能够理解上图所示的运行过程,那么你就了解了事件循环的大致流程,但这时又出现一个问题:已知我们可以通过其他线程执行异步任务,然后再通过回调函数推回任务队列,但是试想一个情况,我现在有很多由不同线程返回的回调函数在任务队列等待执行,但此时我点击了页面的一个按钮触发了回调函数,推入到任务队列中,在用户视角,为了即时响应用户操作我作为主线程是不是应该尽量马上执行这个回调函数?但前面还有这么多回调任务没执行,岂不是还要排队慢慢等?为了解决这个情况,「我们需要将任务队列里的回调任务进行一个优先级的区分」,让某些重要的回调任务能够更快被执行。于是就有了宏任务和微任务。
具体请看下面的流程图
1.2.5 事件循环与页面渲染的协作
至此,你几乎已经可以将一开始遇到的问题解释一大半了,但是先别着急,我们还有最后一个问题,相信你也发现了,我一直在解释有关 JS 引擎线程内的任务运作原理,但没有提及 GUI 渲染线程内部任务的执行时机,也就是我们通过事件循环里的异步机制和宏任务微任务能够有效地处理耗时的 JS 代码以及更快的执行重要的 JS 代码,但「主线程不只是执行 JS 代码,它还要执行渲染页面的任务」,但是它到底是什么时候执行的呢?
首先「浏览器需要在 1 秒内绘制 60 帧的网页画面来使网页动起来,平均每 16.6ms 就要重新计算并渲染一遍网页」,也就是平均每隔 16.6ms 该渲染任务就要执行一遍,如果因为 JS 代码阻塞而超过了这个时间,就会出现 “丢帧”,也就是每秒画不出 60 张画面,看起来卡卡的。
那么结合网页渲染的时机与事件循环,我们可以将它们归结到一个总的过程中:
我画了一个流程图,展示了浏览器事件循环结合页面渲染的具体流程方便理解
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: 方案一和方案二还有以下的区别:
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