浏览器的多进程架构

Chrome 为例,它由多个进程组成,每个进程都有自己核心的职责,它们相互配合完成浏览器的整体功能。每个进程中又包含多个线程,一个进程内的多个线程也会协同工作,配合完成所在进程的职责。

Chrome 采用多进程架构,其顶层存在一个 Browser process 用以协调浏览器的其它进程。

优点

  • 默认一个Tab,新建一个进程,所以一个页面 (或第三方插件) 崩溃不会影响其他页面或整个浏览器
  • 多进程可以充分利用现代CPU多核的优势
  • 方便使用沙盒模型隔离插件等进程,提高浏览器的稳定性

缺点

  • 系统为浏览器新开的进程分配内存CPU 等资源,所以内存和 CPU 的资源消耗也会更大
  • 更高的资源占用。因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源。
  • 更复杂的体系架构。浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了。

/images/render/1.png

主进程 Browser Process

  • 负责浏览器界面的显示与交互。各个页面的管理,创建和销毁其他进程。网络的资源管理、下载等。

第三方插件进程 Plugin Process

  • 每种类型的插件对应一个进程,仅当使用该插件时才创建。

GPU 进程 GPU Process

  • 最多只有一个,用于 3D 绘制等

渲染进程 Renderer Process

  • 称为浏览器渲染进程或浏览器内核,内部是多线程的。

渲染进程 (浏览器内核)

GUI 渲染线程

  • 负责渲染浏览器界面,解析HTML/CSS,构建DOM树和Render树,布局和绘制
  • 当界面需要重绘(Repaint),或者由于某些操作引发回流(Reflow)时,该线程就会执行
  • GUI渲染线程JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。

JS 引擎线程

  • JavaScript 引擎,也成为 JS内核,负责处理Javascript脚本程序。(例如 V8 引擎)
  • JS引擎等待着任务队列中任务的到来,然后加以处理。一个 Tab 页(renderer 进程)中无论什么时候都只有一个 JS 线程在运行 JS 程序。
  • 由于GUI 渲染线程JS 引擎线程互斥的,如果 JS 执行的时间过长,会造成页面的渲染不连贯,页面渲染加载阻塞。

事件触发线程

  • 归属于浏览器而不是 JS 引擎,用来控制事件循环
  • 当 JS 引擎执行代码块如 setTimeOut 时(或来自浏览器内核的其他线程,如鼠标点击、AJAX 异步请求等),会将对应任务添加到事件线程中
  • 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理
  • 由于 JS 的单线程关系,这些待处理队列中的事件都得排队等待 JS 引擎处理(当 JS 引擎空闲时才会去执行)

定时器触发线程

  • setIntervalsetTimeout 所在线程
  • 浏览器定时计数器并不是由 JavaScript 引擎计数的,(因为 JavaScript 引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确),因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行)
  • W3C 在 HTML 标准中规定,要求 setTimeout 中低于 4ms 的时间间隔算为 4ms。

异步 Http 请求线程

  • XMLHttpRequest 在连接后是通过浏览器新开一个线程请求。
  • 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中,再由 JavaScript 引擎执行。

浏览器渲染流程

/images/render/2.png

  1. 解析 HTML 文件,构建 DOM 树,同时浏览器主进程负责下载 CSS 文件
    构建 DOM 树的详细流程: 字节流转 tokens (StartTag, EndTag, 文本 token) → 生成节点 node → 最后生成 DOM :
    /images/render/3.png

  2. CSS 文件下载完成,解析 CSS 文件成树形的数据结构,然后结合 DOM 树合并成 RenderObject

  3. 布局 RenderObject 树 (Layout/reflow),负责 RenderObject 树中的元素的尺寸,位置等计算
  4. 绘制 RenderObject 树 (paint),绘制页面的像素信息
  5. 浏览器主进程将默认的图层和复合图层交给GPU 进程,GPU 进程再将各个图层合成(composite),最后显示出页面

JavaScript 线程

  1. 单线程机制
    JavaScript 为处理页面中用户的交互,以及操作 DOM 树、CSS 样式树来给用户呈现一份动态而丰富的交互体验和服务器逻辑的交互处理。
    如果 JavaScript 是多线程的方式来操作这些 UI DOM,则可能出现 UI 操作的冲突。假设存在两个线程同时操作一个 DOM,一个负责修改一个负责删除,那么这个时候就需要浏览器来裁决如何生效哪个线程的执行结果。
    可以通过锁来解决上面的问题。但为了避免因为引入了锁而带来更大的复杂性,Javascript在最初就选择了单线程执行。

  2. GUI 渲染线程与 JavaScript 引擎的互斥关系
    由于JavaScript是可操纵DOM的,如果在修改这些元素属性同时渲染界面(即 JavaScript 线程和 GUI 线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。
    为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JavaScript 引擎为互斥的关系。

  3. defer 和 async
    没有deferasync,浏览器会立即加载并执行指定的脚本,不等待后续载入的文档元素,读到就加载并执行。
    async,加载和渲染后续文档元素的过程将和script.js 的加载与执行并行进行(异步)。
    defer,加载后续文档元素的过程将和 script.js 的加载并行进行(异步)。但是 script.js 的执行要在所有元素解析完成之后,DOMContentLoaded 事件触发之前完成。
    从实用角度来说,首先把所有脚本都丢到 </body> 之前是最佳实践,因为对于旧浏览器来说这是唯一的优化选择,此法可保证非脚本的其他一切元素能够以最快的速度得到加载和解析。
    结论:

    1. deferasync 在网络读取(下载)是一样的,都是异步的(相较于 HTML 解析)
    2. 差别在于脚本下载完之后何时执行,显然 defer 是最接近我们对于应用脚本加载和执行的要求的
    3. defer是按照加载顺序执行脚本的,这一点要善加利用, async 则是一个乱序执行,反正对它来说脚本的加载和执行是紧紧挨着的,所以不管你声明的顺序如何,只要它加载完了就会立刻执行
    4. async 对于应用脚本的用处不大,因为它完全不考虑依赖(哪怕是最低级的顺序执行),不过它对于那些可以不依赖任何脚本或不被任何脚本依赖的脚本来说却是非常合适的

浏览器的回流与重绘

回流必将引起重绘,重绘不一定会引起回流。

回流 (Reflow)

Render Tree中部分或全部元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流。需要重新生成DOM树。

  • 页面首次渲染
  • 浏览器窗口大小发生改变
  • 元素尺寸或位置发生改变(包括外边距、内边框、边框大小、高度和宽度等)
  • 元素内容变化(文字数量或图片大小,比如用户在input框中输入文字等等)
  • 元素字体大小变化
  • 添加或者删除可见的DOM元素
  • 激活CSS伪类(例如::hover
  • 查询某些属性或调用某些方法

重绘 (Repaint)

当页面中元素样式的改变并不影响它在文档流中的位置时(例如:color、background-color、visibility等),浏览器会将新样式赋予给元素并重新绘制,这个过程称为重绘。

浏览器优化

浏览器会维护一个队列,把所有引起回流和重绘的操作放入队列中,如果队列中的任务数量或者时间间隔达到一个阈值的,浏览器就会将队列清空,进行一次批处理,这样可以把多次回流和重绘变成一次。

但是获取布局信息的操作的时候,会强制队列刷新,比如访问以下属性或者使用以下方法:

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

因为属性和方法都需要返回最新的布局信息,因此浏览器不得不清空队列,触发回流重绘来返回正确的值。

手动优化

  1. 避免频繁操作样式,最好一次性重写style属性,或者将样式列表定义为class并通过更改元素class属性来应用样式。

    通过style属性设置样式导致回流。避免设置多级内联样式,因为每个都会造成回流,样式应该合并在一个外部类,这样当该元素的class属性可被操控时仅会产生一个reflow

  2. 避免频繁操作DOM,创建一个documentFragment,在它上面应用所有 DOM 操作,最后再把它添加到文档中。DocumentFragment 节点不属于文档树,在把它插入文档节点之前,增删节点都不会引起回流。

  3. 先为元素设置display: none,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。
  4. 避免频繁读取会引发回流/重绘的属性,如果确实需要多次使用,就用一个变量缓存起来。
  5. 对具有复杂动画的元素使用绝对定位,使它脱离文档流,否则会引起父元素及后续元素频繁回流。
  6. 避免使用CSS表达式(例如:calc()
  7. 避免使用table布局,table是个和罕见的可以影响在它们之前已经进入的DOM元素的显示的元素。即使一些小的变化将导致表格(table)中的所有其他节点回流。
  8. css3硬件加速(GPU加速),可以让transform、opacity、filters这些动画不会引起回流重绘

渲染层合并 (Composite)

对于页面中 DOM 元素的绘制(Paint)是在多个层上进行的。

在每个层上完成绘制过程之后,浏览器会将绘制的位图发送给GPU绘制到屏幕上,将所有层按照合理的顺序合并成一个图层,然后在屏幕上呈现。

对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常。

/images/render/4.png

DOM树中得每个Node节点都有一个对应的 LayoutObjectLayoutObject 知道如何在屏幕上 paint Node 的内容。

从 LayoutObjects 到 PaintLayers

一般来说,拥有相同的坐标空间LayoutObjects,属于同一个渲染层 (Paint Layer)

PaintLayer最初是用来实现 stacking contest(层叠上下文),以此来保证页面元素以正确的顺序合成(composite),这样才能正确的展示元素的重叠以及半透明元素等等。

因此满足形成层叠上下文条件的 LayoutObject 一定会为其创建新的渲染层,当然还有其他的一些特殊情况,为一些特殊的 LayoutObjects 创建一个新的渲染层,比如 overflow != visible 的元素。

  • 创建新的渲染层的常见情况
    • 根元素 document
    • 有明确的定位属性(relative、fixed、sticky、absolute)
    • opacity < 1
    • 有 CSS fliter 属性
    • 有 CSS mask 属性
    • 有 CSS mix-blend-mode 属性且值不为 normal
    • 有 CSS transform 属性且值不为 none
    • backface-visibility 属性为 hidden
    • 有 CSS reflection 属性
    • 有 CSS column-count 属性且值不为 auto 或者有 CSS column-width 属性且值不为 auto
    • 当前有对于 opacity、transform、fliter、backdrop-filter 应用动画
    • overflow 不为 visible

从 PaintLayers 到 GraphicsLayers

某些特殊的渲染层会被认为是合成层(Compositing Layers),合成层拥有单独的 GraphicsLayer,而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer 父层公用一个。

每个 GraphicsLayer 都有一个 GraphicsContextGraphicsContext 会负责输出该层的位图,位图是存储在共享内存中,作为纹理上传到 GPU 中,最后由 GPU 将多个位图进行合成,然后 draw 到屏幕上,此时,我们的页面也就展现到了屏幕上。

  • 提升为合成层的常见情况
    • 3D transforms:translate3d、translateZ 等
    • video、canvas、iframe 等元素
    • 通过 Element.animate() 实现的 opacity 动画转换
    • 通过 CSS 动画实现的 opacity 动画转换
    • position: fixed
    • 具有 will-change 属性
    • 对 opacity、transform、fliter、backdropfilter 应用了 animation 或者 transition

提升为合成层好处

  • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快
  • 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
  • 对于 transform 和 opacity 效果,不会触发 layout 和 paint

合成层的弊端

  • 绘制的图层必须传输到 GPU,这些层的数量和大小达到一定量级后,可能会导致传输非常慢,进而导致一些低端和中端设备上出现闪烁;
  • 隐式合成容易产生过量的合成层,每个合成层都占用额外的内存,而内存是移动设备上的宝贵资源,过多使用内存可能会导致浏览器崩溃,让性能优化适得其反。

层确实可以提高性能,但是它以内存管理为代价,因此不应作为 web 性能优化策略的一部分过度使用。

隐式合成
一个或多个非合成元素应出现在堆叠顺序上的合成元素之上,被提升到合成层。

例子如下

两个absolute定位的div在屏幕上交叠了,根据z-index的关系,其中一个div就盖在了另外一个上边。

/images/render/5.png

这个时候,如果处于下方的div被加上了CSS属性transform: translateZ(0),就会被浏览器提升为合成层。提升后的合成层位于Document上方,假如没有隐式合成,原本应该处于上方的div就依然还是跟Document共用一个GraphicsLayer,层级反而降了,就出现了元素交叠关系错乱的问题。

/images/render/6.png

所以为了纠正错误的交叠顺序,浏览器必须让原本应该”盖在“它上边的渲染层也同时提升为合成层。

/images/render/7.png

层压缩(Layer Squashing)

由于重叠的原因,可能随随便便就会产生出大量合成层来,而每个合成层都要消耗CPU和内存资源,严重影响页面性能。

如果多个渲染层同一个合成层重叠时,这些渲染层会被压缩到一个GraphicsLayer中,以防止由于重叠原因导致可能出现的层爆炸

例子如下

有四个absolute定位的div在屏幕内发生了交叠。此时处于最下方的div在加上了CSS属性 transform: translateZ(0) 后被浏览器提升为合成层。

按照隐式合成的原理,盖在它上边的div会提升为一个新的合成层,第三个div又盖在了第二个上,自然也会被提升为合成层,第四个也同理。这样一来,就会产生四个合成层。

/images/render/8.png

然而由于浏览器的层压缩机制,会将隐式合成的多个渲染层压缩到同一个GraphicsLayer中进行渲染,也就是说,上方的三个div最终会处于同一个合成层中,这就是浏览器的层压缩。

/images/render/9.png

优化建议

  1. 动画使用 transform 实现

    对于一些体验要求较高的关键动画,比如一些交互复杂的玩法页面,存在持续变化位置的animation元素,我们最好是使用transform来实现而不是通过改变left/top的方式。这样做的原因是,如果使用left/top来实现位置变化,animation节点和Document将被放到了同一个GraphicsLayer中进行渲染,持续的动画效果将导致整个Document不断地执行重绘,而使用transform的话,能够让animation节点被放置到一个独立合成层中进行渲染绘制,动画发生时不会影响到其它层。
    并且另一方面,动画会完全运行在 GPU 上,相比起 CPU 处理图层后再发送给显卡进行显示绘制来说,这样的动画往往更加流畅。

  2. 减少隐式合成

    虽然隐式合成从根本上来说是为了保证正确的图层重叠顺序,但具体到实际开发中,隐式合成很容易就导致一些无意义的合成层生成,归根结底其实就要求我们在开发时约束自己的布局习惯,避免踩坑。

    例如页面里边存在的一个带动画transformbutton按钮,提升为了合成层,动画交叠的不确定性使得页面内其他 z-index 大于它但其实并没有交叠的节点也都全部提升为了合成层。

    这个时候我们只需要把这个动画节点的 z-index 属性值设置得大一些,让层叠顺序高过于页面其他无关节点就行。当然并不是盲目地设置 z-index 就能避免,有时候 z-index 也还是会导致隐式合成,这个时候可以试着调整一下文档中节点的先后顺序直接让后边的节点来覆盖前边的节点,而不用 z-index 来调整重叠关系。方法不是唯一的,具体方式还是得根据不同的页面具体分析。

  3. 减小合成层的尺寸

    举个简单的例子,分别画两个尺寸一样的div,但实现方式有点差别:一个直接设置尺寸100x100,另一个设置尺寸10x10,然后通过 scale 放大 10 倍,并且我们让这两个div都提升为合成层:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    <style>
    .bottom,
    .top {
    position: absolute;
    will-change: transform;
    }
    .bottom {
    width: 100px;
    height: 100px;
    top: 20px;
    left: 20px;
    z-index: 3;
    background: rosybrown;
    }
    .top {
    width: 10px;
    height: 10px;
    transform: scale(10);
    top: 200px;
    left: 200px;
    z-index: 5;
    background: indianred;
    }
    </style>
    <body>
    <div class="bottom"></div>
    <div class="top"></div>
    </body>

    利用Chrome Devtools查看这两个合成层的内存占用后发现,.bottom 内存占用是 39.1 KB,而 .top400 B,差距十分明显。这是因为 .top 是合成层,transform 位于的 Composite 阶段,现在完全在 GPU 上执行。因此对于一些纯色图层来说,我们可以使用 widthheight 属性减小合成层的物理尺寸,然后再用 transform: scale() 放大,这样一来可以极大地减少层合成带来的内存消耗。

关键渲染路径(CRP)

关键渲染路径(Critical Rendering Path, CRP)是浏览器将 HTML, CSS, JavaScript 转换为在屏幕上呈现的像素内容所经历的一系列步骤。也就是浏览器渲染流程。

为尽快完成首次渲染,我们需要最大限度减小以下三种可变因素:

  • 关键资源的数量:可能阻止网页首次渲染的资源。
  • 关键路径长度:获取所有关键资源所需的往返次数或总时间。
  • 关键字节:实现网页首次渲染所需的总字节数,等同于所有关键资源传送文件大小的总和。

优化 DOM

  • 删除不必要的代码和注释包括空格,尽量做到最小化文件。
  • 可以利用 GZIP 压缩文件。
    GZIP 的核心是 Deflate,Deflate 是一个同时使用 LZ77 与 Huffman Coding 的算法。
  • 结合 HTTP 缓存文件。

优化 CSSOM

缩小、压缩以及缓存同样重要,对于CSSOM我们前面重点提过了它会阻止页面呈现,因此我们可以从这方面考虑去优化。

  • 减少关键 CSS 元素数量
  • 当我们声明样式表时,请密切关注媒体查询的类型,它们极大地影响了 CRP 的性能 。
1
2
3
4
5
6
7
8
9
<!-- 阻塞渲染,匹配所有情况-->
<link href="style.css" rel="stylesheet" />

<!-- 声明只适用于打印(媒体类型),因此页面在浏览器中首次加载时,不会阻塞渲染-->
<link href="print.css" rel="stylesheet" media="print" />

<!-- 提供了媒体查询,由浏览器判断:
如果条件符合,则在该样式表下载并处理完以前,浏览器会阻塞渲染 -->
<link href="other.css" rel="stylesheet" media="(min-width: 40em)" />

优化 JavaScript

当浏览器遇到<script>标记时,会阻止解析器继续操作,直到CSSOM构建完毕,JavaScript 才会运行并继续完成 DOM 构建过程。

  • 使用 asyncdefer
    • async 当我们在<script>标记添加 async 属性以后,浏览器遇到这个<script>标记时会继续解析 DOM,同时脚本也不会被 CSSOM 阻止,即不会阻止 CRP
    • deferasync 的区别在于,脚本需要等到文档解析后( DOMContentLoaded 事件前)执行,而 async 允许脚本在文档解析时位于后台运行(两者下载的过程不会阻塞 DOM,但执行会)。
    • 当我们的脚本不会修改 DOMCSSOM 时,推荐使用 async
  • 预加载 —— preload & prefetch 。
  • DNS 预解析 —— dns-prefetch 。
1
2
3
<!-- dns-prefetch 结合 preconnect -->
<link rel="preconnect" href="https://fonts.googleapis.com/" crossorigin />
<link rel="dns-prefetch" href="https://fonts.googleapis.com/" />

希望在 HTTPS 页面开启自动解析功能时,添加如下标记

1
2
<meta http-equiv="x-dns-prefetch-control" content="on" />
<!-- off 则是关闭-->

或者通过在服务器端发送 X-DNS-Prefetch-Control 报头。

总结优化 CRP 常规步骤

  1. 分析、描述关键路径:关键资源数量、字节数、关键路径长度
  2. 最小化关键资源数量:删除相应资源、延迟下载、标记为异步资源等
  3. 减少关键字节数,以减少资源下载时间(往返次数)
  4. 优化剩余关键资源的加载顺序:尽可能早的下载所有关键资源,以缩短关键路径长度

例子一

1
2
3
4
5
6
7
8
9
10
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link href="style.css" rel="stylesheet" />
</head>
<body>
<p>Hello <span>web performance</span> students!</p>
<div><img src="awesome-photo.jpg" /></div>
</body>
</html>

/images/render/11.png

  • 需要HTMLCSS来构建渲染树,因此HTMLCSS均为关键资源,共2个关键资源
  • 浏览器需要一个网络往返过程来提取 HTML 文档,又检索到需要 CSS 文件,最少需要2个往返过程
  • 两种资源加起来的关键字节总量最多为9 KB

例子二

1
2
3
4
5
6
7
8
9
10
11
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link href="style.css" rel="stylesheet" />
</head>
<body>
<p>Hello <span>web performance</span> students!</p>
<div><img src="awesome-photo.jpg" /></div>
<script src="app.js"></script>
</body>
</html>

/images/render/12.png

  • 例子1多了一个 JS 关键资源,共3个关键资源,如果在<script>中加入async属性,<script src="app.js" async></script>,则可以减少为2
  • 关键路径长度仍然是 2 个往返过程!因为CSSJavaScript可以并行传输
  • 关键字节11kb

参考资料

  1. 从浏览器多进程到 JS 单线程,JS 运行机制最全面的一次梳理
  2. 无线性能优化:Composite
  3. 渲染页面:浏览器的工作原理