浏览器-原理
多进程基础
现代浏览器一般是多进程的架构的, 以Chrome为例, 可以分为如下一些进程:
- 浏览器进程(Browser Process): 负责管理Chrome本身, 包括地址栏, 书签, 前进, 后退按钮. 同时也负责一些后台进程, 比如网络请求, 文件访问等等. 还负责其他进程的调度
- 渲染进程(Renderer Process): 负责站点的渲染, 其中也包括js代码的运行, worker的管理等
- 插件进程(Plugin Process): 插件进程负责为浏览器提供各种额外的插件功能, 比如flash
- GPU进程(GPU Process): 负责提供成像功能
还有一些诸如扩展进程, 工具进程之类, 可以在chrome的task manager中查看其运行状况, 以及CPU和内存的占用情况等.
多进程架构的好处
当我们访问一个站点的时候, 渲染进程会负责运行站点的代码, 同时响应用户的交互动作, 当我们在Chrome中打开三个标签页, 同时访问三个站点的时候, 如果其中一个没有响应, 我们可以关闭它, 使用其他标签页.
另一个好处在于, 接触操作系统对进程安全的控制, 浏览器可以将页面放在沙箱中, 站点中运行的代码可以运行在隔离的环境中, 保证核心进程的安全.
虽然多进程的架构优于单进程, 但是由于进程独享私有内存, 这意味着会占用更多的内存空间. 为了节省内存, chrome限制了最大进程数量, 这个数量去绝对硬件的配置.
同时使用多个标签页访问相同的站点的时候, 浏览器不会创建新的渲染进程.
面向服务的架构
多进程是底层的设计, 在应用架构层面, chrome将功能以服务的方式提供. 在硬件条件允许的情况下, chrome会把一个服务用多个进程的方式去实现, 提高稳定性, 当资源紧张的时候, 则将多个服务放在一个进程中来节省资源.
基于站点隔离的渲染进程
利用iframe我们可以在同一个页面访问不同的站点, 从chrome67开始, 每个iframe也会被独立的渲染进程处理
一次访问
下面我们就从一次常见的访问入手,逐步了解浏览器是如何展示页面的。
Step 1:输入处理
当我们在地址栏中输入时,UI线程会先判断我们输入的内容是要搜索的内容还是要访问一个站点,因为地址栏同时也是一个搜索框。
Step 2:访问开始
当我们按下回车开始访问时,UI线程将借助网络线程访问站点资源. 浏览器页签的标题上会出现加载中的图标,同时网络线程会根据适当的网络协议,例如DNS lookup和TLS为这次请求建立连接。
Step 3:处理响应数据
当网络线程收到来自服务器的数据时,会试图从数据中的前面的一些字节中得到数据的类型(Content-Type),以试图了解数据的格式。
当返回的数据类型是HTML时,会将数据传递给渲染进程做进一步的渲染工作。但是如果数据类型是zip文件或者其他文件格式时,会将数据传递给下载管理器做进一步的文件预览或者下载工作。
在开始渲染之前,网络线程要先检查数据的安全性,这里也是浏览器保证安全的地方。如果返回的数据来自一些恶意的站点,网络线程会显示警告的页面。同时,Cross Origin Read Blocking(CORB)策略也会确保跨域的敏感数据不会被传递给渲染进程。
Step 4:渲染过程
当所有的检查结束后,网络线程确信浏览器可以访问站点时,网络线程通知UI线程数据已经准备好了。UI线程会根据当前的站点找到一个渲染进程完成接下来的渲染工作。
在第二步,UI线程将请求地址传递给网络线程时,UI线程就已经知道了要访问的站点。
此时UI线程就可以开始查找或启动一个渲染进程,这个动作与让网络线程下载数据是同时的。
如果网络线程按照预期获取到数据,则渲染进程就已经可以开始渲染了,这个动作减少了从网络线程开始请求数据到渲染进程可以开始渲染页面的时间。
当然,如果出现重定向的请求时,提前初始化的渲染进程可能就不会被使用了,但相比正常访问站点的场景,重定向往往是少数,在实际工作中,也需要根据特定的场景给出特定的方案,不必追求完美的方案。
Step 5:提交访问
经历前面的步骤,数据和渲染进程都已经准备好了。浏览器进程会通过IPC向渲染进程提交这次访问,同时也会保证渲染进程可以通过网络线程继续获取数据。一旦浏览器进程收到来自渲染进程的确认完毕的消息,就意味着访问的过程结束了,文档渲染的过程就开始了。
这时,地址栏显示出表明安全的图标,同时显示出站点的信息。访问历史中也会加入当前的站点信息。为了能恢复访问历史信息,当页签或窗口被关闭时,访问历史的信息会被存储在硬盘中。
Extra Step:加载完毕
当访问被提交给渲染进程,渲染进程会继续加载页面资源并且渲染页面。当渲染进程"结束"渲染工作,会给浏览器进程发送消息,这个消息会在页面中所有子页面(frame)结束加载后发出,也就是onLoad事件触发后发送。当收到"结束"消息后,UI线程会隐藏页签标题上的加载状态图标,表明页面加载完毕。
访问不同的站点
一次普通的访问到此就结束了。当我们输入另外一个地址时,浏览器进程会重复上面的过程。但是在开始新的访问前,会确认当前的站点是否关心beforeunload事件。
beforeunload事件可以提醒用户是否要访问新的站点或者关闭页签,如果用户拒绝则新的访问或关闭会被阻止。
由于所有的包括渲染、运行Javascript的工作都发生在渲染进程中,浏览器进程需要在新的访问开始前与渲染进程确认当前的站点是否关心unload。
如果一次访问是从一个渲染进程中发起的,例如用户点击一个链接或者运行JavaScript代码location = 'http://newsite.com'时,渲染进程首先检查beforeunload。然后再执行和浏览器进程初始化访问同样的步骤,只不过区别在于这样的访问请求是由渲染进程向浏览器进程发起的。
当新的站点请求被创建时,一个独立的渲染进程将被用于处理这个请求。为了支持像unload的事件触发,老的渲染进程需要保持住当前的状态。更详细的生命周期介绍可以参考Page lifecycle。
渲染进程负责页面的内容
渲染进程负责所有发生在浏览器页签中的事情。在一个渲染进程中,主线程负责解析,编译或运行代码等工作,当我们使用Worker时,Worker线程会负责运行一部分代码。合成线程和光栅线程是也是运行在渲染进程中的,负责更高效和顺畅的渲染页面。
渲染进程最重要的工作就是将HTML、CSS和Javascript代码转换成一个可以与用户产生交互的页面。
解析过程
这里我们介绍文本解析成图像的过程
DOM
DOM是一种浏览器内部用于表达页面结构的数据,同时也为Web开发者提供了操作页面元素的接口,让web开发者可以在Javascript代码中获取和操作页面中的元素。
将HTML文本转化成DOM的标准被HTML Standard定义。我们会发现在转化过程中浏览器从来不会抛出异常,类似关闭标签的丢失,开始、关闭标签匹配错误等等。这是因为HTML标准中定义了要静默的处理这些错误,如果对此感兴趣可以阅读An introduction to error handling and strange cases in the parser。
在渲染引擎的内部, 有一个叫HTML解析器(HTML Parser)的模块, 它的职责就是负责将HTML字节流转换为DOM结构, 所以我们这里需要先弄清出HTML解析是如何工作的.
首先, HTML并不是等HTML文档加载完成再开始解析的, 而是网络进程加载了多少数据, HTML解析器就解析多少数据.
详细的流程大致为:
- 网络进程接收到响应头, 会根据响应头中的
content-type字段来判断文件的类型, 比如content-type的值为text/html, 那么浏览器就会知道这是一个HTML类型的文件, 然后为该请求选择或者创建一个渲染进程. - 渲染进程准备好后, 网络进程和渲染进程之间会建立一个共享数据的管道, 网络进程接受到数据后就网这个管道中传输, 而渲染进程则从管道的另一个端不断的读取(流模型), 并同时将读取的数据提供给HTML解析器. HTML解析器会动态的接受字节流, 并解析为DOM
- 接下去就是解析器的工作了.
字节流转换为DOM需要三个阶段:
第一步, 通过分词器将字节流转换为Token

可以看出, Tag Token 可以分成StartTag 和 EndTag.
第二步和第三步是同步进行的, 需要将Token解析为DOM节点, 并将DOM节点添加到DOM树中
HTML 解析器维护了一个Token的栈结构, 该TOken栈主要用来计算节点之间的父子关系, 在第一个阶段中生成的Token会按照顺序被压倒这个栈中. 具体的规则如下:
- 如果压到栈中的是StartTag Token, HTML解析器会为该Token创建一个DOM节点, 然后将该节点加入到DOM树中. 它的父节点就是栈顶Token所对应的节点
- 如果分词器解析出来的是文本Token, 则将该节点加入到DOM树中, 文本Token不需要压入栈中, 它的父节点就是当前栈顶Token所对应的DOM节点
- 如果解析出来EndTag Token, HTML解析器会查看Token栈顶的元素是否是对应的StartTag Token, 如果是, 将STartTag从div从栈中弹出, 表示该div元素解析完成.
通过分词器产生的token就这样不停的压栈和出栈. 直到结束.
script 标签
如果在两段div中插入一段script标签, 当分词器解析到这里, 渲染引擎判断这是一段脚本, 此时HTML解析器就会暂停DOM的解析, 因为接下来js可能会修改当前已经生成的DOM结构.
此时, JS引擎介入, 并执行js脚本. 当执行结束后, HTML解析器恢复执行, 继续解析后面的tokens
额外资源的加载
一个网站通常还会使用类似图片,样式文件和JavaScript代码等额外的资源。这些资源也需要从网络或缓存中获取。主线程在转化HTML的过程中理应挨个加载它们,但是为了提高效率,预加载扫描(Preload Scanner)与转换过程会同时运行着。当预加载扫描在分析器分析HTML过程中发现了类似img或link这样的标签时,就会发送请求给浏览器进程的网络线程,而主线程会根据这些额外资源是否会阻塞转化过程而决定是否等待资源加载完毕。
样式计算
主线程会解析样式(CSS)并决定每个DOM元素的样式。这些样式取决于CSS选择器的范围,在浏览器开发者工具中我们可以看到这些信息。
布局(layout)
完成了样式计算工作后,渲染进程已经知道了DOM的结构和每个节点的样式,但是依然不足以渲染一个页面。
这时, 我们就需要布局信息.
布局是为元素指定集合信息的过程. 主线程遍历DOM结构中的元素机器样式, 同时创建出带有坐标和元素尺寸信息的布局树(Layout Tree). 布局树的结构和DOM树的结构比较的相似, 但只包含将会在页面中显示的元素.
绘制(Paint)
有了DOM、样式和布局还是无法完成渲染工作。试想,当我们试图复制一张图画。我们知道图画中元素的尺寸、形状和位置,我们还需要知道绘制这些元素的顺序。
例如,当一个元素z-index属性被设置后,绘制的顺序会导致渲染成错误的结果。
在这个阶段,主线程遍历布局树并创建绘制记录,绘制记录是一系列由绘制步骤组成的流程,例如先绘制背景,然后是文字,然后是形状。
渲染过程的消耗
在渲染过程中,任何一个步骤中产生的数据变化都会引起后续一系列的的变化。例如,当布局树改变时,绘制需要重构页面中变化的部分。
当一些元素有动画发生时,浏览器需要在每一帧中绘制这些元素。当无法保证每一帧绘制的连续性时,用户就会感觉到卡顿。
为了不影响渲染操作,我们可以将Javascript操作优化成小块,然后使用requestAnimationFrame(),关于如何优化可以参考Optimize JavaScript Exectuion。当需要大量计算时,也可以使用Worker来避免阻塞主进程。
合成(Composition)
现在,浏览器已经知道了文档结构、每一个元素的样式,元素的几何信息,绘制的顺序。将这些信息转化成屏幕上像素的过程叫做光栅化,光栅化是图形学的范畴。
传统的做法是将可视区域的内容进行光栅化。随着用户滚动页面,不断的光栅化更多的区域。然而对于现代浏览器,有着更复杂的的过程,这个过程被称做合成。

合成是一种将页面拆分成多层的技术,合成线程可以将各个层在不同线程中光栅化,再组合成一个页面。当滚动时,如果层已经被光栅化,则会使用已经存在的层合成新的帧,动画则可以通过移动层来实现。
层(Layer)
为了决定层包含哪些元素,主线程需要遍历布局树以找到需要生成的部分。对开发者来说,当某一部分需要用独立的层渲染,我们可以使用css属性will-change让浏览器创建层,关于浏览器如何生成层的标准可自行查阅。
虽然通过分层可以优化浏览器性能,但并不意味着应该给每个元素一个层,过多的层反而影响性能,所以在层的划分上应该具体形况具体分析。
栅格线程与合成线程
当布局树和绘制顺序确定以后,主线程会将这些信息提交给合成线程。合成线程会光栅化各个层。一个层包含的内容可能是一个完整的页面,也可能是页面的部分,所以合成线程将层拆分成许多块,并将它们发送给栅格线程。栅格线程光栅化这些块并将它们存储在GPU缓存中。
合成线程可以决定栅格线程光栅块的优先级,这样可以保证用户能看到的部分可以先被光栅化。一个层也会包含多种块以支持类似缩放这样的功能。
当块被光栅化后,合成线程会使用draw quads收集这些信息并创建合成帧(Compositor frame)。
Draw quads
存储在缓存中,包含类似块位置这样的信息,用于描述如何使用块合成页面。
Compositor frame
用于存储表现页面一帧中包含哪些Draw quads的集合。
然后一个合成帧被提交给浏览器进程。这时如果浏览器UI有变化,或者插件的UI有变化时,另一个合成帧就会被创建出来。所以每当有交互发生时,合成线程就会创建更多的合成帧然后通过GPU将新的部分渲染出来.

合成的好处在于其独立于主线程。合成线程不需要等待样式计算和Javascript代码的运行。这也是为什么合成更适合优化交互性能,但如果布局或者绘制需要重新计算则主线程是必须要参与的。
本质上,浏览器的渲染过程就是将文本转换成图像的过程,而当用户与页面发生交互动作时,则显示新的图像。在这个过程中由渲染进程中的主线程完成计算工作,由合成线程和栅格线程完成图像的绘制工作。而在计算过程中,还有强制布局、重排、重绘等更加细节的概念会在后面的文章中做讲解。
整体的渲染流程如下图所示:

从浏览器的角度看事件
当用户与页面发生交互时,浏览器进程首先接收到事件,然而,浏览器进程只关心事件发生时是在哪个页签中,所以浏览器进程会将事件类型和位置信息等发送给负责当前页签的渲染进程,渲染进程会恰当的找到事件发生的元素并且触发事件监听器。
合成线程对事件的处理
在前面的章节中,我们知道了合成线程可以通过合成技术合成不同的光栅层优化性能,如果页面并不监听任何事件,合成线程可以完全独立于主线程生成新的合成帧。但如果页面监听了事件呢?
标记“慢滚动”区域
由于运行Javascript是主线程的工作,当页面被合成线程合成过,合成线程会标记那些有事件监听的区域。有了这些信息,当事件发生在响应的区域时,合成线程就会将事件发送给主线程处理。如果在非事件监听区域,则渲染进程直接创建新的帧而不关心主线程。
在事件监听时标记
在web开发中常见的方式就是事件代理。利用事件冒泡,我们可以在目标元素的上层元素中监听事件。参照下面的代码。
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault();
}
});
通过这种写法,可以更高效的监听事件。但如果从浏览器的角度看,此时整个页面会被标记成“慢滚动”区域。这意味着虽然页面中的某些部分并不需要事件监听,但合成线程依然要在每次交互发生后等待主线程处理事件,合成线程的优化效果不复存在。
为了解决这个问题,我们可在事件代理时传入passive: true(IE不支持)参数。这样告诉渲染线程,依然需要将事件发送给主线程处理,但不需要等待。
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault()
}
}, {passive: true});
关于使用passive改善滚屏性能,可以参考MDN 使用passive改善滚屏性能。
查找事件目标
当渲染线程将事件发送给主线程后,第一件事就是找到事件触发的目标。通过在渲染过程中生成的绘制信息,可以根据坐标找到目标元素。
减少发送给主线程的事件数量
为了保证动画的顺畅,需要显示器在每秒刷新60次。对于典型的触摸事件由合成线程提交给主线程的事件频率可以达到每秒60-120次,对于典型的鼠标事件每秒会发送100次。事件发送的频率通常比屏幕刷新频率要高。
如果类似touchmove这样的事件每秒向主线程发送120次可能会造成主线程执行时间过长而影响性能。
为了减少发送给主线程的事件数量,Chrome合并了连续的事件。类似wheel,mousewheel,mousemove,pointermove,touchmove这样的事件会被延迟到下一次requestAnimationFrame前触发.

而任何的离散事件,类似keydown, keyup, mouseup, mousedown, touchstart和 touchend都会立即被发送给主线程处理。