如何在浏览器中渲染100万个元素,并且保证页面不卡顿?超详细底层原理图文分享

前言:

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


先来看看下面的场景:

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

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

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

1.分析网页卡顿原因


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. 最主要的浏览器进程有:浏览器进程(负责界面显示、用户交互、子进程管理等)、网络进程(负责加载网络资源。网络进程内部会启动多个线程来处理不同的网络任务)、渲染进程(渲染进程启动后,会开启一个主线程,而这个主线程正是我们执行 JS 代码的地方,而该线程除了执行 JS 还负责解析HTML、解析CSS、计算样式、计算布局、每秒把页面画60次等等)
  4. 渲染进程的主线程可以理解为 GUI渲染线程 + JS 引擎线程,但他们并不独立,只是主线程的两个功能模块,重要的是,他们的运行是互斥的,也就是说他们不能在同一时间同时执行, 上面提到的解析HTML、解析CSS、计算样式、计算布局、每秒把页面画60次都是GUI渲染线程执行的任务,而运行全局 JS 代码、运行 JS 回调函数都是 JS 引擎线程执行的
  5. 我把上面的知识点全部总结为一张流程图,便于理解
  • 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张画面,看起来卡卡的。

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

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

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

1.2.5.小结

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


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

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

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

阻塞的情况:

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

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


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

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

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

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

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

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

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

其他方案

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

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

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

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

参考网站

juejin.cn/post/693711... juejin.cn/post/725217...

相关推荐
夕水24 分钟前
这个提升效率宝藏级工具一定要收藏使用
前端·javascript·trae
会飞的鱼先生38 分钟前
vue3 内置组件KeepAlive的使用
前端·javascript·vue.js
斯~内克1 小时前
前端浏览器窗口交互完全指南:从基础操作到高级控制
前端
Mike_jia1 小时前
Memos:知识工作者的理想开源笔记系统
前端
前端大白话1 小时前
前端崩溃瞬间救星!10 个 JavaScript 实战技巧大揭秘
前端·javascript
loveoobaby1 小时前
Shadertoy着色器移植到Three.js经验总结
前端
蓝易云2 小时前
在Linux、CentOS7中设置shell脚本开机自启动服务
前端·后端·centos
浩龙不eMo2 小时前
前端获取环境变量方式区分(Vite)
前端·vite
土豆骑士2 小时前
monorepo 实战练习
前端
土豆骑士2 小时前
monorepo最佳实践
前端