# 浏览器渲染原理

# 浏览器的多进程架构

不同的浏览器使用不同的架构,下面主要以 Chrome 为例,介绍浏览器的多进程架构。 在 Chrome 中,主要的进程有 4 个:

  • 浏览器进程 (Browser Process):负责浏览器的 TAB 的前进、后退、地址栏、书签栏的工作和处理浏览器的一些不可见的底层操作,比如网络请求和文件访问。
  • 渲染进程 (Renderer Process):负责一个 Tab 内的显示相关的工作,也称渲染引擎。
  • 插件进程 (Plugin Process):负责控制网页使用到的插件
  • GPU 进程 (GPU Process):负责处理整个应用程序的 GPU 任务

首先,当我们是要浏览一个网页,我们会在浏览器的地址栏里输入 URL,这个时候 Browser Process 会向这个 URL 发送请求,获取这个 URL 的 HTML 内容,然后将 HTML 交给 Renderer Process,Renderer Process 解析 HTML 内容,解析遇到需要请求网络的资源又返回来交给 Browser Process 进行加载,同时通知 Browser Process,需要 Plugin Process 加载插件资源,执行插件代码。解析完成后,Renderer Process 计算得到图像帧,并将这些图像帧交给 GPU Process,GPU Process 将其转化为图像显示屏幕。

# 浏览器进程模式

为了节省内存,Chrome 提供了四种进程模式,不同的进程模式会对 tab 进程做不同的处理。

  • Process-per-site-instance (default) - 同一个  site-instance  使用一个进程
  • Process-per-site -  同一个  site  使用一个进程
  • Process-per-tab -  每个 tab 使用一个进程
  • Single process -  所有 tab 共用一个进程

其中,site指的是相同的域名跟协议,跟同源策略有些区别,同源策略要求子域名跟端口也必须相同。site-instance需要满足以下条件:

  • 用户通过<a target="_blank"> 这种方式点击打开的新页面
  • JS 代码打开的新页面(比如  window.open)

首先是Single process ,顾名思义,单进程模式,所有 tab 都会使用同一个进程。接下来是Process-per-tab  ,也是顾名思义,每打开一个 tab,会新建一个进程。而对于Process-per-site ,当你打开 a.baidu.com 页面,在打开 b.baidu.com 的页面,这两个页面的 tab 使用的是共一个进程,因为这两个页面的 site 相同,而如此一来,如果其中一个 tab 崩溃了,而另一个 tab 也会崩溃。 Process-per-site-instance   是最重要的,因为这个是 Chrome 默认使用的模式,也就是几乎所有的用户都在用的模式。当你打开一个 tab 访问 a.baidu.com ,然后再打开一个 tab 访问 b.baidu.com,这两个 tab 会使用两个进程。而如果你在 a.baidu.com 中,通过 JS 代码打开了 b.baidu.com 页面,这两个 tab 会使用同一个进程。

# 导航过程都发生了什么

从用户浏览网页这一简单的场景,来深入了解进程和线程是如何呈现我们的网站页面的。

# 网页加载过程

tab 以外的大部分工作由浏览器进程Browser Process 负责,针对工作的不同,Browser Process 划分出不同的工作线程:

  • UI thread:控制浏览器上的按钮及输入框;
  • network thread:处理网络请求,从网上获取数据;
  • storage thread:控制文件等的访问;

# 第一步:处理输入

当我们在浏览器的地址栏输入内容按下回车时,UI thread 会判断输入的内容是搜索关键词(search query)还是 URL,如果是搜索关键词,跳转至默认搜索引擎对应都搜索 URL,如果输入的内容是 URL,则开始请求 URL。

# 第二步:开始导航

回车按下后,UI thread 将关键词搜索对应的 URL 或输入的 URL 交给网络线程Network thread ,此时 UI 线程使 Tab 前的图标展示为加载中状态,然后网络进程进行一系列诸如 DNS 寻址,建立 TLS 连接等操作进行资源请求,如果收到服务器的 301 重定向响应,它就会告知 UI 线程进行重定向然后它会再次发起一个新的网络请求。

# 第三步:读取响应

network thread 接收到服务器的响应后,开始解析 HTTP 响应报文,然后根据响应头中的 Content-Type 字段来确定响应主体的媒体类型(MIME Type),如果媒体类型是一个 HTML 文件,则将响应数据交给渲染进程(renderer process)来进行下一步的工作,如果是 zip 文件或者其它文件,会把相关数据传输给下载管理器。 与此同时,浏览器会进行 Safe Browsing 安全检查,如果域名或者请求内容匹配到已知的恶意站点,network thread 会展示一个警告页。除此之外,网络线程还会做 CORB(Cross Origin Read Blocking)检查来确定那些敏感的跨站数据不会被发送至渲染进程。

# 第四步:查找渲染进程

各种检查完毕以后,network thread 确信浏览器可以导航到请求网页,network thread 会通知 UI thread 数据已经准备好,UI thread 会查找到一个 renderer process 进行网页的渲染。 浏览器为了对查找渲染进程这一步骤进行优化,考虑到网络请求获取响应需要时间,所以在第二步开始,浏览器已经预先查找和启动了一个渲染进程,如果中间步骤一切顺利,当 network thread 接收到数据时,渲染进程已经准备好了,但是如果遇到重定向,这个准备好的渲染进程也许就不可用了,这个时候会重新启动一个渲染进程。

# 第五步:提交导航

到了这一步,数据和渲染进程都准备好了,Browser Process   会向  Renderer Process   发送 IPC 消息来确认导航,此时,浏览器进程将准备好的数据发送给渲染进程,渲染进程接收到数据之后,又发送 IPC 消息给浏览器进程,告诉浏览器进程导航已经提交了,页面开始加载。 这个时候导航栏会更新,安全指示符更新(地址前面的小锁),访问历史列表(history tab)更新,即可以通过前进后退来切换该页面。

# 第六步:初始化加载完成

当导航提交完成后,渲染进程开始加载资源及渲染页面(详细内容下文介绍),当页面渲染完成后(页面及内部的 iframe 都触发了 onload 事件),会向浏览器进程发送 IPC 消息,告知浏览器进程,这个时候 UI thread 会停止展示 tab 中的加载中图标。

# 网页渲染原理

导航过程完成之后,浏览器进程把数据交给了渲染进程,渲染进程负责 tab 内的所有事情,核心目的就是将 HTML/CSS/JS 代码,转化为用户可进行交互的 web 页面。那么渲染进程是如何工作的呢? 渲染进程中,包含线程分别是:

  • 一个主线程(main thread)
  • 多个工作线程(work thread)
  • 一个合成器线程(compositor thread)
  • 多个光栅化线程(raster thread)

# 构建 DOM

当渲染进程接受到导航的确认信息后,开始接受来自浏览器进程的数据,这个时候,主线程会解析数据转化为 DOM(Document Object Model)对象。 DOM 为 WEB 开发人员通过 JavaScript 与网页进行交互的数据结构及 API。

# 资源子加载

在构建 DOM 的过程中,会解析到图片、CSS、JavaScript 脚本等资源,这些资源是需要从网络或者缓存中获取的,主线程在构建 DOM 过程中如果遇到了这些资源,逐一发起请求去获取,而为了提升效率,浏览器也会运行预加载扫描(preload scanner)程序,如果如果 HTML 中存在 img、link 等标签,预加载扫描程序会把这些请求传递给Browser Processnetwork thread 进行资源下载。

# JavaScript 的下载与执行

构建 DOM 过程中,如果遇到<script> 标签,渲染引擎会停止对 HTML 的解析,而去加载执行 JS 代码,原因在于 JS 代码可能会改变 DOM 的结构(比如执行 document.write()等 API) 不过开发者其实也有多种方式来告知浏览器应对如何应对某个资源,比如说如果在<script>   标签上添加了  async  或  defer  等属性,浏览器会异步的加载和执行 JS 代码,而不会阻塞渲染。

# 样式计算 - Style calculation

DOM 树只是我们页面的结构,我们要知道页面长什么样子,我们还需要知道 DOM 的每一个节点的样式。主线程在解析页面时,遇到<style> 标签或者<link> 标签的 CSS 资源,会加载 CSS 代码,根据 CSS 代码确定每个 DOM 节点的计算样式(computed style)。 计算样式是主线程根据 CSS 样式选择器(CSS selectors)计算出的每个 DOM 元素应该具备的具体样式,即使你的页面没有设置任何自定义的样式,浏览器也会提供其默认的样式。

# 布局 - Layout

DOM 树和计算样式完成后,我们还需要知道每一个节点在页面上的位置,布局(Layout)其实就是找到所有元素的几何关系的过程。 主线程会遍历 DOM 及相关元素的计算样式,构建出包含每个元素的页面坐标信息及盒子模型大小的布局树(Render Tree),遍历过程中,会跳过隐藏的元素(display: none),另外,伪元素虽然在 DOM 上不可见,但是在布局树上是可见的。

# 绘制 - Paint

布局 layout 之后,我们知道了不同元素的结构,样式,几何关系,我们要绘制出一个页面,我们要需要知道每个元素的绘制先后顺序,在绘制阶段,主线程会遍历布局树(layout tree),生成一系列的绘画记录(paint records)。绘画记录可以看做是记录各元素绘制先后顺序的笔记。

# 合成 - Compositing

什么是合成?合成是一种将页面分成若干层,然后分别对它们进行光栅化,最后在一个单独的线程 - 合成线程(compositor thread)里面合并成一个页面的技术。当用户滚动页面时,由于页面各个层都已经被光栅化了,浏览器需要做的只是合成一个新的帧来展示滚动后的效果罢了。页面的动画效果实现也是类似,将页面上的层进行移动并构建出一个新的帧即可。 为了实现合成技术,我们需要对元素进行分层,确定哪些元素需要放置在哪一层,主线程需要遍历渲染树来创建一棵层次树(Layer Tree),对于添加了  will-change  CSS 属性的元素,会被看做单独的一层,没有  will-change  CSS 属性的元素,浏览器会根据情况决定是否要把该元素放在单独的层。 一旦 Layer Tress 被创建,渲染顺序被确定,主线程会把这些信息通知给合成器线程,合成器线程开始对层次数的每一层进行光栅化。有的层的可以达到整个页面的大小,所以合成线程需要将它们切分为一块又一块的小图块(tiles),之后将这些小图块分别进行发送给一系列光栅线程(raster threads)进行光栅化,结束后光栅线程会将每个图块的光栅结果存在 GPU Process 的内存中。 为了优化显示体验,合成线程可以给不同的光栅线程赋予不同的优先级,将那些在视口中的或者视口附近的层先被光栅化。 当图层上面的图块都被栅格化后,合成线程会收集图块上面叫做绘画四边形(draw quads)的信息来构建一个合成帧(compositor frame)。 绘画四边形:包含图块在内存的位置以及图层合成后图块在页面的位置之类的信息。 合成帧:代表页面一个帧的内容的绘制四边形集合

以上所有步骤完成后,合成线程就会通过 IPC 向浏览器进程(browser process)提交(commit)一个渲染帧。这个时候可能有另外一个合成帧被浏览器进程的 UI 线程(UI thread)提交以改变浏览器的 UI。这些合成帧都会被发送给 GPU 从而展示在屏幕上。如果合成线程收到页面滚动的事件,合成线程会构建另外一个合成帧发送给 GPU 来更新页面。 合成的好处在于这个过程没有涉及到主线程,所以合成线程不需要等待样式的计算以及 JavaScript 完成执行。这就是为什么合成器相关的动画最流畅,如果某个动画涉及到布局或者绘制的调整,就会涉及到主线程的重新计算,自然会慢很多。

# 浏览器对事件的处理

以点击事件(click event)为例,让鼠标点击页面时候,首先接受到事件信息的是Browser Process ,但是Browser Process 只知道事件发生的类型和发生的位置,具体怎么对这个点击事件进行处理,还是由 Tab 内的Renderer Process 进行的。Browser Process 接受到事件后,随后便把事件的信息传递给了渲染进程,渲染进程会找到根据事件发生的坐标,找到目标对象(target),并且运行这个目标对象的点击事件绑定的监听函数(listener)。

# 渲染进程中合成器线程接收事件

由于执行 JS 是主线程的工作,当页面合成时,合成器线程会标记页面中绑定有事件处理器的区域为非快速滚动区域(non-fast scrollable region),如果事件发生在这些存在标注的区域,合成器线程会把事件信息发送给主线程,等待主线程进行事件处理,如果事件不是发生在这些区域,合成器线程则会直接合成新的帧而不用等到主线程的响应。

# 浏览器对事件的优化

一般我们屏幕的帧率是每秒 60 帧,也就是 60fps,但是某些事件触发的频率超过了这个数值,比如 wheel,mousewheel,mousemove,pointermove,touchmove,这些连续性的事件一般每秒会触发 60~120 次,假如每一次触发事件都将事件发送到主线程处理,由于屏幕的刷新速率相对来说较低,这样使得主线程会触发过量的命中测试以及 JS 代码,使得性能有了没必要是损耗。 出于优化的目的,浏览器会合并这些连续的事件,延迟到下一帧渲染是执行,也就是 requestAnimationFrame 之前。 而对于非连续性的事件,如 keydown,keyup,mousedown,mouseup,touchstart,touchend 等,会直接派发给主线程去执行。

# 总结

浏览器的多进程架构,根据不同的功能划分了不同的进程,进程内不同的使命划分了不同的线程,当用户开始浏览网页时候,浏览器进程进行处理输入、开始导航请求数据、请求响应数据,查找新建渲染进程,提交导航,之后渲染又进行了解析 HTML 构建 DOM、构建过程加载子资源、下载并执行 JS 代码、样式计算、布局、绘制、合成,一步一步的构建出一个可交互的 WEB 页面,之后浏览器进程又接受页面的交互事件信息,并将其交给渲染进程,渲染进程内主进程进行命中测试,查找目标元素并执行绑定的事件,完成页面的交互。