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

758 阅读13分钟

你是不是有过以下困难:

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

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

进程和线程

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

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

在 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》)

定时器

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

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

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

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

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

这段参考Tasks, microtasks, queues and schedules,谷歌开发者人员用实例讲述了任务执行顺序,并带有在线Demo,强烈建议过一遍(英语不好就逐句翻译)。 在JS中,主线程的任务叫 宏任务(MacroTask) ,宏任务执行完毕后,立即执行的任务叫 微任务(MicroTask)

宏任务:

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

微任务:

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

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

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

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

参考资料:

公众号:道道里的前端栈,每一天一篇前端文章,嚼碎的感觉真奇妙~