[HTTP 系列] 第 6 篇 —— 从输入 URL 回车到页面呈现
这里是《写给前端工程师的 HTTP 系列》终极篇, 从输入 URL 回车到页面呈现到底发生了什么. 这篇文章着重会讲浏览器渲染过程, 如前置的 缓存, DNS 解析, TCP, HTTP 等流程一笔带过, 相关内容请浏览本系列的前几篇文章.
- [HTTP 系列] 第 1 篇 —— 从 TCP/UDP 到 DNS 解析
- [HTTP 系列] 第 2 篇 —— HTTP 协议那些事
- [HTTP 系列] 第 3 篇 —— HTTP 缓存那些事
- [HTTP 系列] 第 4 篇 —— HTTPS
- [HTTP 系列] 第 5 篇 —— 网络安全
- [HTTP 系列] 第 6 篇 —— 从输入 URL 回车到页面呈现
总览
用户发出 URL 请求到页面开始解析的这个过程, 就叫做导航.
- 首先, 浏览器进程接收到用户输入的 URL 请求, 浏览器进程便将该 URL 转发给网络进程.
- 然后, 在网络进程中发起真正的 URL 请求.
- 接着网络进程接收到了响应头数据, 便解析响应头数据, 并将数据转发给浏览器进程.
- 浏览器进程接收到网络进程的响应头数据之后, 发送 提交导航 (CommitNavigation) 消息到渲染进程;
- 渲染进程接收到提交导航的消息之后, 便开始准备接收 HTML 数据, 接收数据的方式是直接和网络进程建立数据管道;
- 最后渲染进程会向浏览器进程确认提交, 这是告诉浏览器进程: 已经准备好接受和解析页面数据了.
- 浏览器进程接收到渲染进程提交文档的消息之后, 便开始移除之前旧的文档, 然后更新浏览器进程中的页面状态.
用户输入
当用户在地址栏中输入一个查询关键字时, 地址栏会判断输入的关键字是搜索内容, 还是请求的 URL.
- 如果是搜索内容, 地址栏会使用浏览器默认的搜索引擎, 来合成新的带搜索关键字的 URL.
- 如果判断输入内容符合 URL 规则, 比如输入的是 yanceyleo.com, 那么地址栏会根据规则, 把这段内容加上协议, 合成为完整的 URL, 如 https://yanceyleo.com.
当用户输入关键字并键入回车之后, 这意味着当前页面即将要被替换成新的页面, 不过在这个流程继续之前, 浏览器还给了当前页面一次执行 beforeunload 事件的机会, beforeunload 事件允许页面在退出之前执行一些数据清理操作, 还可以询问用户是否要离开当前页面, 比如当前页面可能有未提交完成的表单等情况, 因此用户可以通过 beforeunload 事件来取消导航, 让浏览器不再执行任何后续工作.
比如 react-router, 虽然路由跳转是"假"的, 但它仍然提供了 Prompt
, 让你在发生 history 变化之前提示用户是否要做些什么.
<Prompt message="Are you sure you want to leave?" />
当前页面没有监听 beforeunload 事件或者同意了继续后续流程, 那么浏览器便进入下图的状态:
从图中可以看出, 当浏览器刚开始加载一个地址之后, 标签页上的图标便进入了加载状态. 但此时图中页面显示的依然是之前打开的页面内容, 并没立即替换为极客时间的页面. 因为需要等待提交文档阶段, 页面内容才会被替换.
URL 请求过程
浏览器中的 HTTP 请求从发起到结束一共经历了如下八个阶段: 构建请求, 查找缓存, 准备 IP 和端口, 等待 TCP 队列, 建立 TCP 连接, 发起 HTTP 请求, 服务器处理请求, 服务器返回请求和断开连接.
接下来, 便进入了页面资源请求过程. 这时, 浏览器进程会通过进程间通信(IPC)把 URL 请求发送至网络进程, 网络进程接收到 URL 请求后, 会在这里发起真正的 URL 请求流程. 首先会查找缓存, 关于缓存看 [HTTP 系列] —— HTTP 缓存那些事 这篇文章. 如果命中了强缓存, 就直接使用缓存数据. 如果没有命中强缓存, 就会发起真正的网络请求.
请求前的第一步是要进行 DNS 解析, 以获取请求域名的服务器 IP 地址, 从操作系统, 本地 DNS, 根 DNS, 顶级 DNS, 权威 DNS 的层层解析, 当然这中间有缓存. 关于 DNS 解析 看这篇文章. 当然如果用了 CDN, DNS 解析可能会给出 CDN 服务器的 IP 地址, 这样你拿到的就会是 CDN 服务器而不是目标网站的实际地址.
拿到 IP 之后, 接下来就需要获取端口号了. 通常情况下, 如果 URL 没有特别指明端口号, 那么 HTTP 协议默认是 80 端口, 当然 HTTPS 的默认端口号是 443. 这样便可以通过 IP 地址和服务器建立 TCP 连接(三次握手), 当然如果是 HTTPS, 还要建立 TLS 握手. 连接建立之后, 浏览器端会构建请求行, 请求头等信息, 并把和该域名相关的 Cookie 等数据附加到请求头中, 然后向服务器发送构建的请求信息.
注意, 在 TCP 建立连接之前, Chrome 有个机制, 同一个域名同时最多只能建立 6 个 TCP 连接, 如果在同一个域名下同时有 10 个请求发生, 那么其中 4 个请求会进入排队等待状态, 直至进行中的请求完成. 当然这是 HTTP/1 的特性. 不过 HTTP/2 是可以并行请求资源的, 所以如果使用 HTTP/2, 浏览器只会为每个域名维护一个 TCP 连接.
在接收到服务器返回的响应头后, 网络进程开始解析响应头, 如果发现返回的状态码是 301 或者 302, 那么说明服务器需要浏览器重定向到其他 URL. 这时网络进程会从响应头的 Location 字段里面读取重定向的地址, 这就是告诉了浏览器要重新导航到新的地址上, 然后再发起新的 HTTP 或者 HTTPS 请求, 一切又重头开始了. 如果响应行是 200, 那么表示浏览器可以继续处理该请求.
当浏览器受到相应后, 会根据 Content-Type 的值来决定如何显示响应体的内容. 不同 Content-Type 的后续处理流程也截然不同. 如果 Content-Type 字段的值被浏览器判断为下载类型, 那么该请求会被提交给浏览器的下载管理器, 同时该 URL 请求的导航流程就此结束. 但如果是 HTML, 那么浏览器则会继续进行导航流程. 由于 Chrome 的页面渲染是运行在渲染进程中的, 所以接下来就需要准备渲染进程了.
准备渲染进程
默认情况下, Chrome 会为每个页面分配一个渲染进程, 也就是说, 每打开一个新页面就会配套创建一个新的渲染进程. 但如果从一个页面打开了另一个新页面, 而新页面和当前页面属于同一站点的话, 浏览器会让多个页面直接运行在同一个渲染进程中, 官方把这个默认策略叫 process-per-site-instance. 如下图, 打开的这三个页面都是运行在同一个渲染进程中, 进程 ID 是 23601.
同一站点(same-site)的含义是只要根域名和协议相同的站点, 就可以认为是同一个站点. 需要注意的一点是, 如果打开两个 Tab, 即便它们是同一站点, 也会创建两个进程. 你可以通过在某个页面, 使用 window.open()
方法来打开新的页面, 这样它们就会使用同一个进程 ID. 也可以使用 <a href="https://yanceyleo.com/" target="_blank" class="">yanceyleo</a>
的方式, 当然如果带上了 noopener 和 noreferrer 就不会产生上下文了.
在 WHATWG 规范中, 把这一类具有相互连接关系的标签页称为浏览上下文组(browsing context group). 既然提到浏览上下文组, 就有必要提下浏览上下文, 通常情况下, 我们把一个标签页所包含的内容, 诸如 window 对象, 历史记录, 滚动条位置等信息称为浏览上下文. 这些通过脚本相互连接起来的浏览上下文就是浏览上下文组.
Chrome 浏览器会将浏览上下文组中属于同一站点的标签分配到同一个渲染进程中, 这是因为如果一组标签页, 既在同一个浏览上下文组中, 又属于同一站点, 那么它们可能需要在对方的标签页中执行脚本. 因此, 它们必须运行在同一渲染进程中.
提交文档
提交文档, 就是指浏览器进程将网络进程接收到的 HTML 数据提交给渲染进程:
- 首先当浏览器进程接收到网络进程的响应头数据之后, 便向渲染进程发起提交文档的消息;
- 渲染进程接收到提交文档的消息后, 会和网络进程建立传输数据的管道;
- 等文档数据传输完成之后, 渲染进程会返回确认提交的消息给浏览器进程;
- 浏览器进程在收到确认提交的消息后, 会更新浏览器界面状态, 包括了安全状态, 地址栏的 URL, 前进后退的历史状态, 并更新 Web 页面.
正是因为有了提交文档的阶段, 所以在浏览器的地址栏里面输入了一个地址后, 之前的页面没有立马消失, 而是要加载一会儿才会更新页面.
浏览器渲染
一旦文档被提交, 渲染进程便开始页面解析和子资源加载. 渲染模块会处理 HTML, CSS, JavaScript 等数据, 最终输出为屏幕上的像素.
由于渲染机制过于复杂, 所以渲染模块在执行过程中会被划分为很多子阶段, 输入的 HTML 经过这些子阶段, 最后输出像素. 我们把这样的一个处理流程叫做渲染流水线. 按照渲染的时间顺序, 流水线可分为如下几个子阶段: 构建 DOM 树, 样式计算, 布局阶段, 分层, 绘制, 分块, 光栅化和合成. 每个阶段都有三个步骤:
- 开始每个子阶段都有其输入的内容;
- 然后每个子阶段有其处理过程;
- 最终每个子阶段会生成输出内容.
一些小概念
在讲渲染流程之前, 我们先了解一些小概念.
首先我们看显示器是怎么显示图像的. 每个显示器都有固定的刷新频率, 通常是 60HZ, 也就是每秒更新 60 张图片, 更新的图片都来自于显卡中一个叫前缓冲区的地方, 显示器所做的任务很简单, 就是每秒固定读取 60 次前缓冲区中的图像, 并将读取的图像显示到显示器上.
显卡的职责就是合成新的图像, 并将图像保存到后缓冲区中, 一旦显卡把合成的图像写到后缓冲区, 系统就会让后缓冲区和前缓冲区互换, 这样就能保证显示器能读取到最新显卡合成的图像. 通常情况下, 显卡的更新频率和显示器的刷新频率是一致的. 但有时候, 在一些复杂的场景中, 显卡处理一张图片的速度会变慢, 这样就会造成视觉上的卡顿.
当你通过滚动条滚动页面, 或者通过手势缩放页面时, 屏幕上就会产生动画的效果. 之所以你能感觉到有动画的效果, 是因为在滚动或者缩放操作时, 渲染引擎会通过渲染流水线生成新的图片, 并发送到显卡的后缓冲区.
大多数设备屏幕的更新频率是 60 次/秒, 这也就意味着正常情况下要实现流畅的动画效果, 渲染引擎需要每秒更新 60 张图片到显卡的后缓冲区. 我们把渲染流水线生成的每一副图片称为一帧, 把渲染流水线每秒更新了多少帧称为帧率, 比如滚动过程中 1 秒更新了 60 帧, 那么帧率就是 60Hz(或者 60FPS). 由于用户很容易观察到那些丢失的帧, 如果在一次动画过程中, 渲染引擎生成某些帧的时间过久, 那么用户就会感受到卡顿, 这会给用户造成非常不好的印象.
为了生成一帧图像, 引擎提供了重排, 重绘和合成三种方式. 这三种方式的渲染路径是不同的, 通常渲染路径越长, 生成图像花费的时间就越多. 比如重排, 它需要重新根据 CSSOM 和 DOM 来计算布局树, 这样生成一幅图片时, 会让整个渲染流水线的每个阶段都执行一遍, 如果布局复杂的话, 就很难保证渲染的效率了. 而重绘因为没有了重新布局的阶段, 操作效率稍微高点, 但是依然需要重新计算绘制信息, 并触发绘制操作之后的一系列操作.
相较于重排和重绘, 合成操作的路径就显得非常短了, 并不需要触发布局和绘制两个阶段, 如果采用了 GPU, 那么合成的效率会非常高.
因此, 关于渲染引擎生成一帧图像的几种方式, 按照效率我们推荐合成方式优先, 若实在不能满足需求, 那么就再退后一步使用重绘或者重排的方式. 要解决卡顿问题, 就要解决每帧生成时间过久的问题, 为此 Chrome 对浏览器渲染方式做了大量的工作, 其中最卓有成效的策略就是引入了分层, 分块和合成机制. 我们在下面分层那一部分说到. 下面我们来走一遍浏览器渲染的流程.
构建 DOM 树
因为浏览器无法直接理解和使用 HTML, 所以需要将 HTML 转换为浏览器能够理解的结构, 即 DOM 树. 在渲染引擎中, DOM 有三个层面的作用.
- 从页面的视角来看, DOM 是生成页面的基础数据结构.
- 从 JavaScript 脚本视角来看, DOM 提供给 JavaScript 脚本操作的接口, 通过这套接口, JavaScript 可以对 DOM 结构进行访问, 从而改变文档的结构, 样式和内容.
- 从安全视角来看, DOM 是一道安全防护线, 一些不安全的内容在 DOM 解析阶段就被拒之门外了.
你可以在 Chrome 的控制台中输入 doucment
回车, 其实就是一个完整的 DOM 树结构. DOM 和 HTML 内容几乎是一样的, 但是和 HTML 不同的是, DOM 是保存在内存中树状结构, 可以通过 JavaScript 来查询或修改其内容.
DOM 树的生成
在渲染引擎内部, 有一个叫 HTML 解析器(HTMLParser)的模块, 它的职责就是负责将 HTML 字节流转换为 DOM 结构. HTML 解析器并不是等整个文档加载完成之后再解析的, 而是网络进程加载了多少数据, HTML 解析器便解析多少数据.
网络进程接收到响应头之后, 会根据响应头中的 content-type 字段来判断文件的类型, 比如 content-type 的值是 text/html, 那么浏览器就会判断这是一个 HTML 类型的文件, 然后为该请求选择或者创建一个渲染进程. 渲染进程准备好之后, 网络进程和渲染进程之间会建立一个共享数据的管道, 网络进程接收到数据后就往这个管道里面放, 而渲染进程则从管道的另外一端不断地读取数据, 并同时将读取的数据喂给 HTML 解析器. 你可以把这个管道想象成一个水管, 网络进程接收到的字节流像水一样倒进这个水管, 而水管的另外一端是渲染进程的 HTML 解析器, 它会动态接收字节流, 并将其解析为 DOM.
上面说到代码从网络传输过来是字节流的形式, 字节流转换为 DOM 的流程如下图所示, 下面详细说明.
第一个阶段是通过分词器将字节流转换为 Token. V8 编译 JavaScript 过程中的第一步是做词法分析; 解析 HTML 也是一样的, 需要通过分词器先将字节流转换为一个个 Token, 分为 Tag Token 和文本 Token. 上述 HTML 代码通过词法分析生成的 Token 如下所示:
至于后续的第二个和第三个阶段是同步进行的, 需要将 Token 解析为 DOM 节点, 并将 DOM 节点添加到 DOM 树中. HTML 解析器维护了一个 Token 栈结构, 该 Token 栈主要用来计算节点之间的父子关系, 在第一个阶段中生成的 Token 会被按照顺序压到这个栈中. 具体的处理规则如下所示:
- 如果压入到栈中的是 StartTag Token, HTML 解析器会为该 Token 创建一个 DOM 节点, 然后将该节点加入到 DOM 树中, 它的父节点就是栈中相邻的那个元素生成的节点.
- 如果分词器解析出来是Text Token, 那么会生成一个文本节点, 然后将该节点加入到 DOM 树中, 文本 Token 是不需要压入到栈中, 它的父节点就是当前栈顶 Token 所对应的 DOM 节点.
- 如果分词器解析出来的是 EndTag Token, 比如是 EndTag div, HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div, 如果是, 就将 StartTag div 从栈中弹出, 表示该 div 元素解析完成.
<html>
<body>
<div>1</div>
<div>test</div>
</body>
</html>
以上面这段代码为例:
HTML 解析器开始工作时, 会默认创建了一个根为 document 的空 DOM 结构, 同时会将一个 StartTag document 的 Token 压入栈底. 然后经过分词器解析出来的第一个 StartTag html Token 会被压入到栈中, 并创建一个 html 的 DOM 节点, 添加到 document 上.
然后按照同样的流程解析出来 StartTag body 和 StartTag div.
接下来解析出来的是第一个 div 的文本 Token, 渲染引擎会为该 Token 创建一个文本节点, 并将该 Token 添加到 DOM 中, 它的父节点就是当前 Token 栈顶元素对应的节点.
再接下来, 分词器解析出来第一个 EndTag div, 这时候 HTML 解析器会去判断当前栈顶的元素是否是 StartTag div, 如果是则从栈顶弹出 StartTag div.
需要注意的是, 如果解析到 <script>
标签, 渲染引擎判断这是一段脚本, 此时 HTML 解析器就会暂停 DOM 的解析, 因为接下来的 JavaScript 可能要修改当前已经生成的 DOM 结构. 脚本执行完成之后, HTML 解析器恢复解析过程, 继续解析后续的内容, 直至生成最终的 DOM.
不管是内嵌 JavaScript 脚本还是通过 JavaScript 文件加载的. 其整个执行流程还是一样的: 执行到 JavaScript 标签时, 暂停整个 DOM 的解析, 执行 JavaScript 代码. 不过通过 JavaScript 文件加载的, 需要先下载这段 JavaScript 代码, 而 JavaScript 文件的下载过程会阻塞 DOM 解析, 而通常下载又是非常耗时的, 会受到网络环境, JavaScript 文件大小等因素的影响. 因此 Chrome 做了一些优化, 比如预解析操作: 当渲染引擎收到字节流之后, 会开启一个预解析线程, 用来分析 HTML 文件中包含的 JavaScript, CSS 等相关文件, 解析到相关文件之后, 预解析线程会提前下载这些文件.
如果 JavaScript 文件中没有操作 DOM 相关代码, 就可以将该 JavaScript 脚本设置为异步加载, 通过 async 或 defer 来标记代码. async 和 defer 虽然都是异步的, 不过还有一些差异, 使用 async 标志的脚本文件一旦加载完成, 会立即执行;而使用了 defer 标记的脚本文件, 需要在 DOMContentLoaded 事件之前执行. 具体可以看关于 script 标签 async 和 defer 属性分析这篇文章.
<script async type="text/javascript" src="foo.js"></script>
<script defer type="text/javascript" src="foo.js"></script>
此外, JavaScript 脚本还是依赖样式表的, 不管该脚本是否操纵了 CSSOM, 都会执行 CSS 文件下载, 解析操作, 再执行 JavaScript 脚本. 这是因为如果 JavaScript 代码里修改了样式, 它必须等到 CSSOM 就绪之后才能修改.
因此, JavaScript 会阻塞 DOM 生成, 而样式文件又会阻塞 JavaScript 的执行. 所以说一般把 CSS 放在最上面, script 放在 body 最下面.
扩展一下: 渲染引擎还有一个安全检查模块叫 XSSAuditor, 是用来检测词法安全的. 在分词器解析出来 Token 之后, 它会检测这些模块是否安全, 比如是否引用了外部脚本, 是否符合 CSP 规范, 是否存在跨站点请求等. 如果出现不符合规范的内容, XSSAuditor 会对该脚本或者下载任务进行拦截.
样式计算
在讲样式计算之前, 先说一下渲染流水线视角下的 CSS.
// theme.css div { color: coral; background-color: black; }
<html>
<head>
<link href="theme.css" rel="stylesheet" />
</head>
<body>
<div>hello world</div>
</body>
</html>
如上面的代码, 我们有一个外部的 css 文件, 被 html 文件所引用. 它的渲染流程大致如下图所示. 我们来分析一下.
首先是发起主页面的请求, 这个发起请求方可能是渲染进程, 也有可能是浏览器进程, 发起的请求被送到网络进程中去执行. 网络进程接收到返回的 HTML 数据之后, 将其发送给渲染进程, 渲染进程会解析 HTML 数据并构建 DOM. 这里需要特别注意下, 请求 HTML 数据和构建 DOM 中间有一段空闲时间, 这个空闲时间有可能成为页面渲染的瓶颈.
上面我们说道, 当渲染进程接收 HTML 文件字节流时, 会先开启一个预解析线程, 如果遇到 JavaScript 文件或者 CSS 文件, 那么预解析线程会提前下载这些数据. 对于上面的代码, 预解析线程会解析出来一个外部的 theme.css 文件, 并发起 theme.css 的下载. 这里也有一个空闲时间需要你注意一下, 就是在 DOM 构建结束之后, theme.css 文件还未下载完成的这段时间内, 渲染流水线无事可做, 因为下一步是合成布局树, 而合成布局树需要 CSSOM 和 DOM, 所以这里需要等待 CSS 加载结束并解析成 CSSOM.
如果我们加入一段 JS 脚本会怎么样呢? 可以看下面这张图:
<html>
<head>
<link href="theme.css" rel="stylesheet" />
</head>
<body>
<div>hello world</div>
<script>
console.log("hi world");
</script>
<div>hello world</div>
</body>
</html>
上面我们提到过在解析 DOM 的过程中, 如果遇到了 JavaScript 脚本, 那么需要先暂停 DOM 解析去执行 JavaScript, 因为 JavaScript 有可能会修改当前状态下的 DOM.
不过在执行 JavaScript 脚本之前, 如果页面中包含了外部 CSS 文件的引用, 或者通过 style 标签内置了 CSS 内容, 那么渲染引擎还需要将这些内容转换为 CSSOM, 因为 JavaScript 有修改 CSSOM 的能力, 所以在执行 JavaScript 之前, 还需要依赖 CSSOM. 也就是说 CSS 在部分情况下也会阻塞 DOM 的生成.
再复杂一点, 如果我们加入一段外部的 JS 脚本会怎么样呢? 可以看下面这张图:
// foo.js console.log("hi world");
<html>
<head>
<link href="theme.css" rel="stylesheet" />
</head>
<body>
<div>hello world</div>
<script src="foo.js"></script>
<div>hello world</div>
</body>
</html>
从图中可以看出来, 在接收到 HTML 数据之后的预解析过程中, HTML 预解析器识别出来了有 CSS 文件和 JavaScript 文件需要下载, 然后就同时发起这两个文件的下载请求, 需要注意的是, 这两个文件的下载过程是重叠的, 所以下载时间按照最久的那个文件来算.
后面的流水线就和前面是一样的了, 不管 CSS 文件和 JavaScript 文件谁先到达, 都要先等到 CSS 文件下载完成并生成 CSSOM, 然后再执行 JavaScript 脚本, 最后再继续构建 DOM, 构建布局树, 绘制页面.
因此我们一般要把 css 放在上面, 把 js 放在最后.
好了, 我们了解到 CSS 的渲染方式后, 来正式讲一讲样式计算的目的是为了计算出 DOM 节点中每个元素的具体样式, 这个阶段大体可分为三步来完成.
把 CSS 转换为浏览器能够理解的结构
CSS 样式主要有三种来源, 分别是:
- 通过 link 引用的外部 CSS 文件
<style>
标记内的 CSS- 元素 style 属性的内联 CSS
和 HTML 文件一样, 浏览器也是无法直接理解这些纯文本的 CSS 样式, 所以当渲染引擎接收到 CSS 文本时, 会执行一个转换操作, 将 CSS 文本转换为浏览器可以理解的结构, 即 styleSheets. 你可以通过在 Chrome 的控制台中输入 doucment.styleSheets
回车, 来查看 CSSOM 树.
转换样式表中的属性值, 使其标准化
我们知道很多 CSS 属性值有不同的写法, 但能达到相同的结果. 比如 rgb(0, 0 ,0)
, black
, #000
, #000000
都可以指黑色. 并且像 2em, blue, bold, 这些类不容易被渲染引擎理解, 所以需要将所有值转换为渲染引擎容易理解的, 标准化的计算值, 这个过程就是属性值标准化.
从上图中可以看到, 2em 被解析成了 32px, red 被解析成了 rgb(255, 0, 0)
, bold 被解析成了 700. 在 Chrome 上, 你可以通过 Computed Styles 来查看一个元素被标准化后的属性值.
计算出 DOM 树中每个节点的具体样式
此时样式已经被标准化, 接下来就需要计算 DOM 树中每个节点的样式属性. 这就涉及到 CSS 的继承规则和层叠规则.
首先讲 CSS 继承, CSS 继承就是每个 DOM 节点都包含有父节点的样式. 比如下面这段代码:
body { font-size: 20pxl; } p { color: blue; } span { display: none; } div { font-weight: bold; color: red; } div p { color: green; }
这张样式表最终应用到 DOM 节点的效果如下图所示, 所有子节点都继承了父节点样式. 比如 body 节点的 font-size 属性是 20, 那 body 节点下面的所有节点的 font-size 都等于 20.
下面这张图是一个真实的例子. 我们选择了 P 标签, 它的具体样式位于区域 2, 我们可以看到它的样式继承关系. 区域 3 是样式来源, 也就是该标签的样式来自哪个文件, 需要注意的是 UserAgent 样式表, 它是浏览器提供的一组默认样式, 如果你不提供任何样式, 默认使用的就是 UserAgent 样式.
样式计算过程中的第二个规则是样式层叠. 层叠是 CSS 的一个基本特征, 它定义了如何合并来自多个源的属性值的算法. 它在 CSS 处于核心地位, CSS 的全称层叠样式表正是强调了这一点.
总之, 样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式, 在计算过程中需要遵守 CSS 的继承和层叠两个规则. 这个阶段最终输出的内容是每个 DOM 节点的样式, 并被保存在 ComputedStyle 的结构内.
布局阶段
现在, 我们有 DOM 树和 DOM 树中元素的样式, 但这还不足以显示页面, 因为我们还不知道 DOM 元素的几何位置信息. 那么接下来就需要计算出 DOM 树中可见元素的几何位置, 我们把这个计算过程叫做布局. Chrome 在布局阶段需要完成两个任务: 创建布局树和布局计算.
创建布局树
DOM 树还含有很多不可见的元素, 比如 head 标签, 还有使用了 display: none
属性的元素. 所以在显示之前, 我们还要额外地构建一棵只包含可见元素布局树. 从下图可以看出, DOM 树中所有不可见的节点都没有包含到布局树中. 为了构建布局树, 浏览器需要遍历 DOM 树中的所有可见节点, 并把这些节点加到布局树中; 而不可见的节点会被布局树忽略掉, 如 head 标签下面的全部内容与包含了 display: none
属性的元素.
布局计算
现在我们有了一棵完整的布局树. 那么接下来, 就要计算布局树节点的坐标位置了. 在执行布局操作的时候, 会把布局运算的结果重新写回布局树中, 所以布局树既是输入内容也是输出内容, 这是布局阶段一个不合理的地方.
针对这个问题, Chrome 团队正在重构布局代码, 下一代布局系统叫 LayoutNG, 试图更清晰地分离输入和输出, 从而让新设计的布局算法更加简单.
分层
在布局计算之后, 我们就拿到了所有每个元素的具体位置信息, 但即便如此渲染引擎还是不能开始绘制页面. 这是因为页面中有很多复杂的效果, 如一些复杂的 3D 变换, 页面滚动, 或者使用 z-index 做 z 轴排序等, 如果没有采用分层机制, 从布局树直接生成目标图片的话, 那么每次页面有很小的变化时, 都会触发重排或者重绘机制, 这种牵一发而动全身的绘制策略会严重影响页面的渲染效率. 为了更加方便地实现这些效果, 渲染引擎还需要为特定的节点生成专用的图层, 并生成一棵对应的图层树(LayerTree).
这就跟 Photoshop 的图层概念类似, PhotoShop 中一个项目是由很多图层构成的, 每个图层都可以是一张单独图片, 可以设置透明度, 边框阴影, 可以旋转或者设置图层的上下位置, 正是这些图层叠加在一起构成了最终的页面图像.
在这个过程中, 将素材分解为多个图层的操作就称为分层, 最后将这些图层合并到一起的操作就称为合成. 所以, 分层和合成通常是一起使用的.
在 Chrome DevTools 中, 打开 layers tab, 就可以看到你的网站的分层结构.
我们看看这些图层和布局树节点之间的关系:
通常情况下, 并不是布局树的每个节点都包含一个图层, 如果一个节点没有对应的层, 那么这个节点就从属于父节点的图层. 如上图中的 span 标签没有专属图层, 那么它们就从属于它们的父节点图层. 但不管怎样, 最终每一个节点都会直接或者间接地从属于一个层. 通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层.
首先, 拥有层叠上下文属性的元素会被提升为单独的一层.
页面是个二维平面, 但是层叠上下文能够让 HTML 元素具有三维概念, 这些 HTML 元素按照自身属性的优先级分布在垂直于这个二维平面的 z 轴上. 明确定位属性的元素, 定义透明属性的元素, 使用 CSS 滤镜的元素等, 都拥有层叠上下文属性.
其次, 需要剪裁(clip)的地方也会被创建为图层.
下面这段代码, 宽高都是 200px, 但 div 里的字数很多, 肯定会超过 200*200 的面积, 这时候就产生了剪裁, 出现这种裁剪情况的时候, 渲染引擎会为文字部分单独创建一个层, 如果出现滚动条, 滚动条也会被提升为单独的层.
<style>
div {
width: 200px;
height: 200px;
overflow: auto;
background: gray;
}
</style>
<body>
<div>
<p>
所以元素有了层叠上下文的属性或者需要被剪裁, 那么就会被提升成为单独一层,
你可以参看下图:
</p>
<p>
从上图我们可以看到, document层上有 A 和 B 层, 而 B 层之上又有两个图层.
这些图层组织在一起也是一颗树状结构.
</p>
<p>
图层树是基于布局树来创建的, 为了找出哪些元素需要在哪些层中,
渲染引擎会遍历布局树来创建层树(Update LayerTree).
</p>
</div>
</body>
分块
如果说分层是从宏观上提升了渲染效率, 那么分块则是从微观层面提升了渲染效率. 通常情况下, 页面的内容都要比屏幕大得多, 显示一个页面时, 如果等待所有的图层都生成完毕, 再进行合成的话, 会产生一些不必要的开销, 也会让合成图片的时间变得更久.
因此, 合成线程会将每个图层分割为大小固定的图块, 然后优先绘制靠近视口的图块, 这样就可以大大加速页面的显示速度. 不过有时候, 即使只绘制那些优先级最高的图块, 也要耗费不少的时间, 因为涉及到一个很关键的因素, 即纹理上传, 这是因为从计算机内存上传到 GPU 内存的操作会比较慢.
为了解决这个问题, Chrome 又采取了一个策略: 在首次合成图块的时候使用一个低分辨率的图片. 比如可以是正常分辨率的一半, 分辨率减少一半, 纹理就减少了四分之三. 在首次显示页面内容的时候, 将这个低分辨率的图片显示出来, 然后合成器继续绘制正常比例的网页内容, 当正常比例的网页内容绘制完成后, 再替换掉当前显示的低分辨率内容. 这种方式尽管会让用户在开始时看到的是低分辨率的内容, 但是也比用户在开始时什么都看不到要好.
图层绘制
在完成图层树的构建之后, 渲染引擎会对图层树中的每个图层进行绘制. 渲染引擎会把一个图层的绘制拆分成很多小的绘制指令(其实就是执行一些函数), 然后再把这些指令按照顺序组成一个待绘制列表. 就好比说先绘制蓝色背景; 再在中间绘制一个红色的圆; 再在圆上绘制绿色三角形. 你可以通过 Chrome DevTools 中的 layers tab, 了解你的网站在绘制时的绘制指令.
栅格化(raster)操作
绘制列表只是用来记录绘制顺序和绘制指令的列表, 而实际上绘制操作是由渲染引擎中的合成线程来完成的. 下图描述了渲染主线程和合成线程之间的关系, 当图层的绘制列表准备好之后, 主线程会把该绘制列表提交(commit)给合成线程.
让我们看一看合成线程是怎样工作的. 首先我们知道通常一个页面很长, 但是用户只能看到其中的一部分, 我们把用户可以看到的这个部分叫做视口(viewport). 换句话说, 如果滚动条很长的话, 用户每次滚动只能看到一小部分, 所以在这种情况下, 要绘制出所有图层内容的话, 就会产生太大的开销, 而且也没有必要.
基于此, 合成线程会将图层划分为图块(tile), 这些图块的大小通常是 256*256
或者 512*512
. 然后合成线程会按照视口附近的图块来优先生成位图, 实际生成位图的操作是由栅格化来执行的. 因此, 所谓栅格化, 是指将图块转换为位图. 图块是栅格化执行的最小单位. 渲染进程维护了一个栅格化的线程池, 所有的图块栅格化都是在线程池内执行的.
通常, 栅格化过程都会使用 GPU 来加速生成, 使用 GPU 生成位图的过程叫快速栅格化, 或者 GPU 栅格化, 生成的位图被保存在 GPU 内存中. 由于 GPU 操作是运行在 GPU 进程中, 如果栅格化操作使用了 GPU, 那么最终生成位图的操作是在 GPU 中完成的, 这就涉及到了跨进程操作. 从下图可以看出, 渲染进程把生成图块的指令发送给 GPU, 然后在 GPU 中执行生成图块的位图, 并保存在 GPU 的内存中.
合成和显示
一旦所有图块都被光栅化, 合成线程就会生成一个绘制图块的命令, DrawQuad, 然后将该命令提交给浏览器进程. 浏览器进程里面有一个叫 viz 的组件, 用来接收合成线程发过来的 DrawQuad 命令, 然后根据 DrawQuad 命令, 将其页面内容绘制到内存中, 最后再将内存显示在屏幕上.
需要重点关注的是, 合成操作是在合成线程上完成的, 这也就意味着在执行合成操作时, 是不会影响到主线程执行的. 这就是为什么经常主线程卡住了, 但是 CSS 动画依然能执行的原因.
渲染完成
至此, 页面就渲染完成了, 此时, 渲染进程会发送一个消息给浏览器进程, 浏览器接收到消息后, 会停止标签图标上的加载动画.
加餐: 重排, 重绘, 合成
重排
重排意味着更新了元素的几何属性, 如果你通过 JavaScript 或者 CSS 修改元素的几何位置属性, 例如改变元素的宽度, 高度等, 那么浏览器会触发重新布局, 解析之后的一系列子阶段, 这个过程就叫重排. 无疑, 重排需要更新完整的渲染流水线, 所以开销也是最大的.
重绘
重绘意味着更新元素的绘制属性, 例如更改某些元素的背景颜色. 从下图可以看出, 由于仅仅改变的是背景颜色, 那么布局阶段将不会被执行, 因为并没有引起几何位置的变换, 所以就直接进入了绘制阶段, 然后执行之后的一系列子阶段, 这个过程就叫重绘. 相较于重排操作, 重绘省去了布局和分层阶段, 所以执行效率会比重排操作要高一些.
合成
那如果你更改一个既不要布局也不要绘制的属性, 渲染引擎将跳过布局和绘制, 只执行后续的合成操作, 我们把这个过程叫做合成. 下面这个例子, 我们使用了 CSS 的 transform 来实现动画效果, 这可以避开重排和重绘阶段, 直接在非主线程上执行合成动画操作. 这样的效率是最高的, 因为是在非主线程上合成, 并没有占用主线程的资源, 另外也避开了布局和绘制两个子阶段, 所以相对于重绘和重排, 合成能大大提升绘制效率.
总结
- 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构.
- 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets, 计算出 DOM 节点的样式.
- 创建布局树, 并计算元素的布局信息.
- 对布局树进行分层, 并生成分层树.
- 为每个图层生成绘制列表, 并将其提交到合成线程.
- 合成线程将图层分成图块, 并在光栅化线程池中将图块转换成位图.
- 合成线程发送绘制图块命令 DrawQuad 给浏览器进程.
- 浏览器进程根据 DrawQuad 消息生成页面, 并显示到显示器上.
PREVIOUS POST
[HTTP 系列] 第 4 篇 —— HTTPS
NEXT POST
浏览器架构的前生今世