架构
进程和线程
进程可以被描述为是一个应用的执行程序。
线程存在于进程并执行程序任意部分。
启动应用时会创建一个进程。程序也许会创建一个或多个线程来帮助它工作,这是可选的。
操作系统为进程提供了一个可以使用的“一块”内存,所有应用程序状态都保存在该私有内存空间中。
关闭应用程序时,相应的进程也会消失,操作系统会释放内存。
进程可以请求操作系统启动另一个进程来执行不同的任务。
此时,内存中的不同部分会分给新进程。如果两个进程需要对话,他们可以通过进程间通信(IPC)来进行。
许多应用都是这样设计的,所以如果一个工作进程失去响应,该进程就可以在不停止应用程序不同部分的其他进程运行的情况下重新启动。
浏览器架构
那么如何通过进程和线程构建 web 浏览器呢?它可能由一个拥有很多线程的进程,或是一些通过 IPC 通信的不同线程的进程。
在2016年,Chrome官方团队使用“面向服务的架构”(Services Oriented Architecture,简称SOA)的思想设计了新的Chrome架构。
如果在资源受限的设备上(如下图),Chrome会将很多服务整合到一个进程中,从而节省内存占用。
进程 | 控制 |
---|---|
浏览器进程 | 主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。 |
渲染进程 | 核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎Blink和JavaScript引擎V8都是运行在该进程中,默认情况下,Chrome会为每个Tab标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。 |
插件进程 | 主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。 |
网络进程 | 主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。 |
GPU进程 | 在独立的进程中处理GPU任务。之所以放到独立的进程,是因为GPU要处理来自多个应用的请求,但要在同一个界面上绘制图形。 |
SOA架构的优点
稳定且流畅
由于进程是相互隔离的,所以当一个页面或者插件崩溃时,影响到的仅仅是当前的页面进程或者插件进程,并不会影响到浏览器和其他页面,这就完美地解决了页面或者插件的崩溃会导致整个浏览器崩溃,也就是不稳定的问题。
同理,JavaScript也是运行在渲染进程中的,所以即使JavaScript阻塞了渲染进程,影响到的也只是当前的渲染页面,而并不会影响浏览器和其他页面。
安全沙箱
SOA架构还有助于安全和隔离。因为操作系统有限制进程特权的机制,浏览器可以借此限制某些进程的能力。
比如,Chrome会限制处理任意用户输入的渲染器进程,不让它任意访问文件。
更内聚,松耦合,易于维护和扩展
原来的各种模块被重构成独立的服务(Service),每个服务(Service)都可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,通过IPC来通信,从而构建一个更内聚、松耦合、易于维护和扩展的系统,更好实现 Chrome 简单、稳定、高速、安全的目标。
站点隔离
站点隔离(Site Isolation for web developers)是新近引入Chrome的一个里程碑式特性,即每个跨站点iframe都运行一个独立的渲染器进程。
即便像前面说的那样,每个标签页单开一个渲染器进程,但允许跨站点的iframe运行在同一个渲染器进程中并共享内存空间,那安全攻击仍然有可能绕开同源策略,而且有人发现在现代CPU中,进程有可能读取任意内存( Meltdown/Spectre )。
进程隔离是隔离站点、确保上网安全最有效的方式。
Chrome 默认采用站点隔离。站点隔离是多年工程化努力的结果,它并非多开几个渲染器进程那么简单。
比如,不同的iframe运行在不同进程中,开发工具在后台仍然要做到无缝切换,而且即便简单地Ctrl+F
查找也会涉及在不同进程中搜索。
导航
导航涉及浏览器进程与线程间为显示网页而通信。一切从用户在浏览器中输入一个URL开始。输入URL之后,浏览器会通过互联网获取数据并显示网页。从请求网页到浏览器准备渲染网页的过程,叫做导航。
下面我们逐步看一看导航的几个步骤。
第一步:处理输入。
UI线程会判断用户输入的是查询字符串还是URL。因为Chrome的地址栏同时也是搜索框。
第二步:开始导航。
如果输入的是URL,首先,网络进程会查找本地缓存是否缓存了该资源。
如果有缓存资源,那么直接返回资源给浏览器进程。
如果在缓存中没有查找到资源,那么UI线程会通知网络线程发起网络调用,获取网站内容。
此时标签页左端显示旋转图标,网络线程进行DNS查询、建立TLS连接(对于HTTPS)。
网络线程可能收到服务器的重定向头部,如HTTP 301。此时网络线程会跟UI线程沟通,告诉它服务器要求重定向。然后,再发起对另一个URL的请求。
第三步:读取响应。
服务器返回的响应体到来之后,网络线程会检查接收到的前几个字节。响应的Content-Type头部应该包含数据类型,如果没有这个字段,则需要MIME类型嗅探。
如果响应是HTML文件,那下一步就是把数据交给渲染器进程。但如果是一个zip文件或其他文件,那就意味着是一个下载请求,需要把数据传给下载管理器。
此时也是“安全浏览”检查的环节。如果域名和响应数据匹配已知的恶意网站,网络线程会显示警告页。
此外,CORB (Cross Origin Read Blocking) 检查也会执行,以确保敏感的跨站点数据不会发送给渲染器进程。
第四步:联系渲染器进程。
所有查检完毕,网络线程确认浏览器可以导航到用户请求的网站,于是会通知UI线程数据已经准备好了。UI线程会联系渲染器进程渲染网页。
打开一个新页面采用的渲染进程策略:
- 通常情况下,打开新的页面都会使用单独的渲染进程;
如果从A页面打开B页面,且A和B都属于同一站点的话,那么B页面复用A页面的渲染进程;如果是其他情况,浏览器进程则会为B创建一个新的渲染进程。
具体地讲,我们将“同一站点”定义为根域名(例如,baidu.com)加上协议(例如,
https://
或者http://
),还包含了该根域名下的所有子域名和不同的端口。https://WWW.baidu.com https://WWW.baidu.com:8080
由于网络请求可能要花几百毫秒才能拿到响应,这里还会应用一个优化策略。第二步UI线程要求网络线程发送请求后,已经知道可能要导航到哪个网站去了。因此在发送网络请求的同时,UI线程会提前联系或并行启动一个渲染器进程。这样在网络线程收到数据后,就已经有渲染器进程原地待命了。如果发生了重定向,这个待命进程可能用不上,而是换作其他进程去处理。
第五步:提交导航。
数据和渲染器进程都有了,就可以通过IPC从浏览器进程向渲染器进程提交导航。渲染器进程也会同时接收到不间断的HTML数据流。
当浏览器进程收到渲染器进程的确认消息后,导航完成,文档加载阶段开始。
此时,地址栏会更新,安全指示图标和网站设置UI也会反映新页面的信息。
当前标签页面的会话历史会更新,后退/前进按钮起作用。为便于标签页/会话在关闭标签页或窗口后恢复,会话历史会写入磁盘。
最后一步:初始加载完成。
提交导航之后,渲染器进程将负责加载资源和渲染页面(具体细节后面介绍)。
而在“完成”渲染后(在所有iframe中的onload
事件触发且执行完成后),渲染器进程会通过IPC给浏览器进程发送一个消息。此时,UI线程停止标签页上的旋转图标。
初始加载完成后,客户端JavaScript仍然可能加载额外资源并重新渲染页面。
如果此时用户在地址又输入了其他URL呢?浏览器进程还会重复上述步骤,导航到新站点。不过在此之前,需要确认已渲染的网站是否关注beforeunload
事件。因为标签页中的一切,包括JavaScript代码都由渲染器进程处理,所以浏览器进程必须与当前的渲染器进程确认后再导航到新站点。
如果导航请求来自当前渲染器进程(用户点击了链接或JavaScript运行了window.location = "https://newsite.com"
),渲染器进程首先会检查beforeunload
处理程序。然后,它会走一遍与浏览器进程触发导航同样的过程。唯一的区别在于导航请求是由渲染器进程提交给浏览器进程的。
导航到不同的网站时,会有一个新的独立渲染器进程负责处理新导航,而老的渲染器进程要负责处理unload
之类的事件。
更多细节,可以参考【译】页面生命周期API以及Web 页面生命周期。
另外,导航阶段还可能涉及【中】Service Worker,即网页应用中的网络代理服务,开发者可以通过它控制什么缓存在本地,何时从网络获取新数据。
Service Worker说到底也是需要渲染器进程运行的JavaScript代码。如果网站注册了Server Worker,那么导航请求到来时,网络线程会根据URL将其匹配出来,此时UI线程就会联系一个渲染器进程来执行Service Worker的代码:可能只要从本地缓存读取数据,也可能需要发送网络请求。
如果Service Worker最终决定从网络请求数据,浏览器进程与渲染器进程间的这种往返通信会导致延迟。
因此,这里会有一个“导航预加载”的优化Speed up Service Worker with Navigation Preloads,即在Service Worker启动同时预先加载资源,加载请求通过HTTP头部与服务器沟通,服务器决定是否完全更新内容。
渲染
渲染是渲染器进程内部的工作,涉及Web性能的诸多方面(详细内容可以参考这里Why does speed matter?)。
标签页中的一切都由渲染器进程负责处理,其中主线程负责运行大多数客户端JavaScript代码,少量代码可能会由工作线程处理(如果用到了Web Worker或Service Worker)。
合成器(compositor)线程和栅格化(raster)线程负责高效、平滑地渲染页面。
渲染器进程的核心任务是把HTML、CSS和JavaScript转换成用户可以交互的网页接下来,我们从整体上过一遍渲染器进程处理Web内容的各个阶段。
解析HTML
构建DOM
渲染器进程收到导航的提交消息后,开始接收HTML,其主线程开始解析文本字符串(HTML),并将它转换为DOM(Document Object Model,文档对象模型)。
DOM是浏览器内部对页面的表示,也是JavaScript与之交互的数据结构和API。
如何将HTML解析为DOM由HTML标准定义。HTML标准要求浏览器兼容错误的HTML写法,因此浏览器会“忍气吞声”,绝不报错。详情可以看看“解析器错误处理及怪异情形简介”。
加载子资源
网站都会用到图片、CSS和JavaScript等外部资源。浏览器需要从缓存或网络加载这些文件。主线程可以在解析并构建DOM的过程中发现一个加载一个,但这样效率太低。
为此,Chrome会在解析同时并发运行“预加载扫描器”,当发现HTML文档中有<img>
或<link>
时,预加载扫描器会将请求提交给浏览器进程中的网络线程。
JavaScript可能阻塞解析
如果HTML解析器碰到<script>
标签,会暂停解析HTML文档并加载、解析和执行JavaScript代码。
因为JavaScript有可能通过document.write()
修改文档,进而改变DOM结构(HTML标准的“解析模型”有一张图可以一目了然)。所以HTML解析器必须停下来执行JavaScript,然后再恢复解析HTML。至于执行JavaScript的细节,大家可以关注V8团队相关的分享:【译】 JavaScript 引擎基础:Shapes 和 Inline Caches。
提示浏览器你要加载资源
为了更好地加载资源,可以通过很多方式告诉浏览器。如果JavaScript没有用到
document.write()
,可以在<script>
标签上添加async
或defer
属性。这样浏览器就会异步运行JavaScript代码,不会阻塞解析。合适的话,可以考虑使用JavaScript modules。再比如,<link rel="preload">
告诉浏览器该资源对于当前导航绝对必要,应该尽快下载。关于资源加载优先级,可以参考这里:【译】Fast load times。
计算样式(Recalculate style)
光有DOM还不行,因为并不知道页面应该长啥样。所以接下来,主线程要解析CSS并计算每个DOM节点的样式。这个过程就是根据CSS选择符,确定每个元素要应用什么样式。在Chrome开发工具“计算的样式”(computed)中可以看每个元素计算后的样式。
1.把CSS转换为浏览器能够理解的结构
CSS样式来源主要有三种:
- 通过
link
引用的外部CSS文件; - <style>标记内的 CSS;
- 元素的
style
属性内嵌的CSS。 - 和HTML文件一样,浏览器也是无法直接理解这些纯文本的CSS样式,所以当渲染引擎接收到CSS文本时,会执行一个转换操作,将CSS文本转换为浏览器可以理解的结构——
styleSheets
。 - 为了加深理解,你可以在Chrome控制台中查看其结构,只需要在控制台中输入
document.styleSheets
,然后就看到如下图所示的结构, 该数据结构同时具备了查询和修改功能。
2.转换样式表中的属性值,使其标准化
3.计算出DOM树中每个节点的具体样式
这就涉及到CSS的继承规则和层叠规则了。
首先是CSS继承。
- 首先,可以选择要查看的元素的样式(位于图中的区域2中),在图中的第1个区域中点击对应的元素元素,就可以了下面的区域查看该元素的样式了。比如这里我们选择的元素是
<p>
标签,位于html.body.div.
这个路径下面。 - 其次,可以从样式来源(位于图中的区域3中)中查看样式的具体来源信息,看看是来源于样式文件,还是来源于UserAgent样式表。这里需要特别提下UserAgent样式,它是浏览器提供的一组默认样式,如果你不提供任何样式,默认使用的就是UserAgent样式。
- 最后,可以通过区域2和区域3来查看样式继承的具体过程。
样式计算过程中的第二个规则是样式层叠。层叠是CSS的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。
布局(Layout)
到这一步,渲染器进程知道了文档的结构,也知道了每个节点的样式。但基于这些信息仍然不足以渲染页面。
布局,就是要找到元素间的几何位置关系。主线程会遍历DOM元素及其计算样式,然后构造一棵布局树,这棵树的每个节点将带有坐标和大小信息。
布局树与DOM树的结构类似,但只包含页面中可见元素的信息。如果元素被应用了display: none
,则布局树中不会包含它(visibility: hidden
的元素会包含在内)。类似地,通过伪类p::before{content: 'Hi!'}
添加的内容会包含在布局树中,但DOM树中却没有。
为了构建布局树,浏览器大体上完成了下面这些工作:
- 遍历DOM树中的所有可见节点,并把这些节点加到布局中;
- 而不可见的节点会被布局树忽略掉,如
head
标签下面的全部内容,再比如body.p.span
这个元素,因为它的属性包含dispaly:none
,所以这个元素也没有被包进布局树。
确定页面的布局要考虑很多很多因素,并不简单。比如,字体大小、文本换行都会影响段落的形状,进而影响后续段落的布局。CSS可让元素浮动到一边、隐藏溢出边界的内容、改变文本显示方向。可想而知,布局阶段的任务是非常艰巨的。Chrome有一个工程师团队专司布局,感兴起的话,可以看看他们这个分享:BlinkOn 8: Block Layout Deep Dive(在YouTube上)。
更新了元素的几何属性(重排)
从上图可以看出,如果你通过JavaScript或者CSS修改元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的
分层(Layer)
因为页面中有很多复杂的效果,如一些复杂的3D变换、页面滚动,或者使用z-indexing做z轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。
要想直观地理解什么是图层,你可以打开Chrome的“开发者工具”,选择“Layers”标签(开发者工具 -> More tools ->Layers),就可以可视化页面的分层情况。
通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。
那么需要满足什么条件,渲染引擎才会为特定的节点创建新的层呢?
第一点,拥有层叠上下文属性的元素会被提升为单独的一层。
从图中可以看出,明确定位属性的元素、定义透明属性的元素、使用CSS滤镜的元素等,都拥有层叠上下文属性。
第二点,需要剪裁(clip)的地方也会被创建为图层。
<style>
div {
width: 200;
height: 200;
overflow:auto;
background: gray;
}
</style>
<body>
<div >
<p>所以元素有了层叠上下文的属性或者需要被剪裁,那么就会被提升成为单独一层,你可以参看下图:</p>
<p>从上图我们可以看到,document层上有A和B层,而B层之上又有两个图层。这些图层组织在一起也是一颗树状结构。</p>
<p>图层树是基于布局树来创建的,为了找出哪些元素需要在哪些层中,渲染引擎会遍历布局树来创建层树(Update LayerTree)。</p>
</div>
</body>
在这里我们把div的大小限定为200 200像素,而div里面的文字内容比较多,文字所显示的区域肯定会超出200 200的面积,这时候就产生了剪裁。
出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。
如果页面某些部分应该独立一层(如滑入的菜单)但却没有,那你可以在CSS中给它加上will-change
属性来提醒浏览器。
分层并不是越多越好,合成过多的层有可能还不如每帧都对页面中的一小部分执行一次栅格化更快。关于这里边的权衡,可以参考:坚持仅合成器的属性和管理层计数。
图层绘制(Paint)
在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,那么接下来我们看看渲染引擎是怎么实现图层绘制的?
渲染引擎实现图层的绘制,会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:
从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。
而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。
更新元素的绘制属性(重绘)
从图中可以看出,如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。
栅格化(raster)
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。
当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程。
- 通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。
有些情况下,图层很大,但是通过视口,用户只能看到页面的很小一部分。所以这种情况下,要绘制出所有图层内容的话,没有必要。
基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常都是256 x 256 或者 512 x 512。
合成器线程会安排栅格化线程优先转换视口(及附近)的图块。而构成一层的图块也会转换为不同分辨率的版本,以便在用户缩放时使用。
所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的。
通常,栅格化过程都会使用GPU来加速生成,使用GPU生成位图的过程叫快速栅格化,或者GPU栅格化,生成的位图被保存在GPU内存中。
相信你还记得,GPU操作是运行在GPU进程中,如果栅格化操作使用了GPU,那么最终生成位图的操作是在GPU中完成的,这就涉及到了跨进程操作。
合成
什么是合成?合成(composite)是将页面不同部分先分层并分别栅格化,然后再通过独立的合成器线程合成页面。
这样当用户滚动页面时,因为层都已经栅格化,所以浏览器唯一要做的就是合成一个新的帧。而动画也可以用同样的方式实现:先移动层,再合成帧。
所有小片都栅格化以后,合成器线程会收集叫做“绘制方块”(draw quad)的图块信息,以创建合成器帧。
- 绘制方块:包含小片的内存地址、页面位置等合成页面相关的信息。
- 合成器帧:由从多绘制方块拼成的页面中的一帧。
创建好的合成器帧会通过IPC提交给浏览器进程。浏览器进程里面有一个叫viz
的组件,用来接收合成线程发过来的DrawQuad
命令,然后根据DrawQuad
命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
与此同时,为更新浏览器界面,UI线程可能还会添加另一个合成器帧;或者因为有扩展,其他渲染器进程也可能添加额外的合成器帧。
所有这些合成器帧都会发送给GPU,以便最终显示在屏幕上。如果发生滚动事件,合成器线程会再创建新的合成器帧并发送给GPU。
使用合成的好处是不用牵扯主线程。合成器线程不用等待样式计算或JavaScript执行。
这也是为什么“只需合成的动画”(【中】High Performance Animations)被认为性能最佳的原因。因为如果布局和绘制需要再次计算,那还得用到主线程。
用一张图来展示:
渲染流水线大总结
好了,我们现在已经分析完了整个渲染流程,从HTML到DOM、样式计算、布局、图层、绘制、光栅化、合成和显示。下面我用一张图来总结下这整个渲染流程:
- 渲染进程将HTML内容转换为能够读懂的DOM树结构。
- 渲染引擎将CSS样式表转化为浏览器可以理解的styleSheets,计算出DOM节点的样式。
- 创建布局树,并计算元素的布局信息。
- 对布局树进行分层,并生成分层树。
- 为每个图层生成绘制列表,并将其提交到合成线程。
- 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
- 合成线程发送绘制图块命令DrawQuad给浏览器进程。
- 浏览器进程根据DrawQuad消息生成页面,并显示到显示器上。
参考:
[[译] 现代浏览器内部揭秘(第一部分)](https://juejin.cn/post/684490...
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。