(好文)一文讲解浏览器运行渲染机制、JS 任务队列及事件循环

全栈修炼

共 5730字,需浏览 12分钟

 ·

2021-03-15 07:44

你是不是有过以下困难:

多个方法互相嵌套,但是最终还是蒙对了不是很明白为什么浏览器有时候会卡死事件循环好像知道那么点,但是就是讲不出来为啥……

本篇文章就把你的问题给一一解答,当然这些东西想完弄清楚,肯定离不开进程线程浏览器内核渲染事件循环,任务队列等,我们就一个一个的来看,它们到底是怎么工作的。

进程和线程

举个例子,一个工厂,它有自己独立的资源,工厂和工厂之间相互独立,各自做各自的事情。一个场子可以有很多工人,工人可以 单个作业 也可以 协同作业,工人做的事情,都只会在自己的工厂内,并且共享这个工厂的空间。

我们现在把概念放到进程上,一个进程就相当于一个工厂,工厂里的资源就相当于系统分配的独立内存,多个工厂各自做各自的事情就相当于进程之间相互独立,一个工厂有很多工人就相当于一个进程可以有很多线程,工人的作业就相当于线程完成任务,工人共享这个工厂的空间,就相当于一个进程下面的线程之间可以共享程序的内存。

在 windows 的任务管理器中 CPU 和 内存 可以把每个进程的占用看的很清楚,当然 Mac OS 从活动监视器中也可以看到。所以:进程是 cpu 资源分配的最小单位,线程是cpu调度的最小单位

大家所说的多线程和单线程,都是只在一个进程内的多和单!

浏览器

构成

前提:页面是跑在浏览器上的,也就是说浏览器是页面的载体,浏览器会制定一套规则,页面满足了这个规则然后才可以在到浏览器上正常运行。

浏览器本质上其实是一个软件,它运行在一个操作系统上(windows 或 MacOS 或 其他),一般来说操作系统会开一个端口去运行这个软件,也就是为这个进程分配了CPU,内存 和 磁盘空间等。

那浏览器是单进程还是多进程呢?我们看一下:


可见它是个多个进程的浏览器!

在 Chrome 多进程架构里,它包括了四个进程:

Browser进程(负责地址栏、书签栏、前进后退、网络请求、文件访问等)Renderer进程(负责一个Tab内所有和网页渲染有关的所有事情,是最核心的进程GPU进程(负责GPU相关的任务)Plugin进程(负责Chrome插件相关的任务)

如果你打开它的任务管理器,你会发现:

上图我们可以看出:一个标签页就是一个进程,甚至一个扩展程序就是一个进程!在浏览器中打开一个网页就相当于新开了一个进程。

但是:在这里浏览器有自己的优化机制,有时候打开多个标签页,进程会合并,所以每一个标签页对应一个进程不是绝对的。

这样的多进程分配的好处是:

如果一个页面挂了,不会影响其他页面,甚至影响到整个浏览器避免安装的三方插件等影响了浏览器全局多进程充分利用了多核的优势把插件,扩展程序等全部隔离,提高稳定性

当然,缺点很就明显了,内存消耗大,确实有点像空间换时间的意思。

请求,响应

接下来我们看下浏览器是如何通过输入内容来请求成功的。

1.

当用户在地址栏输入内容时,UI线程首先问的是“这是搜索查询还是URL?”。在Chrome浏览器中,地址栏也是搜索输入字段,因此UI线程需要解析并决定是将您发送到搜索引擎还是请求的网站。


2.

当用户按下Enter键时,UI线程会发起网络调用以获取网站内容。加载微调框显示在选项卡的角上,并且网络线程通过相应的协议(例如DNS查找和为请求建立TLS连接)。

此时,网络线程可能会收到服务器重定向标头,例如HTTP301。在这种情况下,网络线程与服务器正在请求重定向的UI线程进行通信。然后,将启动另一个URL请求。


3.

一旦有响应了,网络线程将在必要时查看流的前几个字节。响应的Content-Type标头应说明它是什么数据类型,但是由于可能丢失或错误, 因此在此处进行MIME Type检查。

如果响应是HTML文件,则下一步是将数据传递到渲染器进程,但是如果是zip文件或其他文件,则意味着这是下载请求,因此它们需要将数据传递到下载管理器。




4.

网络线程从安全站点询问响应数据是否为HTML,并进行安全检查。





在这个时候,浏览器已经拿到响应了,接下来就开始进行渲染了。

5.

一旦完成所有检查,并且Network线程确信浏览器应导航到请求的站点,则Network线程将告知UI线程数据已准备就绪。然后,UI线程找到一个渲染器进程来进行网页渲染。





6.

现在已经准备好数据和渲染器进程,将IPC从浏览器进程发送到渲染器进程以提交导航。它还会传递数据流,因此渲染器进程可以继续接收HTML数据。一旦浏览器进程听到确认已在渲染器进程中进行提交的确认,导航即完成,文档加载阶段开始。

此时,地址栏已更新,安全指示符和站点设置UI反映了新页面的站点信息。选项卡的会话历史记录将被更新,因此后退/前进按钮将逐步浏览刚刚导航到的站点。为了方便在关闭选项卡或窗口时恢复选项卡/会话,会话历史记录存储在磁盘上。





到这里为止,浏览器的请求和响应就完成了。那在响应之后如何渲染呢,我们接着往下看

渲染

先说几个渲染进程内将要工作的线程:

主线程(Main thread):下载资源、执行js、计算样式、进行布局、绘制合成光栅线程(Raster thread)合成线程(Compositor thread)工作线程(Worker thread)

在下面的渲染过程中,其实就是这四个进程的互相配合,我们一起来看下吧。

1.

当渲染过程接收提交消息用于导航和开始接收HTML数据,主线程开始解析文本串(HTML),使之成为一个 Document Object Model ,也就是 DOM

2.

网站有用到图片,CSS 和JavaScript的话,这些东西需要从网络或者缓存中加载,主线程可以边请求,边预加载构建DOM。


3.

当HTML解析器找到 <script> 标签后,将会暂停HTML解析,并且必须加载、解析和执行 JavaScript的代码。为什么?因为JavaScript 可以使用诸如 document.write() 更改整个DOM结构!所以开发人员在写代码的时候可以在 <script> 标签上加 async 或者 defer 属性。然后浏览器将会异步加载并运行JavaScript,不会阻止解析。

4.

主线程解析CSS样式,并把CSS样式一一对应到DOM节点上,注意,此时CSS页面还没有生效,只是样式和节点绑定了关系。





5.

接下来CSS根据DOM节点,会生成类似于DOM结构的一个布局树,仅包含了页面上可见内容的信息,如果有 display: none 等,则该元素不属于布局树。如果有p::before {content:"123"} 等伪类的存在,就算它不在DOM中,也会包含在布局树中。





在此绘制步骤中,主线程遍历布局树以创建绘制记录。绘画记录是绘画过程的注释,例如“先是背景,然后是文本,然后是矩形”,类似 canvas

注意:在渲染的时候,每个步骤前面操作的结果都用于创建新数据,如果布局树发生了更改,文档受影响的部分就会重新绘制,也就是 重绘,开发过程中要尽量避免这一现象。

6.

至此浏览器知道了:文档的结构,每个DOM元素的样式,页面的几何形状以及绘制的顺序。把这些东西换转为屏幕上像素我们称之为 光栅化。在现代浏览器中执行这一行为的过程,称为 **合成(Compositing)**,就是把页面各个部分分成若干层,分别进行栅格化,然后合成器线程的单独线程中进行合成,一个层可以称之为一个 layer。





7.

层分好了并确定了顺序之后,主线程就把这个信息提交给合成线程,然后合成器线程把每个图层栅格化,发送给栅格线程,栅格线程把它们存储在GPU内存内。





8.

最终,合成线程将栅格化的块合成帧,并通过IPC传递给浏览器进程,显示在屏幕上。






至此,浏览器的请求,响应和渲染过程结束!

(一半了,稍微休息一下,我们再继续!)

JS单线程

回顾一下,浏览器的渲染进程中,主线程里包括了执行JS,那也就意味着:

JS在浏览器的 渲染进程(Rendered Process) 的 主线程(Main Thread) 内



记住:JS是被设计成单线程的!

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?—— 阮一峰

所以叙述出来就是:JS逻辑 和 UI渲染 是在一个线程中顺序发生的,二者同一时间只可以存在一个。继续回顾一下上面渲染所提到的,HTML解析器必须等待JS运行,JS是可以操作DOM 和 布局树的,会干扰到主线程在解析HTML的顺序,从而影响结果,所以为了页面的渲染统一,JS被设计成了 执行阻塞UI渲染型。

同时也反映出了一个问题:JS过多会造成页面卡顿,因为走不下去了。所以JS的逻辑一定不能冗余。

任务队列

既然JS是单线程,也就意味着里面的逻辑是排队运行的,后一个任务必须等前一个结束才可以运行。这样就会出一个问题,有没有一种可能是挂起不那么重要的任务,先走重要的,等结束之后再执行挂起的任务呢?

按照这个说法的话,所有任务就可以分成:同步任务(sync) 和 异步任务(async) ,同步任务就是主线程里面的排队进行,异步任务就是不进入主线程,进入一个 “任务队列(task queue)” 的地方呆着,看着主线程里的任务进行,一旦发现主线程的同步任务执行完了,就通知主线程,说我这里的异步任务可以执行了,该任务才会进入主线程执行。

所以有没有发现,那些鼠标点击事件,页面滚动,回调函数,http请求……其实就在任务队列里面。

事件循环(Event Loop)

概念

有了任务队列的存在,就会有事件循环的存在,因为任务队列中可能有很多任务,一个在任务队列的任务进入到主线程后,任务队列依然会看着主线程,看看刚进去的这个有没有执行完毕,毕竟任务队列里还有很多没执行的任务,所以主线程去读取任务队列是循环不断的,也就叫做了 事件循环

这里放张网图,基本上一看就明白了(参考自Philip Roberts的演讲《Help, I'm stuck in an event-loop[1]》)


定时器

这个有点特殊,单独讲一下。

定时器不是个异步事件,是一个定时事件,但是仍属于一个回调操作,是被放在任务队列中的。

就算定时器被设置的时间是0,它也仍然会在主线程逻辑走完之后(此时栈清空了),再执行,所以时间是0的定时器,它可以被理解为希望尽早的执行。

需要注意的是,setTimeout()只是将事件插入了"任务队列",必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()指定的时间执行。——阮一峰

微任务(MicroTask)和宏任务(MacroTask)

这段参考Tasks, microtasks, queues and schedules[2],一位谷歌开发者人员用实例讲述了任务执行顺序,并带有在线Demo,强烈建议过一遍(英语不好就逐句翻译)。

在JS中,主线程的任务叫 宏任务(MacroTask) ,宏任务执行完毕后,立即执行的任务叫 微任务(MicroTask) 。

宏任务:

主线程已经存在了的任务叫宏任务,从任务队列中进入主线程的任务也叫宏任务,一个宏任务执行过程中,从头到尾不会执行其他的东西浏览器会在一个宏任务结束后,在下一个宏任务开始前,对页面进行重新渲染

微任务:

当前宏任务执行结束后立即执行的任务叫微任务,也就是说它在前宏任务之后,后宏任务之前,渲染之前!它的速度比定时器要快,因为不用等待渲染,定时器是宏任务在一个宏任务执行结束后,所有的微任务都会执行完毕(渲染前)

基于上面的概念,我们可以给常用的任务分下类:

宏任务:主代码,setTimeout,setInterval,setImmediate,requestAnimationFrame,I/O,UI渲染微任务:Promise,process.nextTick,MutationObserve,queueMicrotask

当然 Vue 中的 nextTick 也就属于微任务了,最后放一张图帮助一下理解:



参考资料:

JavaScript 运行机制详解:再谈Event Loop[3]

Event Loops[4]

浅谈浏览器架构、单线程js、事件循环、消息队列、宏任务和微任务[5]

浏览器多进程到JS单线程,JS运行机制最全面的一次梳理[6]

Inside look at modern web browser (part 2)[7]

Inside look at modern web browser (part 3)[8]

overview-of-the-parsing-model[9]

上最全!图解浏览器的工作原理[10]



可以加超级猫的 wx:CB834301747 ,一起闲聊前端。

微信搜 “前端GitHub”,回复 “电子书” 即可以获得 160 本前端精华书籍哦。


往期精文

浏览 28
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报