这个问题是前端的经典问题,从这个问题出发我们可以从根本上了解如何解决性能优化问题~
首先我们可以在开头大概了解下在浏览器输入 URL到页面展示,中间有哪些步骤:
- 用户从浏览器进程里输入请求信息
- DNS 域名解析
- 网络发起 URL 请求
- 服务器响应 URL 请求之后,浏览器进程就要开始准备渲染进程了
- 渲染进程准备好之后,需要先向渲染进程提交页面数据,我们称之为提交文档的阶段
- 渲染进程接收完文档信息之后,开始解析页面和加载子资源,完成页面的渲染
接下来我们详细的了解下各个步骤干了些什么
1. 用户输入
首先用户在地址栏输入一个查询关键字的时候,地址栏会判断输入的是搜索内容还是请求 URL
- 如果是搜索内容,地址栏会使用浏览器的默认搜索引擎,来合成新的带搜索关键字的 URL
- 如果输入的内容符合 URL 的规则,比如输入的是 baidu.com ,那么地址栏会根据规则,把这段内容加上协议,合成完整的 URL,比如:www.baidu.com
当用户按下回车,浏览器开始加载 URL
2. DNS 域名解析(可见 Page - DNS 域名解析)
这一步开始进入页面资源请求过程,首先网络进程会查找本地缓存是否缓存了该资源
- 首先网络进程会查找本地缓存是否缓存了请求的资源
- 如果缓存了该资源,那么直接返回资源给浏览器进程
- 如果在缓存中没有,则进行 DNS 解析,以获取请求域名的服务器 IP 地址
- DNS 解析方式
- 客户端和浏览器、本地 DNS 之间的查询方式是递归查询
- 本地 DNS 服务器与根域及其子域之间的查询方式是迭代查询 简而言之就是:
- 如果缓存了该资源,那么直接返回资源给浏览器进程
- 如果在缓存中没有,直接进入网络请求流程,请求前第一步是进行 DNS 解析,以获取请求域名的服务器 IP 地址,如果进行的 HTTPS 请求协议,那么还需要建立 TLS 连接
3. URL 请求过程
接下来利用 IP 地址和服务器简历 TCP 连接,连接建立之后,浏览器会构建请求行、请求头等信息,并把该域名相关的 cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息,以下是详细步骤
建立 TCP 连接 - 三次握手
获取到了服务器的 IP 后开始进行 TCP 连接,如果是进行的 HTTPS 请求协议,HTTPS 其实有两部分组成(HTTP + SSL/TLS),也就是在 HTTP 的基础上加了一层处理加密信息的模块,服务端和客户端的信息传输都会通过 TLS进行加密,所以传输的数据都是加密后的数据。
TCP 三次握手
-
第一次握手
建立连接,客户端发送连接请求报文段、将 SYN 置为 1,Sequence Number 为 X ,然后客户端进入 SYN_SEND 状态,等待服务器的确认
-
第二次握手
服务器收到客户端的 SYN 报文段,需要对这个 SYN 报文段进行确认,设置 Acknowledgment Number 为 x+1 (Sequence Number+1)
同时自己还要发送 SYN 请求信息,将 SYN 置为 1,Sequence Number 为 y
服务器端将上述所有信息放在一个报文段(即 SYN+ACK 报文段),一并发送给客户端,此时服务器进入 SYN_RECV 状态
-
第三次握手
客户端收到服务器的 SYN+ACK 报文段,然后将 Acknowledgment Number 为 y+1,向服务器发送 ACK 报文段,这个报文段发送完成之后,客户端和服务器都进入 established 状态,完成 TCP 三次握手
❓ - 为什么要进行三次握手?
✅ - 为了防止服务器端开启一些无用的连接增加服务器开销以及防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误。
- 由于网络传输是有延时的 (要通过网络光纤和各种中间代理服务器),在传输的过程中,比如客户端发起了 SYN=1 创建连接的请求(第一次握手)。
- 如果服务器端就直接创建了这个连接并返回包含 SYN、ACK 和 Seq 等内容的数据包给客户端,这个数据包因为网络传输的原因丢失了,丢失之后客户端就一直没有接收到服务器返回的数据包。
- 那客户端设置了一个超时时间,时间到了就关闭了连接创建的请求。再重新发出创建连接的请求,而服务器端是不知道客户端有没有接收到服务器端返回的信息,那接收到客户端重新发出的请求后又要重开一个连接窗口。
- 这样没有给服务器端一个创建还是关闭连接端口的请求,服务器端的端口就一直开着,等到客户端因超时重新发出请求时,服务器就会重新开启一个端口连接。那么服务器端上没有接收到请求数据的上一个端口就一直开着,长此以往,这样的端口多了,就会造成服务器端开销的严重浪费。
- 还有一种情况是已经失效的客户端发出的请求信息,由于某种原因传输到了服务器端,服务器端以为是客户端发出的有效请求,接收后产生错误。
- 所以我们需要“第三次握手”来确认这个过程,让客户端和服务器端能够及时地察觉到因为网络等一些问题导致的连接创建失败,这样服务器端的端口就可以关闭了不用一直等待。
- 也可以这样理解:“第三次握手”是客户端向服务器端发送数据,这个数据就是要告诉服务器,客户端有没有收到服务器“第二次握手”时传过去的数据。若发送的这个数据是“收到了”的信息,接收后服务器就正常建立 TCP 连接,否则建立TCP连接失败,服务器关闭连接端口。由此减少服务器开销和接收到失效请求发生的错误。
SSL 握手过程
-
第一阶段
建立安全能力,比如协议版本、版本 ID、密码构件、压缩方法、初始随机数
-
第二阶段
服务器发送证书、密钥交换数据和证书请求、最后发送请求 - 相应阶段的结束信号
-
第三阶段
如果有证书请求客户端发送此证书 之后客户端发送密钥交换数据 也可以发送证书验证消息
-
第四阶段
变更密码构件和结束握手协议
发送 HTTP 请求、服务器处理请求,返回响应结果
连接建立之后,浏览器会构建请求行、请求头等信息,并把该域名相关的 cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。
客户端请求文件的时候,如果发现自己缓存的文件有 Last Modified,则会在请求中包含 if-modified-since,这个时间就是缓存文件的 Last Modified,所以如果请求中包含 if-modified-since,就说明已经有缓冲在客户端,服务器只要判断这个时间和当前请求的文件的修改时间是否一致就可以返回状态码(304 / 200)
服务器收到请求信息后,会根据请求信息生成响应数据(包括响应行,响应头,响应体)等信息, 并发送给网络进程,等网络进程接受了响应行和响应头后,就开始解析响应头的内容,首先是对状态码的区分:
-
如果请求需要重定向则返回 301 或者 302,说明服务器需要浏览器重定向到其他 URL。这时网络进程会从响应头的 Location 字段里面读取重定向的地址,然后再发起新的 HTTP 或者 HTTPS 请求,一切又重头开始了。
-
如果发现客户端的请求头部有缓存的相关信息,比如 if-none-match 或者 if-modified-since,则验证缓存是否有效,有效则返回状态码 304,客户端将会使用缓存的文件。
-
若无需重定向且无代码问题无缓存则重新返回资源,状态码为 200,那么我们的流程就可以正常往下进行。
接下来就是对响应的数据类型的处理,因为 URL 请求的数据类型有时候是下载类型有时候是 HTML 页面,浏览器会根据响应头的 Content-Type 区分响应的数据是什么类型的
- Content-Type: text/html - 服务器的返回数据格式是 HTML 格式。
- Content-Type: application/octet-stream - 数据是字节流类型,通常情况下浏览器会按照下载类型来处理该请求,该请求会被提交给浏览器的下载管理器,同时 URL 请求的流程就此结束。
所以如果 Content-Type 的值被浏览器判断成下载类型,那么该请求会被提交给浏览器的下载管理器,同时 URL 请求的导航流程就此结束
如果是 HTML ,浏览器会继续进行导航流程,由于 Chrome 的页面渲染是运行在渲染流程中的,所以接下来就要准备渲染进程了,但是在渲染之前还有一个步骤就是关闭 tcp 连接
4. 关闭 TCP 连接 - 四次挥手
-
第一次挥手
主机 1 (可以试客户端也可以是服务器)设置 Sequence Number 和 Acknowledgment Number 向主机 2 发送一个 FIN 报文段,此时主机 1 进入 FIN_WAIT_1 状态。这表示主机 1 没有数据要发送给主机 2 了。
-
第二次挥手
主机 2 收到了主机 1 发送的 FIN 报文段,向主机 1 返回一个 ACK 报文段,Acknowledgment Number = Sequence Number + 1,主机 2 进入 FIN_WAIT_2 状态,表示主机 2 告诉主机 1:我同意你的关闭请求。
-
第三次挥手
主机 2 向主机 1 发送 FIN 报文段,请求关闭连接,同时主机 2 进入 LAST_ACK zhuang't
-
第四次挥手
主机 1 收到主机 2 发送的 FIN 报文段,向主机 2 发送 ACK 报文段,然后主机 1 进入 TIME_WAIT 状态,主机 2 收到主机 1 的 ACK 报文段之后就关闭了连接
此时主机 1 等待了 2MSL(TCP报文在网络中最长存活时间) 之后没有收到回复,说明主机 2 已关闭了连接,所以主机 1 也可以正常关闭了。
❓ - 为什么要进行四次挥手?
是因为FIN释放连接报文与ACK确认接收报文是分别由第二次和第三次"握手"传输的。那为何建立连接时一起传输,释放连接时却要分开传输?
- 建立连接时,被动方服务器端结束 CLOSED 阶段进入握手阶段并不需要任何准备,可以直接返回 SYN 和 ACK 报文,开始建立连接。
- 释放连接时,被动方服务器,突然收到主动方客户端释放连接的请求时并不能立即释放连接,因为还有必要的数据需要处理,所以服务器先返回ACK确认收到报文,经过 CLOSE-WAIT 阶段准备好释放连接之后,才能返回 FIN 释放连接报文。
5. 准备渲染进程
通常情况下,Chrome 会为每个页面分配一个渲染进程,但是对同一站点的情况就会共用一个进程(之前讲过)
渲染进程准备好之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中,并没有提交给渲染进程,所以下一步就到了提交文档阶段。
6. 提交文档
这里的文档,指的是 URL 请求的响应体数据
- 提交文档的消息是由浏览器进程发出的,渲染进程接收到提交文档的消息后,会和网络进程建立传输数据的通道
- 等文档数据传输完成之后,渲染进程会返回确认提交的消息给浏览器进程
- 浏览器接收到确认提交的消息后,才会更新浏览器界面状态,包括了安全状态、地址栏状态的 URL、前进后退的历史状态,并更新 Web 页面
这也就解释了为什么在浏览器输入一个地址后,之前的页面没有马上消失,二是要加载一会才回更新页面。 到了这里,一个完整的导航流程就走完了,马上进入渲染阶段
7. 渲染阶段
一旦文档被提交,渲染进程就要开始页面解析和子资源加载了,这就进入到了渲染流程
我们知道页面的三大要素:
- HTML - 由标签和文本组成,负责页面的结构显示
- CSS - 层叠样式,由选择器和样式组成 - 负责文本的样式
- JavaScript - 负责页面的逻辑交互
由于渲染机制过于复杂,所以渲染模块在执行过程中会被划分为很多子阶段,输入的 HTML\CSS\JavaScript 经过这些子阶段,最后输出像素,这样的处理流程叫做渲染流水线,大致如图所示
按照渲染的时间顺序,流水线可以氛围如下几个子阶段:
构建 DOM 树 ---> 样式计算 ---> 布局阶段 ---> 分层 ---> 绘制 ---> 分块 ---> 光栅化 ---> 合成
在每个阶段都会有三个内容:
- 开始每个子阶段都有其输入的内容
- 每个子阶段都有其处理过程
- 最终每个子阶段都会生成输出内容
那么接下来我们一步步来了解这些子阶段
构建 DOM 树
由于浏览器无法直接理解和使用 HTML,所以需要将 HTML 转换为浏览器可以理解的解构 - DOM 树 树结构如下所示,其中的每个点我们叫做节点
我们可以在开发者工具的控制台中,输入 document 得到该网页的完整 DOM 结构, 如下图所示
DOM 和 HTML 内容几乎是一样的,但是和 HTML 不同的是,DOM 是保存在内存中的树状结构,可以通过 JavaScript 来查询或者修改其内容
所以构建 DOM 的输入内容是一个 HTML 文件,经由 HTML 解析器解析,最终输出树状结构的 DOM
这样已经生成好 DOM 树了,但是 DOM 节点的样式还未知,所以下一步就是样式计算
样式计算
我们知道 CSS 样式来源有三种:
- link 引用的外部 CSS
- 标记内的 CSS
- 元素的 style 属性内嵌的 CSS
和 HTML 文件一样,浏览器并不能直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本的时候,会执行一个转换操作,把 CSS 转换成浏览器可以理解的:styleSheets
在控制台中,输入 document.styleSheets 得到该网页的 styleSheets ,如下图所示
这个图里面就包含了我们刚刚说到的三种样式来源 接下来浏览器就会把样式表中的属性进行标准化转换(例如 em/rem -> px)
转换示例如上图所示
转换完成之后,就下来就需要计算 DOM 树种每个节点的样式属性了,那么如何来进行计算呢?
这就涉及到了 CSS 的继承规则和层叠规则
- CSS 继承
- CSS 继承就是每个节点都会包含父节点有的样式(比如 body 节点设置了 font-size:18px; 那么 body 下面的子节点的 font-size 都是18)
- 在开发者工具控制台的 computed 中,我们可以查看样式来源,可以看到具体的某些样式是来自样式文件还是 UserAgent 样式表(浏览器的默认样式)
- 层叠样式规则
- 层叠是 CSS 的一个基本特征,它定义了如何合并来自多个源的属性值的算法
样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式,在计算过程就需要遵循 CSS 继承和层叠两个规则
这个阶段最终输出的是每个 DOM 节点的样式,并且保存在 ComputedStyle 的结构中。
布局阶段
到了这个阶段,我们有了 DOM 树和 DOM 数中元素的样式,但是呢这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息
那么接下来就要计算出 DOM 树中可见元素的几何位置,这个计算过程叫做布局
1. 创建布局树
在 DOM 树中可能会有很多不可见的元素,比如 head 标签和一些有 display:none 的属性,所以在显示前,我们还要额外构建一棵只包含可见元素的布局树 我们可以结合下图来看看布局树的构造过程:
从上图可以看出来,DOM 树种所有不可见的元素都没有包含到布局树中
为了构建布局树,浏览器大体上完成了下面这些工作
- 遍历DOM 树中所有可见节点,并把这些节点加到布局中
- 不可见的节点会被忽略掉,如 head 标签下面的全部内容,以及 display:none 的元素也不会被包含进布局树
2. 布局计算
现在我们有了一棵完整的布局树,接下来就是计算布局树中节点的坐标位置(计算过程暂时略过,比较复杂)
在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局 阶段并没有清晰地将输入内容和输出内容区分开来。针对这个问题,Chrome 团队正在重构布局代码,下一代布局系统叫 LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。
分层
有了布局树之后,是不是就可以开始绘制页面了呢?答案当然是 false 因为页面中往往有很多复杂的效果,比如 3D 变换,页面滚动、z-index 的 Z 轴排序等等,为了更方便的实现这些效果 渲染引擎还需要为特定的节点生成专用的涂层,并生成一棵对应的图层树,正是各种各样的图层叠加在一起构成了最终的页面
打开 Chrome 的“开发者工具”,选择“Layers”标签,就可以可视化页面的分层情况,
到这里我们就知道浏览器的页面实际上被分成了很多图层,这些图层叠加合成了最终的页面,我们可以来看看这些图层和布局树节点之间的关系:
通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的图层,那么这个节点就舒服父节点的图层。
不管怎么样,最种每个节点都会直接或间接的属于一个层。
那么这里有个问题,在什么样的条件下,渲染引擎才会为特定的节点创建新的图层呢?
- 拥有层叠上下文属性的元素会被提升为单独的一层(position:fixed / z-index / filter:blue(5px) / opacity: 0.5 等)
- 需要剪裁 (clip) 的地方也会被单独创建图层(比如一个 div 长宽只有 200,但是内容很多,那么多余的部分就会出现被裁剪,这种情况渲染引擎会为文字部分单独创建一层,如果出现滚动条,那么滚动条也是一个单独的层)
所以如果元素有了层叠上下文属性或者需要被剪裁,满足任意一点就会被提升为单独的图层
图层绘制
完成了图层树的创建之后,渲染引擎会对图层树中的每个图层进行绘制(按照基本回话顺序,比如先底色再画图形的顺序,所以是低层级到高层级的绘制) 我们可以打开开发者工具的 Layers 标签,选择 document 层,就可以实际体验下绘制列表。
栅格化
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。 我们可以结合下图来看下渲染主线程和合成线程之间的关系:
当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程,合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图
合成线程发送绘制图块命令DrawQuad给浏览器进程,浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上
总结
- 浏览器不能直接理解 HTML 数据,所以第一步需要将其转换为浏览器能够理解的 DOM 树结构;
- 生成 DOM 树后,还需要根据 CSS 样式表,来计算出 DOM 树所有节点的样式;
- 最后计算 DOM 元素的布局信息,使其都保存在布局树中。
如果下载 CSS 文件阻塞了,会阻塞 DOM 树的合成吗?会阻塞页面的显示吗?
不会阻塞dom树的构建,原因Html转化为dom树的过程,发现文件请求会交给网络进程去请求对应文件,渲染进程继续解析Html。 会阻塞页面的显示,当计算样式的时候需要等待css文件的资源进行层叠样式。资源阻塞了,会进行等待,直到网络超时,network直接报出相应错误,渲染进程继续层叠样式计算
8. 渲染完成
页面生成完成后,渲染进程会发送一个消息给浏览器进程,浏览器接收到消息后,会停止标签图标上的加载动画。这样,一个完整的页面就生成了。