浏览器从输入 URL 到页面展示发生了什么
导航阶段
从浏览器输入 URL 到页面展示到底经历了什么?本文通过两个阶段来解释,第一个阶段是导航阶段,也就是在页面真正开始渲染之前浏览器所做的工作,第二个阶段是渲染阶段,也就是把接受到的 Http 信息通过浏览器内部的处理展现在页面上。

浏览器进程、渲染进程和网络进程的主要职责
- 主要负责用户交互、子进程管理和文件储存等功能。
- 是面向渲染进程和浏览器进程等提供网络下载功能。
- 的主要职责是把从网络下载的 HTML、JavaScript、CSS、图片等资源解析为可以显示和交互的页面。因为渲染进程所有的内容都是通过网络获取的,会存在一些恶意代码利用浏览器漏洞对系统进行攻击,所以运行在渲染进程里面的代码是不被信任的。这也是为什么 Chrome 会让渲染进程运行在安全沙箱里,就是为了保证系统的安全。
流程
- 首先,浏览器进程接收到用户输入的 URL 请求,浏览器进程便将该 URL 转发给网络进程。
- 然后,在网络进程中发起真正的 URL 请求。
- 接着网络进程接收到了响应头数据,便解析响应头数据,并将数据转发给浏览器进程。
- 浏览器进程接收到网络进程的响应头数据之后,发送“提交导航 (CommitNavigation)”消息到渲染进程;
- 渲染进程接收到“提交导航”的消息之后,便开始准备接收 HTML 数据,接收数据的方式是直接和网络进程建立数据管道;
- 最后渲染进程会向浏览器进程“确认提交”,这是告诉浏览器进程:“已经准备好接受和解析页面数据了”。
- 浏览器进程接收到渲染进程“提交文档”的消息之后,便开始移除之前旧的文档,然后更新浏览器进程中的页面状态。
用户发出 URL 请求到页面开始解析的这个过程,就叫做导航。
拆解解析
1.用户输入
当用户在地址栏中输入一个查询关键字时,地址栏会判断输入的关键字是搜索内容,还是请求的 URL。
- 如果是搜索内容,地址栏会使用浏览器默认的搜索引擎,来合成新的带搜索关键字的 URL。
- 如果判断输入的内容符合 URL 规则,比如输入的是 anblog.top,地址栏会根据规则,把这段内容加上协议,合成完整的 URL。https://anblog.top/
2.URL 请求过程
页面资源请求过程。这时,浏览器进程会通过进程间通信(IPC)把 URL 请求发送至网络进程,网络进程接收到 URL 请求后,会在这里发起真正的 URL 请求流程。
首先,网络进程会查找本地缓存是否缓存了该资源。 如果有缓存资源,那么直接返回资源给浏览器进程;如果在缓存中没有查找到资源,那么直接进入网络请求流程。发起网络请求前的第一步是要进行 DNS 解析(1.先查浏览器的缓存;2.查本机的 hosts 文件;3.域名服务器解析;4.若域名服务器也没有,递归(让别人找)加迭代解析(别人告诉你或者给你指路)),以获取请求域名的服务器 IP 地址。如果请求协议是 HTTPS,那么还需要建立 TLS 连接。
接下来就是利用 IP 地址和服务器建立 TCP 连接。 连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。
服务器接收到请求信息后,会根据请求信息生成响应数据(包括响应行、响应头和响应体等信息),并发给网络进程。 等网络进程接收了响应行和响应头之后,就开始解析响应头的内容了。(为了方便讲述,下面我将服务器返回的响应头和响应行统称为响应头。)
几个相关概念
重定向
在接收到服务器返回的响应头后,网络进程开始解析响应头,如果发现返回的状态码是 301 或者 302(在服务器中配置的),那么说明服务器需要浏览器重定向到其他 URL。这时网络进程会从响应头的 Location 字段里面读取重定向的地址,然后再发起新的 HTTP 或者 HTTPS 请求,一切又重头开始了。就像下面的 nginx.conf 的内容含义是如果请求 blog.annanblog.top,就重定向到https://blog.annanblog.top
如何查看服务器返回的响应头信息
在终端 cmd 输入不加 https 的地址
curl -I blog.annanblog.top
直接输入 https 地址
curl -I https://blog.annanblog.top
结论:在导航过程中,如果服务器响应行的状态码包含了 301、302 一类的跳转信息,浏览器会跳转到新的地址继续导航;如果响应行是 200,那么表示浏览器可以继续处理该请求。
响应数据类型处理
在处理了跳转信息之后,我们继续导航流程的分析。URL 请求的数据类型,有时候是一个下载类型,有时候是正常的 HTML 页面,那么浏览器是如何区分它们呢?
答案是 Content-Type。Content-Type 是 HTTP 头中一个非常重要的字段, 它告诉浏览器服务器返回的响应体数据是什么类型,然后浏览器会根据 Content-Type 的值来决定如何显示响应体的内容。
curl -I https://blog.annanblog.top
可以看到这个响应头的 Content-type 字段值是 text/html,这是在告诉浏览器,服务器返回的数据是 HTML 格式。
如果是下载的请求,Content-Type 的值会变
从返回的响应头信息来看,Content-Type 的值是 application/octet-stream,显示数据是字节流类型的,通常情况下,浏览器会按照下载类型来处理该请求。
结论:不同 Content-Type 的后续处理流程也截然不同。如果 Content-Type 字段的值被浏览器判断为下载类型,那么该请求会被提交给浏览器的下载管理器,同时该 URL 请求的导航流程就此结束。但如果是 HTML,那么浏览器则会继续进行导航流程。由于 Chrome 的页面渲染是运行在渲染进程中的,所以接下来就需要准备渲染进程了。
3.准备渲染进程
默认情况下,Chrome 会为每个页面分配一个渲染进程,也就是说,每打开一个新页面就会配套创建一个新的渲染进程。但是,也有一些例外,在某些情况下,浏览器会让多个页面直接运行在同一个渲染进程中。
导航栏可以打开 chrome 的任务管理器
同站点的,到新页面还会共用一个线程,但是有一点需要注意,这个打开单击直接跳转的,如果是右键浏览器在新标签页打开则还是新建一个渲染线程。
“同一站点”定义为根域名(例如,annanblog.top)加上协议(例如,https:// 或者 http://),还包含了该根域名下的所有子域名和不同的端口,比如下面这三个:
https://blog.annanblog.top https://www.annanblog.top https://www.annanblog.top:8080
Chrome 的默认策略是,每个标签对应一个渲染进程。但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程。官方把这个。
案例: 比如我现在在 annanblog.top 页面,然后从这个页面打开 wordpress 登录界面
https://www.annanblog.top/wp-login.php
可以看到是同一站点的,打开 chrome 的任务管理器(在 chrome 头部右键打开)查看
可以看到是共用一个进程的,同时用旧站打开新站,新站是子域名 blog.annanblog.top
https://blog.annanblog.top/
可以看到依然使用同一个进程,由于 annanblog.top 和 blog.annanblog.top 的标签页拥有相同的协议和根域名,所以它们属于同一站点
但是当打开站尾的备案的时候(https://beian.miit.gov.cn/#/Integrated/index)
可以看到新开了一个进程,因为不是同一站点。
总结:
打开一个页面 chrome 默认所采取的渲染进程策略:
- 通常情况下,打开新的页面都会使用单独的渲染进程;
- 如果从 A 页面打开 B 页面,且 A 和 B 同属于一个站点的话,B 页面复用 A 页面的渲染进程;若不是,浏览器进程会为 B 页面创建一个新的渲染进程。
以上渲染进程准备好之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中,并没有提交给渲染进程,所以下一步就进入了提交文档阶段。
4.提交文档
提交文档,就是把网络进程中的 HTML 数据,提交给渲染进程,流程:
- 首先当浏览器接收到网络进程的响应头数据之后,就向渲染进程发起“提交文档”的消息;浏览器进程:好了,看来网络进程 OK 了,你两可以交接了!
- 当渲染进程接受到“提交文档”的消息后,会和网络进程建立传输数据的“管道”(IPC); 渲染进程:网络进程!来!交给我!
- 当文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程;渲染进程:好!我收到数据了!和你说一声,浏览器进程!
- 浏览器进程在收到“确认提交”的消息后,会更新浏览器界面状态,包括安全状态、地址栏的 URL、前进后退的历史状态、并更新 Web 界面。浏览器进程:我把我管的这部分浏览器上的用户交互的安排好了!
当浏览器进程确认提交之后:更新内容如下图:
- 更新前进后退状态
- 更新安全状态 小锁子
- 更新 URL 地址栏
- 更新 web 页面

这也就解释了为什么在浏览器的地址栏里面输入了一个地址后,之前的页面没有立马消失,而是要加载一会儿才会更新页面。之后就是进入渲染阶段。

中间的渲染模块就是第二阶段渲染阶段。
渲染阶段
流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成
重点关注:
- 开始每个子阶段都有其输入的内容;
- 然后每个子阶段有其处理过程;
- 最终每个子阶段会生成输出内容。
1.构建 DOM 树
为什么要构建 DOM 树呢?
这是因为浏览器无法直接理解和使用 HTML,所以需要将 HTML 转换为浏览器能够理解的结构——DOM 树。
树这种结构非常像我们现实生活中的“树”,其中每个点我们称为节点,相连的节点称为父子节点。树结构在浏览器中的应用还是比较多的,比如下面我们要介绍的渲染流程,就在频繁地使用树结构。

从图中可以看出,构建 DOM 树的输入内容是一个非常简单的 HTML 文件,然后经由 HTML 解析器解析,最终输出树状结构的 DOM。
在控制台输入 document 就可以看到 DOM 结构,可以看到 DOM 和 HTML 内容几乎是一样的,但是和 HTML 不同的是,DOM 是保存在内存中树状结构,可以通过 JavaScript 来查询或修改内容。
2.样式计算
样式计算的目的是为了计算出 DOM 节点中每个元素的具体样式,可以分为三步完成。
把 CSS 转换成浏览器能理解的结构。
CSS 的样式来源有三种:
通过 link 引用的外部 CSS 文件
<link href="normalize.css" rel="stylesheet">
<style> 标记内的 CSS
<style>你的css代码</style>
元素的 style 属性内嵌的 CSS
<div style="background:#fff"></div>
和 HTML 文件一样,浏览器也是无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets。
在 chrome 控制台输入 document.styleSheets,就可以看到如下图所示结构
<div align='center'>styleSheets</div>
转换样式表中的属性值,使其标准化
现在我们已经把现有的 CSS 文本转化为浏览器可以理解的结构了,那么接下来就要对其进行属性值的标准化操作。
比如以下的 CSS 文本
body { font-size: 2em } p {color:blue;} span {display: none} div {font-weight: bold} div p {color:green;} div {color:red; }
可以看到上面的 CSS 文本中有很多属性值,如 2em、blue、bold,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。
标准化后是什么样子?
2em 被解析成了 32px,red 被解析成了 rgb(255,0,0),bold 被解析成了 700......
em 单位为一个相对的度量单位,它通过寻找父标签的 font-size。然后通过计算得出自身的 font-size。font-size 是几 em 就是父标签的 font-size 的几倍。利用 em 单位设置便签的 width 或者 height 等属性原理也一样。
计算出 DOM 树中每个节点的具体样式
样式的属性已被标准化了,接下来就需要计算 DOM 树中每个节点的样式属性了,如何计算呢?
这就涉及到 CSS 的继承规则和层叠规则了。
首先是 CSS 继承。CSS 继承就是每个 DOM 节点都包含有父节点的样式。
比如以下的 CSS 文本:
body { font-size: 20px } 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。
再举个例子 浏览器对着元素右键检查:
特别提下 UserAgent 样式,它是浏览器提供的一组默认样式,如果你不提供任何样式,默认使用的就是 UserAgent 样式。
层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在 CSS 处于核心地位,CSS 的全称“层叠样式表”正是强调了这一点。
样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式,在计算过程中需要遵守 CSS 的继承和层叠两个规则。这个阶段最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。
如果你想了解每个 DOM 元素最终的计算样式,可以打开 Chrome 的“开发者工具”,选择第一个“element”标签,然后再选择“Computed”子标签,如下图所示:
3.布局阶段
现在,我们有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做布局。
创建布局树
DOM 树有很多不可见的元素,比如 head 标签,还有使用了 display:none 属性的元素。所以在显示之前,我们还需要额外构建一棵只包含可见元素的元素布局树。
布局计算
现在有了一棵完整的布局树,接下来就要计算布局树节点的坐标位置,布局计算过程暂时不讲。
在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容.
4.分层
现在我们有了布局树,而且每个元素的具体位置信息都计算出来了,那么接下来是不是就要开始着手绘制页面了?答案依然是否定的。
因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。如果你熟悉 PS,相信你会很容易理解图层的概念,正是这些图层叠加在一起构成了最终的页面图像。
要想直观地理解什么是图层,你可以打开 Chrome 的“开发者工具”,选择“Layers”标签,就可以可视化页面的分层情况,如下图所示

可以看到这个效果就和 PS 图层一样,一层一层最后叠加成了最终的页面。

并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。如上图中的 span 标签没有专属图层,那么它们就从属于它们的父节点图层。但不管怎样,最终每一个节点都会直接或者间接地从属于一个层。
那么需要满足什么条件,渲染引擎才会为特定的节点创建新的图层呢?通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。
第一点,拥有层叠上下文属性的元素会被提升为单独的一层。页面是个二维平面,但是层叠上下文能够让 HTML 元素具有三维概念,这些 HTML 元素按照自身属性的优先级分布在垂直于这个二维平面的 z 轴上。你可以结合下图来直观感受下:

从图中可以看出,明确定位属性的元素、定义透明属性的元素、使用 CSS 滤镜的元素等,都拥有层叠上下文属性。若你想要了解更多层叠上下文的知识,你可以参考
第二点,需要剪裁(clip)的地方也会被创建为图层。不过首先你需要了解什么是剪裁,结合下面的 HTML 代码:
<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 的面积,这时候就产生了剪裁,渲染引擎会把裁剪文字内容的一部分用于显示在 div 区域。
出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。

所以说,元素有了层叠上下文的属性或者需要被剪裁,满足其中任意一点,就会被提升成为单独一层。
5.图层绘制
在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,那么接下来我们看看渲染引擎是怎么实现图层绘制的?
试想一下,如果给你一张纸,让你先把纸的背景涂成蓝色,然后在中间位置画一个红色的圆,最后再在圆上画个绿色三角形。你会怎么操作呢?
通常,你会把你的绘制操作分解为三步:
- 绘制蓝色背景;
- 在中间绘制一个红色的圆;
- 再在圆上绘制绿色三角形。
渲染引擎实现图层的绘制与之类似,会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表

你也可以打开“开发者工具”的“Layers”标签,选择“document”层,来实际体验下绘制列表

图中,区域 1 就是 document 的绘制列表,拖动区域 2 中的进度条可以重现列表的绘制过程。
6.栅格化(raster)操作
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系:

如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程,那么接下来合成线程是怎么工作的呢?
通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport),就比如你下拉页面,只能看到你拉动到的显示在你屏幕的东西。

在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。
基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512,如下图所示:

合成线程会按照视口附近的图块来优先生成位图(位图又叫做点阵图,是一个个很小的颜色小方块组合在一起的图片。一个小方块代表 1px),实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。
位图:又称点阵图像或栅格图像,最小单位由像素构成的图
而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:

通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。
相信你还记得,GPU 操作是运行在 GPU 进程中,如果栅格化操作使用了 GPU,那么最终生成位图的操作是在 GPU 中完成的,这就涉及到了跨进程操作。具体形式你可以参考下图:

7.合成和显示
一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。
浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
到这里,经过这一系列的阶段,编写好的 HTML、CSS、JavaScript 等文件,经过浏览器就会显示出漂亮的页面了。
渲染流水线总结:
好了,我们现在已经分析完了整个渲染流程,从 HTML 到 DOM、样式计算、布局、图层、绘制、光栅化、合成和显示。下面我用一张图来总结下这整个渲染流程:

- 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构。
- 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
- 创建布局树,并计算元素的布局信息。
- 对布局树进行分层,并生成分层树。
- 为每个图层生成绘制列表,并将其提交到合成线程。
- 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
- 合成线程发送绘制图块命令 DrawQuad 给浏览器进程。
- 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。