# 浏览器渲染原理

本文提到的浏览器特指 Chrome / Chromium

# 进程和线程

# 进程

程序运行需要有它自己专属的内存空间,可以把这块内存空间简单的理解为「 进程 」

每个应用至少有一个进程,进程之间相互独立,即使要通信,也需要双方同意

# 线程

有了进程后,就可以运行程序的代码了, 运行代码的「基本单元」称之为「 线程 」

一个进程至少有一个线程,所以在进程开启后会自动创建一个线程来运行代码,该线程称之为主线程。 如果程序需要同时执行多块代码,主线程就会启动更多的线程来执行代码,所以一个进程中可以包含多个线程

# 浏览器进程模型

当代 chrome 是多进程和多线程的模型, 线程是不能单独存在的,它是由进程来启动和管理的; 一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程

  • 浏览器进程 主要负责界面显示、用户交互、子进程管理,同时提供存储等功能

  • 渲染进程 核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式

  • 插件进程 主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响

  • GPU 进程 其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程

  • NetworkService进程 主要负责页面的网络资源加载,网络进程内部会启动多个线程来处理不同的网络任务

  • 实用程序AudioService进程 AudioService 是用于处理音频的,也并非是一定要使用的

  • 实用程序StorageService进程 StorageService 是用于处理本地存贮的,包括 Storage (LocalStorage/SessionStorage)、Cache(CacheStorage/ApplicationCache)

  • 其他实用程序进程 包括备用渲染程序进程、V8代理解析工具进程 等等

# 什么是沙箱

可以把沙箱看成是操作系统给进程上了一把锁,沙箱里面的程序可以运行,但是不能在你的硬盘上写入任何数据,也不能在敏感位置读取任何数据,例如你的文档和桌面

Chrome 把插件进程和渲染进程锁在沙箱里面,这样即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取系统权限。安全问题得以解决

# 渲染进程

Chromium默认进程策略,会为每个 Tab 标签创建一个渲染进程。 当渲染进程启动后,会开启一个渲染主线程,该主线程负责执行HTMLCSSJavascript

# 渲染主线程

渲染主线程(Renderer Main Thread)是浏览器中最繁忙的线程, 它通过事件循环依次从消息队列中取出任务执行

# 主线程职责

需要主线程处理的任务包括但不限于以下内容

🔹 解析HTML

🔹 解析CSS

🔹 计算样式

🔹 布局

🔹 处理图层

🔹 每秒把页面画60次

🔹 执行全局JS代码

🔹 执行事件处理函数

🔹 执行计时器的回调函数

🔹 其他任务

# 主线程调度

要在单线程上处理这么多的任务,渲染主线程遇到了一个前所未有的难题, 那就是如何调度任务?

:::info 假设有如下场景❓

🔺 我正在执行一个JS函数,执行到一半的时候用户点击了按钮,我该立即去执行点击事件的处理函数吗?

🔺 我正在执行一个JS函数,执行到一半的时候某个计时器到达了时间,我该立即去执行它的回调吗?

🔺 浏览器进程通知我“用户点击了按钮”,与此同时,某个计时器也到达了时间,我应该处理哪一个呢? :::

征对以上问题, 工程师为浏览器主线程而设计了大名鼎鼎的事件循环来处理多个任务, 步骤如下

🔹 在最开始的时候,渲染主线程会进入一个无限循环

🔹 每一次循环会检查消息队列中是否有任务存在。如果有,就取出第一个任务执行,执行完一个后进入下一次循环;如果没有,则进入休眠状态。

🔹 其他所有线程(包括其他进程的线程)可以随时向消息队列添加任务。新任务会加到消息队列的末尾。在添加新任务时,如果主线程是休眠状态,则会将其唤醒以继续循环拿取任务 这样一来,就可以让每个任务有条不紊的、持续的进行下去了

# 异步的理解

如何理解`Javscript`的异步?

Javscript是一门单线程的语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个。 而渲染主线程承担着诸多的工作,渲染页面执行JS都在其中运行。

如果使用同步的方式执行耗时等待任务,就极有可能导致主线程产生阻塞,从而导致消息队列中的很多其他任务无法得到执行。 这样一来,一方面会导致繁忙的主线程白白的消耗时间,另一方面导致页面无法及时更新,给用户造成卡死现 象, 所以浏览器采用了异步的方式来执行耗时等待任务

具体做法是当某些任务发生时,比如计时器、网络、事件监听,主线程将任务交给其他线程去处理,自身该立即结束该任务的执行,转而执行后续代码。

当其他线程完成时,将事先传递的回调函数包装成任务,加入到消息队列的末尾排队,等待主线程调度执行 在这种异步模式下,浏览器永不阻塞,从而最大限度的保证了单线程的流畅运行

例如定时器任务执行过程如下: 主线程遇到setTimeout任务, 会立即将任务发送给计时器线程记录事件, 并结束该定时器任务

当计时完成,将原先的回调包装成任务添加到消息队列的末尾排队, 等待主线程的依次调用

概括

主线程是单线程, 且不允许被阻塞, 这就是异步产生的原因, 事件循环是异步的实现方式

# 消息循环

消息循环在Chrome的规范中又称为事件循环

事件循环定义

事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。在Chrome的源码中,它开启一个不会结束的for(;;)循环,每次循环从消息队列中取出第一个任务执行,而其 他线程只需要在合适的时候将任务加入到队列未尾即可。

过去把消息队列简单分为宏队列和微队列,这种说法目前已无法满足复杂的浏览器环境,取而代之的是一种更 加灵活多变的处理方式。

根据W3C官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的 队列。不同任务队列有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器 必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行

# 消息队列线程

消息队列(Message Loop Thread)里面包含了很多内部消息类型,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。

除此之外,消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。

# 队列的种类

Chrome至少实现了以下消息队列

  • 延时消息队列: 用于存放计时器到达后的回调任务,优先级「中」

  • 交互消息队列: 用于存放用户操作后产生的事件处理任务,优先级「高」

  • 微任务队列: 用户存放需要最快执行的任务,优先级「最高」

  • 其他任务队列

# 队列优先级

Chrome浏览器中既然有多种消息队列, 且消息队列是有优先级的,那优先级顺序如何的呢?

W3C 规范中已经对任务队列优先级做出规定 (opens new window)

优先级规定

  • 每个任务都有一个任务类型, 同一类型的任务必须在一个队列, 不同类型的任务可以分属于不同的队列

  • 浏览器必须准备好一个微任务队列(Micro Task Queue), 微任务队列中的任务优先于所有其他任务执行

注意

通过查看规范了解到,W3C 已经不再使用宏队列(Macro Task Queue)的说法了, 也正是因为浏览器的复杂度急剧提升

因此, 除开微任务队列, 将另外的任务队列视为普通消息队列, 在普通消息队列中全是一个个的宏任务, 宏任务执行过程中, 又会创建自己的微任务队列, 等待当前宏任务执行完成后, 接着开始执行微任务队列中的所有任务

# IO 线程

IO 线程(IO Thread)用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程,后续的步骤就和前面的“处理其他线程发送的任务”一样了

# 合成器线程

合成器线程(Compositor Thread)主要职责是

  • Input Handler; Hit Tester
  • Web Content 中的滚动与动画
  • 计算 Web Content 的最优分层
  • 协调图片解码、绘制、光栅化任务(helpers

其中,Compositor thread helpers 的数目取决于 CPU 核心数

# 光栅线程

光栅线程(Raster Thread)在Chromium渲染进程中用于异步处理图形和文本的光栅化任务,以提高渲染性能和确保页面的响应性。这有助于实现流畅的Web页面渲染和动画效果

# 工作线程

工作线程(Worker Threads)是在渲染进程中创建的线程,用于执行一些耗时的 JavaScript 任务,以提高网页的响应性和性能,并允许在后台执行计算密集型或异步操作。这对于创建更流畅的Web应用程序和避免主线程的阻塞非常有帮助

# 共享渲染进程

Chrome渲染进程 的默认策略(process-per-site-instance)是每个标签对应一个新的渲染进程,以保证不同的标签页之间不相互影响。

但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程,在下列场景下会共享相同的进程

  • 同源的页面使用a标签打开, 同时设置target='_blank' rel='opener'

  • 同源的页面使用window.open打开

如果想改变这种默认行为, 可以修改`opener`

  • 页面使用a标签, 同时设置target='_blank' rel='noopener noreferrer'

  • 页面使用window.open(url, '', ‘noopener’)

以上可以通过笔者的demos代码 (opens new window)进行测试, 打开Chrome的任务管理器进行核对

如果想更加深入了解这块的内容, 请查看浏览器共享进程

# 渲染流程

# 网络交互

  • 用户输入 URL 后, 浏览器进程判断是搜索还是输入的 URL, 合成完整的 URL

  • 浏览器进程通过 IPC 将 URL 请求发送到网络进程

  • 网络进程会查找本地缓存是否缓存了该资源。如果有缓存资源,那么直接返回资源给 浏览器进程;如果在缓存中没有查找到资源,那么直接进入网络请求流程

  • 网络进程首先通过 DNS 的迭代查询, 得到最终的 IP 地址 (浏览器DNS缓存 -> 操作系统DNS缓存 -> 路由器DNS缓存 -> ISP中DNS缓存 -> 通过UDP向根域名服务器发送查询请求 -> 顶级域名服务器请求 -> 次级域名服务器请求 -> 得到最终的IP地址)

  • 通过 IP 地址和服务器建立 TCP 链接, 再进行通信, 服务端发送响应内容到网络进程, 然后开始解析响应头 , 如果是 HTML, 则准备渲染进程, 主进程提交文档, 使渲染进程和网络进程之间建立通信管道

  • 接下来进入渲染流水线

# 渲染流水线

浏览器渲染

当浏览器的网络线程收到HTML文档后,会产生一个渲染任务,并将其传递给渲染主线程的消息队列。

在事件循环机制的作用下,渲染主线程取出消息队列中的渲染任务,开启渲染流程。

整个渲染流程分为多个阶段,分别是:HTML解析样式计算布局分层绘制分块光栅化 每个阶段都有明确的输入输出,上一个阶段的输出会成为下一个阶段的输入。 这样,整个渲染流程就形成了一套组织严密的生产流水线。

# 第一步 解析HTML (Parse)

解析过程中遇到CSS解析CSS,遇到JS执行JS。为了提高解析效率,浏览器在开始解析前,会启动一个 预解析的线程,率先下载HTML中的外部CSS文件和外部的JS文件。

如果主线程解析到link位置,此时外部的CSS文件还没有下载解析好,主线程不会等待,继续解析后续的 HTML。这是因为下载和解析CSS的工作是在预解析线程中进行的。这就是CSS不会阻塞HTML解析的根本 原因。

如果主线程解析到script位置,会停止解析HTML, 转而等待JS文件下载好,并将全局代码解析执行完成 后,才能继续解析HTML。这是因为JS代码的执行过程可能会修改当前的DOM树,所以D0M树的生成必 须暂停。这就是JS会阻塞HTML解析的根本原因。

第一步完成后,会得到DOM树CSSOM(CSS Object Model)树,浏览器的默认样式、内部样式、外部样式、行内样式均会包含 在CSSOM树中

# 第二步 样式计算 (Style)

主线程会遍历得到的DOM树,依次为树中的每个节点计算出它最终的样式,称之为Computed Style

在这一过程中,很多预设值会变成绝对值,比如red会变成rgb(255, 0, 0)相对单位会变成绝对单位,比 如em会变成px, 这一步完成后,会得到一棵带有样式的DOM

# 第三步 布局 (Layout)

接下来是布局,布局完成后会得到布局树

布局阶段会依次遍历DOM树的每一个节点,计算每个节点的几何信息。例如节点的宽高、相对包含块的位 置, 大部分时候,DOM树和布局树并非一一对应。

比如display:none的节点没有几何信息,因此不会生成到布局树;又比如使用了伪元素选择器,虽然DOM 树中不存在这些伪元素节点,但它们拥有几何信息,所以会生成到布局树中。还有匿名行盒、匿名块盒等等都 会导致DOM树和布局树无法一一对应

# 第四步 分层 (Layer)

下一步是分层, 主线程会使用一套复杂的策略对整个布局树中进行分层。 分层的好处在于,将来某一个层改变后,仅会对该层进行后续处理,从而提升效率。 滚动条、堆叠上下文、transformopacity等样式都会或多或少的影响分层结果,也可以通过will-change属性更大程度的影响分层结果

注意

不要滥用will-change来进行分层, 滥用会导致更加严重的渲染问题, 而不会提升效率

当元素频繁变动导致渲染,且这些元素未分为独立层,从而引发页面卡顿,出现效率问题的情况下使用

可以在chrome devtools -> More tools -> Layers里面查看分层

# 第五步 绘制 (Paint)

再下一步是绘制

主线程会为每个层单独产生绘制指令集,用于描述这一层的内容该如何画出来。完成绘制后,主线程将每个图层的绘制信息提交给合成线程,剩余工作将由合成线程完成。合成线程首先对每个图层进行分块,将其划分为更多的小区域。它会从线程池中拿取多个线程来完成分块(Tiling)工作

注意

此刻, 渲染主线程工作到此为止, 剩余步骤交给其他线程来完成

# 第六步 光栅化 (Raster)

分块完成后,进入光栅化阶段。

合成线程会将块信息交给GPU进程,以极高的速度完成光栅化。GPU进程会开启多个线程来完成光栅化,并且优先处理靠近视口区域的块。光栅化的结果,就是一块一块的位图

# 第七步 画 (Draw)

最后一个阶段就是画了

合成线程拿到每个层、每个块的位图后,生成一个个「DrawQuad」信息。DrawQuad会标识出每个位图应该画到屏幕的哪个位置,以及会考虑到旋转、缩放等变形。变形发生在合成线程,与渲染主线程无关,这就是transform效率高的本质原因。合成线程会把DrawQuad提交给浏览器GPU进程,由浏览器GPU进程产生系统调用,提交给GPU硬件,完成最终的屏幕成 像。

# 整体小结

综上所述, 大致流程如下:

  • 渲染进程将 HTML 内容转换为能够读懂的DOM 树结构。

  • 渲染引擎将 CSS 样式表转化为浏览器可以理解的styleSheets,计算出 DOM 节点的样式。

  • 创建布局树,并计算元素的布局信息。

  • 对布局树进行分层,并生成分层树

  • 为每个图层生成绘制列表,并将其提交到合成线程。

  • 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。

  • 合成线程发送绘制图块命令DrawQuad给浏览器进程。

  • 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上

# 重排和重绘

# DOM解析阻塞

  • CSS加载不会阻塞DOM树的解析
  • CSS加载会阻塞DOM树的生成
  • CSS加载会阻塞后面的JS语句的执行
  • JS执行会阻塞DOM树的解析和生成

# 强制更新布局

现代的浏览器都是很聪明的,由于每次重排都会造成额外的计算消耗,因此大多数浏览器都会通过队列化修改并批量执行来优化重排过程。浏览器会将修改操作放入到队列里,直到过了一段时间或者操作达到了一个阈值,才清空队列。但是!当你获取布局信息的操作的时候,会强制队列刷新,比如当你访问以下属性或者使用以下方法:

  • offsetTop、offsetLeft、offsetWidth、offsetHeight
  • scrollTop、scrollLeft、scrollWidth、scrollHeight
  • clientTop、clientLeft、clientWidth、clientHeight
  • getComputedStyle()
  • getBoundingClientRect

以上属性和方法都需要返回最新的布局信息,因此浏览器不得不清空队列,触发回流重绘来返回正确的值。因此,我们在修改样式的时候,最好避免使用上面列出的属性,他们都会刷新渲染队列。如果要使用它们,最好将值缓存起来。

# 重排 (Reflow)

浏览器一边生成渲染树, 一边计算每个元素最终的尺寸和位置。完成后, 页面中的所有元素的尺寸和位置就确定下来了, 即将被渲染到页面

  • 获取元素的尺寸和位置
  • 直接或间接改变元素的尺寸和位置(上述的强制更新布局)

重排的本质

重排的本质就是重新计算Layout

当进行了会影响布局树的操作后,需要重新计算布局树,会引发Layout

为了避免连续的多次操作导致布局树反复计算,浏览器会合并这些操作,当JS代码全部完成后再进行统一计算。所以,改动属性造成的Reflow是异步完成的

也同样因为如此,当JS获取布局属性时,就可能造成无法获取到最新的布局信息。浏览器在反复权衡下,最终决定获取属性立即Reflow

# 重绘 (Repaint)

浏览器一边Reflow, 一边进行生成对应的图形绘制到页面, 绘制的过程称之为Repaint。所有会导致Reflow的代码, 均会导致Repaint

  • 改变背景色
  • 改变字体颜色
  • 圆角边框
  • 背景图

重绘的本质

重绘的本质就是重新根据分层信息计算了绘制指令。 当改动了可见样式后,就需要重新计算,会引发Repaint。由于元素的布局信息也属于可见样式,所以reflow一定会引起Repaint

# 匿名包含块

浏览器在渲染的过程中, 会遵循以下两个规则

  • 文本内容必须在行盒中
  • 行盒和块盒不能相邻

例如下面的代码, 文本1必须在行盒中, 所以会生成匿名行盒, 文本2包含块石行盒不能与块盒相邻, 所以会生成匿名块盒

<div>
  <!-- 内部的文本会生成匿名行盒 -->
  <div>文本1</div>
  <!-- 此处会生成匿名块盒 -->
  <span>文本2</span>
</div>

# 页面更新

在浏览器中, EventLoop 微任务执行完毕后, 此时会检查界面是否需要更新, 那么浏览器到底如何更新界面呢?

  • 当 Eventloop 执行完 Microtasks 后,会判断 document 是否需要更新,因为浏览器是 60Hz 的刷新率,每 16.6ms 才会更新一次。

  • 然后判断是否有 resize 或者 scroll 事件,有的话会去触发事件,所以 resize 和 scroll 事件也是至少 16ms 才会触发一次,并且自带节流功能。

  • 判断是否触发了 media query

  • 更新动画并且发送事件

  • 判断是否有全屏操作事件

  • 执行 requestAnimationFrame 回调

  • 执行 IntersectionObserver 回调,该方法用于判断元素是否可见,可以用于懒加载上,但是兼容性不好

  • 更新界面

  • 以上就是一帧中可能会做的事情。如果在一帧中有空闲时间,就会去执行 requestIdleCallback 回调。

# 编译过程

JS 的编译和执行过程是非常复杂而又底层的内容, 而对于前端开发人员来说, 这块基本处于知识盲区; 经过查阅许多大佬的文章, 我画了一张概括图

图上做了汇总, 点击查看大图

JS编译过程分析

# 参考资料