文章目录
前言
写这篇文章的初衷:
记得最开始学前端知识时,是一点一点的积累,一个知识点一个知识点的攻克。
就这样,虽然在很长一段时间内积累了不少的知识,但是,总是无法将它串联到一起。每次梳理时都是很分散的,无法保持思路连贯性。
直到最近,在将DNS域名解析、建立TCP连接、构建HTTP请求、浏览器渲染过程‘’流程梳理一遍后,感觉就跟打通了任督二脉一样,有了一个整体的架构,以前的知识点都连贯起来了,至少现在知道了它的大部分骨架。
梳理出一个知识体系,以后就算再学新的知识,也会尽量往这个体系上靠拢,环环相扣,更容易理解,也更不容易遗忘。这也是本文的目标。
以 前端领域 的知识为重点,并且本文内容超多,建议先了解主干,然后分批次阅读。
这篇文章真的写了好久好久…
一、梳理主干流程
知识体系中,最重要的是骨架,脉络。有了骨架后,才方便填充细节。所以,先梳理下主干流程:
- 浏览器接收url并开启一个新进程(这一部分可以展开浏览器的进程与线程的关系)
- 浏览器解析输入的 URL,提取出其中的协议、域名和路径等信息。(这部分涉及URL组成部分)
- 浏览器向 DNS 服务器发送请求,DNS服务器通过 多层查询 将该 域名 解析为对应的 IP地址 ,然后将请求发送到该IP地址上,与 服务器 建立连接和交换数据。(这部分涉及DNS查询)
- 浏览器与服务器建立 TCP 连接。(这部分涉及TCP三次握手/四次挥手/5层网络协议)
- 浏览器向服务器发送 HTTP 请求,包含请求头和请求体。(4,5,6,7包含http头部、响应码、报文结构、cookie等知识)
- 服务器接收并处理请求,并返回响应数据,包含状态码、响应头和响应体。
- 浏览器接收到响应数据,解析响应头和响应体,并根据状态码判断是否成功。
- 如果响应成功,浏览器接收到http数据包后的解析流程(这部分涉及到html - 词法分析,解析成DOM树,解析CSS生成CSSOM树(样式树),合并生成render渲染树(样式计算)。然后layout布局,分层,调用GPU绘制等,最后将绘制的结果合成最终的页面图像,显示在屏幕上。这个过程会发生回流和重绘)。
- 连接结束 -> 断开TCP连接 四次挥手
梳理出主干骨架,然后就需要往骨架上填充细节内容。
二、浏览器接收url并开启一个新进程
这部分内容开始之前我们需要先通过一张图对 进程 和 线程 的关系有一个初步的了解。
1. 浏览器是多进程的
浏览器是多进程的,有一个主进程,每打开一个tab页面都会新开一个进程(某些情况下多个tab会合并进程)。
注意:在这里浏览器应该也有自己的优化机制,有时候打开多个tab页后,比如打开多个空白标签页。可以在Chrome任务管理器中看到,进程被合并了。
最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。
- 浏览器主进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。只有一个。
- 浏览器渲染进程(浏览器内核)(内部是多线程的):核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在
沙箱模式
下。 - 网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
- GPU进程:最多一个,用于3D绘制等。
- 第三方插件进程:要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响;仅当使用该插件时才创建。
强化记忆:在浏览器中打开一个网页相当于新起了一个进程(进程内有自己的多线程)
下图以 chrome浏览器 为例。我们可以自己通过Chrome的更多工具 =》 任务管理器 自行验证查看,可以看到chrome的任务管理器中有多个进程(分别是每一个Tab页面有一个独立的进程,以及一个主进程)
然后能看到每个进程的内存资源信息以及cpu占有率。
2. 浏览器内核是多线程的
每一个tab页面可以看作是浏览器内核的一个进程,然后这个进程是多线程的,它有几大类子线程
- GUI渲染线程:负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
GUI渲染线程与JS引擎线程是互斥的
。 - JS引擎线程:也叫 JS 内核,负责解析执行 JS 脚本程序的主线程,例如 V8 引擎。JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序。
- 事件触发线程:属于浏览器内核线程,主要用于控制事件,例如鼠标、键盘等,当事件被触发时,就会把事件的处理函数推进事件队列,等待 JS 引擎线程执行。
- 定时器触发线程:主要控制 setInterval和 setTimeout,用来计时,计时完毕后,则把定时器的处理函数推进事件队列中,等待 JS 引擎线程。
- 异步http请求线程:通过XMLHttpRequest连接后,通过浏览器新开的一个线程,监控readyState状态变更时,如果设置了该状态的回调函数,则将该状态的处理函数推进事件队列中,等待JS引擎线程执行。
可以看到,里面的JS引擎是内核进程中的一个线程,这也是为什么常说JS引擎是单线程的。
虽然 JS 是单线程的,但实际上参与工作的线程一共有四个:
后面三个只是协助,只有 JS 引擎线程是真正执行的。
3. JS引擎单线程的原因
JS引擎之所以是单线程,是由于JavaScript最初是作为浏览器脚本语言开发的,并且JavaScript需要操作DOM等浏览器的API,如果多个线程同时进行DOM更新等操作则可能会出现各种问题(如竞态条件、数据难以同步、复杂的锁逻辑等),因此将JS引擎设计成单线程的形式就可以避免这些问题。
虽然JS引擎是单线程的,但是通过使用 异步编程模型 和 事件循环机制,JS仍然可以实现高并发处理。
如果JS是多线程的场景描述:
那么现在有2个线程,process1 process2,由于是多线程的JS,所以他们对同一个dom,同时进行操作
process1 删除了该dom,而process2 编辑了该dom,同时下达2个矛盾的命令,浏览器究竟该如何执行呢?这时可能就会出现问题了。
4. GUI渲染线程与JS引擎线程互斥
由于JavaScript是可操纵DOM的,如果在修改这些元素属性同时渲染界面(即JS线程和UI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。
因此为了防止渲染出现不可预期的结果,浏览器设置GUI渲染线程与JS引擎为互斥的关系,当JS引擎执行时GUI线程会被挂起,
GUI更新则会被保存在一个队列中等到JS引擎线程空闲时立即被执行。
因为本文主要讲输入URL后页面的渲染过程,所以关于浏览器开启网络请求线程这部分详细内容大家可以移步查看,里面包括JS运行机制,进程线程的详解:
从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理
5. 渲染过程中遇到 JS 文件如何处理?
JS的加载、解析与执行会阻塞文档的解析,也就是说,在构建 DOM 时,HTML 解析器若遇到了 JavaScript,那么它会暂停文档的解析,将控制权移交给 JS 引擎,等 JS 引擎运行完毕,浏览器再从中断的地方恢复继续解析文档。
也就是说,如果想要首屏渲染的越快,就越不应该在首屏就加载 JS 文件,这也是建议将 script 标签放在 body 标签底部的原因。当然在当下,并不是说 script 标签必须放在底部,因为你可以给 script
标签添加 defer
或者 async
属性。
defer与async的区别
- defer要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行。
- async一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。
- 一句话,
defer
是“渲染完再执行
”,async
是“下载完就执行
”。 - 另外,如果有多个defer脚本,会按照它们在页面出现的顺序加载,而多个async脚本是不能保证加载顺序的。(因为只要该模块加载完成,就执行该模块,不确定模块什么时候能加载完)
二、解析URL
输入URL后,会进行解析(URL的本质就是统一资源定位符)
URL一般包括几大部分:
- 协议(Protocol):指访问资源时使用的协议,常见的协议有 HTTP、HTTPS、FTP 等。
- 主机名(Host):指服务器的域名或 IP 地址,用于唯一标识一个服务器。
- 端口号(Port):指服务器上提供服务的端口号,可以省略。例如,默认的 HTTP 端口为 80,HTTPS 端口为 443。
- 路径(Path):指服务器上资源的路径,表示访问资源时需要进入的目录层级以及资源的名称。
- 查询参数(Query):指对资源请求的参数,格式为 key=value,多个参数间使用
&
连接。 - 锚点(Fragment):指
#
后的hash值,一般用来定位到某个位置。
举个例子,www.example.com/index.html?key1=value1&key2=value2#section 表示了一个 URL,
其中协议为 HTTP,主机名为 www.example.com,路径为 /index.html,查询参数为 key1=value1 和 key2=value2,锚点为 section。
三、DNS域名解析
在解析过程之前我们先理解几个概念。
1. DNS是什么?
DNS(Domain Name System)是一种用于将域名
解析为IP地址
的系统。(把我们的域名映射为IP地址,这就是DNS的作用)
它可以将人们易于记忆的域名转换为服务器可识别的IP地址,这样用户就可以使用域名访问网站,而不必直接输入数字格式的IP地址。
在浏览器中输入网址时,电脑会先向DNS服务器
发送请求,获取该网址对应的IP地址
,并在成功获取后直接连接该IP地址对应的服务器,在服务器端获取网页内容并显示出来,完成整个访问过程。因此,DNS在互联网中起着至关重要的作用。
2. IP和域名的关系
IP(Internet Protocol)地址是一个数字标识,用于唯一识别连接到互联网上的每个计算机、服务器和其他设备。域名则是网站的人类可读的名称。域名系统(DNS服务器)可以将域名转换为与之关联的IP地址。
简单来说,IP地址是网络设备的标识符,而域名则是方便人们记忆和使用的网络地址别名。
域名系统通过将 域名
映射到 IP地址
,使互联网上的用户能够以易记的方式访问特定的网站或服务器。
3. 域名服务器概念图
从上面这张图可以看到,域名的管理是分层次的。最高级是根,也叫做根服务器
。从上往下功能逐渐细化。DNS就是和这些服务器进行打交道。
有了上面的这些概念,现在我们再来认识一下DNS域名解析过程就容易多了。
4. DNS域名解析过程
- 首先会在浏览器缓存中查询是否有该域名对应的IP地址,若有则直接返回,解析过程结束。
- 如果浏览器缓存中没有该域名对应的IP地址,则向本地DNS服务器发送查询请求。
- 如果本地DNS服务器缓存中有该域名对应的IP地址,则直接返回,解析过程结束。
- 如果本地DNS服务器缓存中没有该域名对应的IP地址,则向根域名服务器发送查询请求。
- 根域名服务器返回一个所查询域的顶级域名服务器地址。
- 本地DNS服务器向 顶级域名服务器 发送查询请求。
- 顶级域名服务器返回下一级DNS服务器的地址(权威DNS服务器)。
- 本地DNS服务器向权威DNS服务器发送查询请求。
- 权威DNS服务器返回该域名对应的IP地址,并将结果返回给本地DNS服务器。
- 本地DNS服务器将结果保存在缓存中,便于下次使用。并将结果返回给浏览器。
- 浏览器将结果保存在缓存中,并使用该IP地址访问对应的网站。
这个过程大体大体由一张图可以表示:从网上找的图片方便理解。
而且,需要知道dns解析是很耗时的,因此如果解析域名过多,会让首屏加载变得过慢,可以考虑dns-prefetch
优化
关于 本地DNS服务器 这里单独讲解下:
如果之前的过程无法解析时,操作系统会把这个域名发送给这个本地DNS服务器。每个完整的内网通常都会配置本地DNS服务器,例如用户是在学校或工作单位接入互联网,那么用户的本地DNS服务器肯定在学校或工作单位里面。它们一般都会缓存域名解析结果,当然缓存时间是受到域名的失效时间控制的。大约80%的域名解析到这里就结束了,后续的DNS迭代和递归也是由本地DNS服务器负责。
5. DNS解析时发现域名和IP不一致,访问了该域名会如何?
- 域名和IP不一致,域名解析成了其他的的IP地址,但是这个IP地址正确。访问该域名就会访问其他的网站。
知乎上有一个阿里巴巴的回答:
从技术上来讲是可以解析到任意IP地址的,这时候针对这个地址发起HTTP访问,HTTP头中的host字段会是你的域名(而非该IP对应站点的域名),如果对方的网站HTTP服务器没有做对应的防护就可以访问,如果对方的网站HTTP服务器有防护则无法访问。
- 域名和IP不一致,域名解析成了其他的的IP地址,但是这个IP地址错误,访问该域名就会失败。
可参考:DNS解析时发现域名和IP不一致,访问了该域名会如何(大厂真题)
四、建立 TCP 连接
需要了解3次握手规则建立连接以及断开连接时的四次挥手。
拿到了IP地址后,就可以发起HTTP请求了。HTTP请求的本质就是TCP/IP的请求构建。建立连接时需要 3次握手 进行验证,断开链接也同样需要 4次挥手 进行验证,保证传输的可靠性。
1. 三次握手
模拟三次握手(场景对话版):
客户端:hello,你是server么?
服务端:hello,我是server,你是client么
客户端:yes,我是client
可通过下方图文结合方式字理解三次握手:
三次握手原理:
第一次握手:客户端发送一个带有 SYN
(synchronize同步)标志的数据包给服务端。
第二次握手:服务端接收成功后,回传一个带有 SYN/ACK
标志的数据包传递确认信息,表示我收到了。
第三次握手:客户端再回传一个带有 ACK
标志的数据包,表示我知道了,握手结束。
其中:SYN标志位数置1,表示建立TCP连接;ACK表示响应,置1时表示响应确认。
三次握手过程详细说明:
刚开始客户端处于 Closed
的状态,服务端处于 Listen
状态。
- 第一次握手: 客户端发送标识位SYN = 1,随机产生序列号seq = x的数据包到服务端,服务端由SYN = 1知道客户端要建立连接,并进入
SYN_SENT
状态,等待服务器确认;(SYN=1,seq=x,x为随机生成的数值)
- 第二次握手: 服务器收到请求并确认联机信息后,向客户端发送标识位SYN = 1,ACK = 1和随机产生的序列号seq = y, 确认码ack number = x+1(客户端发送的seq+1)的数据包,此时服务器进入
SYN_RCVD
状态;(SYN=1,ACK=1,seq=y,y为随机生成的数值,确认号 ack=x+1)
这里ack加1可以理解为时确认和谁建立连接。- 第三次握手:客户端收到后检查确认码ack number是否正确,即和第一次握手发送的序列号加1结果是否相等,以及ACK标识位是否为1;若正确,客户端发送标识位ACK = 1、seq = x + 1和确认码ack = y + 1(服务器发送的seq+1)到服务器,服务器收到后确认ACK=1和seq是否正确,若正确则完成建立连接,此包发送完毕,客户端和服务器进入
ESTAB_LISHED
状态。完成三次握手,客户端与服务器开始传送数据.。(ACK=1,seq=x+1,ack=y+1)
TCP 三次握手的建立连接的过程就是相互确认初始序号的过程。告诉对方,什么样序号的报文段能够被正确接收。
第三次握手的作用是: 客户端对服务器端的初始序列号的确认,如果只使用两次握手,那么服务器就没有办法知道自己的序号是否已被确认。同时这样也是为了防止失效的请求报文被服务器接收,而出现错误的情况。
2. 四次挥手
模拟四次挥手(场景对话版):
主动方:我已经关闭了向你那边的主动通道了,只能被动接收了
被动方:收到通道关闭的信息,我这还有数据没有发送完成,你等下
被动方:那我也告诉你,我这边向你的主动通道也关闭了
主动方:最后收到数据,之后双方无法通信
可通过下方图文结合方式理解四次挥手:
四次挥手原理:
第一次挥手:客户端发送一个FIN,用来关闭客户端到服务器的数据传送,并且指定一个序列号。客户端进入FIN_WAIT_1
状态。
第二次挥手:服务器收到FIN后,发送一个ACK给客户端,确认序号为客户端的序列号值 +1 ,表明已经收到客户端的报文了,此时服务器处于 CLOSE_WAIT
状态。
第三次挥手:服务器发送一个FIN,用来关闭服务器到客户端的数据传送,服务器进入LAST_ACK
状态。
第四次挥手:客户端收到FIN后,客户端进入TIME_WAIT
状态,接着发送一个ACK给服务器,确认序号为收到序号+1 ,服务器收到确认后进入CLOSED
状态,完成四次挥手。
其中:FIN标志位数置1,表示断开TCP连接。
四次挥手过程详细说明:
刚开始双方都处于 ESTABLISHED
状态,假如是客户端先发起关闭请求。
- 第一次挥手:客户端发送一个FIN = 1、初始化序列号seq = u,到服务器,表示需要断开TCP连接,客户端进入
FIN_WAIT_1
状态,等待服务器的确认。(FIN = 1,seq = u,u由客户端随机生成)
- 第二次挥手:服务器收到这个FIN,它发回ACK = 1、seq序列号(由回复端随机生成)、确认序号ack为收到的序号加1(ack = u+1);以便客户端收到信息时,知晓自己的TCP断开请求已经得到验证。服务器进入
CLOSE_WAIT
,等待关闭连接;客户端进入FIN_WAIT_2
,稍后关闭连接。(ACK = 1,seq = v,ack = u+1)
- 第三次挥手:服务器在回复完客户端的TCP断开请求后,不会马上进行TCP连接的断开。服务器会先确保断开前,所有传输到客户端的数据是否已经传输完毕,一旦确认传输完毕,就会发回FIN = 1,ACK = 1,seq = w和确认码ack = u+1给客户端,服务器进入
LAST_ACK
状态,等待最后一次ACK确认;(FIN = 1,ACK = 1,seq = w,ack = u+1 ,w由服务器端随机生成)
- 第四次挥手:客户端收到服务器的TCP断开请求后,会回复服务器的断开请求。包含ACK = 1、随机生成的seq = u+1,并将确认序号设置为收到序号加1(ack = w+1)到服务器,从而完成服务器请求的验证回复。客户端进入
TIME-WAIT
状态,此时 TCP 未释放掉,需要等待2MSL
以确保服务器收到自己的 ACK 报文后进入CLOSE
状态,服务端进入CLOSE
状态。(ACK = 1,seq = u+1,ack = w+1)
注意:为什么 TIME_WAIT 等待的时间是 2MSL?
1)MSL 是 报文最大生存时间,一来一回需要等待 2 倍的时间。
2)最后一次挥手中,客户端会等待一段时间再关闭的原因,是为了防止发送给服务器的确认报文段丢失或者出错,从而导致服务器 端不能正常关闭。
常用关键词总结:
- SYN标志位用来建立TCP连接。如果SYN=1而ACK=0,表明它是一个连接请求;如果SYN=1且ACK=1,则表示同意建立一个连接。
- ACK表示响应,置1时表示确认号(为合法,为0的时候表示数据段不包含确认信息,确认号被忽略。)
- FIN表示关闭连接,置1时表示发端完成发送任务。用来释放连接,表明发送方已经没有数据发送了。
为什么需要四次挥手呢?
- TCP协议 的连接是
全双工
的,即数据传输可以同时在两个方向上进行。所以终止连接时,需要每个方向都单独关闭。(单独一方的连接关闭,只代表不能再向对方发送数据,连接处于的是半关闭状态)- 客户端发送FIN报文终止连接后,
服务器可能还有数据需要发送
(比如上一次的响应),所以服务器会先发送ACK报文确认收到FIN报文,并将未发送的数据发送出去,然后再发送自己的FIN报文终止连接。- 客户端接收到服务器的FIN报文后也需要发送ACK报文确认收到,才能正式关闭连接。
3. 为什么是三次握手?不是两次、四次?
为了确认双方的 接收能力 和 发送能力 都正常。
如果是用两次握手,则会出现下面这种情况:
如客户端发出连接请求,但因连接请求报文丢失而未收到确认,于是客户端再重传一次连接请求。后来收到了确认,建立了连接。数据传输完毕后,就释放了连接,此时客户端共发出了两个连接请求报文段。
其中第一个丢失,第二个到达了服务端,但是第一个丢失的报文段只是在某些网络节点长时间滞留了,延误到连接释放以后的某个时间才到达服务端,此时服务端误以为客户端又发出一次新的连接请求,于是就向客户端发出确认报文段,同意建立连接,不采用三次握手;只要服务端发出确认,就建立新的连接了。此时客户端忽略服务端发来的确认,也不发送数据,则服务端一直等待客户端发送数据,浪费了资源。
4. TCP/IP的分层管理
按层次分为以下四层:应用层
、传输层
、网络层
和数据链路层
。
为什么要分层呢?
分层是有一定好处的。比如,如果互联网是由一个协议统筹,某个地方需要改变设计时,就必须把所有部分整体替换掉。而分层之后只需把变动的层替换掉即可。把各层之间的接口部分规划好之后,每个层次内部的设计就能够自由改动了。
各层的作用:
- 应用层(DNS,HTTP协议)DNS将域名解析成IP地址并发送HTTP请求,OSI 参考模型中最靠近用户的一层。
- 传输层(TCP,UDP) 建立TCP连接(三次握手),客户端和服务端数据传输就是在这层进行的。
- 网络层(IP,ARP地址解析协议)IP寻址及路由选择;所起的作用就是在众多的选项内选择一条传输线路。
- 数据链路层:用来处理连接网络的硬件部分,硬件上的范畴均在链路层的作用范围之内。
其实就是一个概念:从客户端发出HTTP请求到服务器接收,中间会经过一系列的流程。
简括就是:
从应用层 DNS 将域名解析成 IP 地址,并发送 HTTP 请求,到传输层通过三次握手建立tcp/ip连接,再到网络层的ip寻址,再到数据链路层的封装成帧,利用物理介质传输。
当然,服务端的接收就是反过来的步骤。发送端从应用层往下走,接收端则从链路层往上走。
举例,其实分层这部分大致了解下,知道怎么回事就可以啦。
我们用HTTP举例来说明,首先作为发送端的客户端在应用层(HTTP协议)发出某个想看Web页面的HTTP请求。
接着,为了传输方便,在传输层(TCP协议)把应用层处收到的数据(HTTP请求报文)进行分割,并在各个报文上打上标记序号及端口号转发给网络层。
在网络层(IP协议),增加作为通信目的地的MAC地址后转发给链路层。这样一来,发往网络的通信请求就准备齐全了。
接收端的服务器在链路层接收到数据,瞬狙往上层发送,一直到应用层。当传输到应用层,才算真正接收到由客户端发送过来的HTTP请求。
五、浏览器向服务器发送 HTTP 请求
1. HTTP请求报文都有什么组成?
HTTP请求报文主要由三个部分组成:请求行
、请求头
和请求体
。具体如下:
请求行:包含请求方法
、URI(请求的资源路径)
和HTTP协议版本
。例如:GET /index.html HTTP/1.1。
请求头(Header): 包含了客户端向服务器发送的附加信息,例如浏览器类型、字符编码、认证信息等。请求头以键值对
的形式存在,多个键值对之间以换行符分隔。例如:Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7。
请求体(Body): 存放请求参数
,即浏览器向服务器传输数据的实体部分。常用于POST方法提交请求时,发送表单数据、JSON数据等类型的数据。
需要注意的是,并不是所有的HTTP请求都必须带有请求体,像GET请求
通常不需要发送请求体。
为什么 HTTP 报文中要存在 “空行”?
因为 HTTP 协议并没有规定报头部分的键值对有多少个。空行就相当于是 “报头的结束标记”, 或者是 “报头和正文之间的分隔符”。
HTTP 在传输层依赖 TCP 协议, TCP 是面向字节流的. 如果没有这个空行, 就会出现 “粘包问题”
2. 常见状态码含义
区分状态码
1××开头 - 信息性状态码,表示HTTP请求已被接收,需要进一步处理。
2××开头 - 成功状态码,表示请求已成功处理完成。
3××开头 - 重定向状态码,表示请求需要进一步的操作以完成。
4××开头 - 客户端错误状态码,表示请求包含错误或无法完成。
5××开头 - 服务器错误状态码,表示服务器无法完成有效的请求。
常见状态码
200 - 请求成功,从客户端发送给服务器的请求被正常处理并返回
301 - 表示被请求的资源已经被永久移动到新的URI(永久重定向)
302 - 表示被请求的资源已经被临时移动到新的URI(临时重定向)
304 - 表示服务器资源未被修改;通常是在客户端发出了一个条件请求,服务器通过比较资源的修改时间来确定资源是否已被修改
400 - 服务器不理解请求,请求报文中存在语法错误
401 - 请求需要身份验证
403 - 服务器拒绝请求(访问权限出现问题)
404 - 被请求的资源不存在
405 - 不允许的HTTP请求方法,意味着正在使用的HTTP请求方法不被服务器允许
500 - 服务器内部错误,无法完成请求
503 - 服务器当前无法处理请求,一般是因为过载或维护
3. 请求/响应头部
请求和响应头部也是分析时常用到的。
常用的请求头部(部分):
Accept: 接收类型,表示浏览器支持的MIME类型
(对标服务端返回的Content-Type
)
Accept-Encoding:浏览器支持的压缩类型,如gzip
等,超出类型不能接收
Content-Type:客户端发送出去实体内容的类型
Cache-Control: 指定请求和响应遵循的缓存机制,如no-cache
If-Modified-Since:对应服务端的Last-Modified
,用来匹配看文件是否变动,只能精确到1s之内,http1.0
中
Expires:缓存控制,在这个时间内不会请求,直接使用缓存,http1.0,而且是服务端时间
Max-age:代表资源在本地缓存多少秒,有效时间内不会请求,而是使用缓存,http1.1中
If-None-Match:对应服务端的ETag
,用来匹配文件内容是否改变(非常精确),http1.1中
Cookie: 有cookie
并且同域访问时会自动带上
Connection: 当浏览器与服务器通信时对于长连接如何进行处理,如keep-alive
Host:请求的服务器URL
Origin:最初的请求是从哪里发起的(只会精确到端口),Origin
比Referer
更尊重隐私
Referer:该页面的来源URL
(适用于所有类型的请求,会精确到详细页面地址,csrf
拦截常用到这个字段)
User-Agent:用户客户端的一些必要信息,如UA头部等
常用的响应头部(部分):
Access-Control-Allow-Headers: 服务器端允许的请求
Headers
Access-Control-Allow-Methods: 服务器端允许的请求方法
Access-Control-Allow-Origin: 服务器端允许的请求Origin
头部(譬如为*)
Content-Type:服务端返回的实体内容的类型
Date:数据从服务器发送的时间
Cache-Control:告诉浏览器或其他客户,什么环境可以安全的缓存文档
Last-Modified:请求资源的最后修改时间
Expires:应该在什么时候认为文档已经过期,从而不再缓存它
Max-age:客户端的本地资源应该缓存多少秒,开启了Cache-Control
后有效
ETag:请求变量的实体标签的当前值
Set-Cookie:设置和页面关联的cookie
,服务器通过这个头部把cookie
传给客户端
Keep-Alive:如果客户端有keep-alive
,服务端也会有响应(如timeout=38)
Server:服务器的一些相关信息
一般来说,请求头部和响应头部是匹配分析的。
譬如,请求头部的Accept
要和响应头部的Content-Type
匹配,否则会报错。
譬如,跨域请求时,请求头部的Origin
要匹配响应头部的Access-Control-Allow-Origin
,否则会报跨域错误。
譬如,在使用缓存时,请求头部的If-Modified-Since
、If-None-Match
分别和响应头部的Last-Modified
、ETag
对应。
注意点
:
请求头 和 响应头 中的 Content-Type
,是不一样的。
请求头的Content-Type常见取值:
application/x-www-from-urlencoded //以键值对的数据格式提交
multipart/form-data //用于上传文件图片等二进制数据
响应头的Content-Type常见取值:
text/html // body 数据格式是 HTML
text/css // body 数据格式是 CSS
application/javascript // body 数据格式是 JavaScript
application/json //body 数据格式是 JSON (最常见的)
4. 请求/响应体
http 请求 时,除了头部,还有消息实体
,一般来说,
请求实体中会将一些需要的参数都放入(用于post
请求)。
比如实体中可以放参数的序列化形式(a=1&b=2
这种),或者直接放表单对象(Form Data
对象,上传时可以夹杂参数以及文件)等等。
而一般 响应实体中,就是放服务端需要返给客户端的内容。
一般现在的接口请求时,实体中就是信息的json格式,而像页面请求这种,里面直接放了一个html字符串,然后浏览器自己解析并渲染。
如下图所示(post请求发送给接口的数据)
注意点
:
- 不是所有的HTTP请求都必须带有请求体,像
GET请求
通常不需要发送请求体。 - 响应完成之后怎么办?TCP 连接就断开了吗?
不一定。这时候要判断Connection字段, 如果请求头或响应头中包含
Connection: Keep-Alive
,
表示建立了持久连接
,这样TCP连接会一直保持,之后请求统一站点的资源会复用这个连接。否则断开TCP连接, 请求-响应流程结束。
5. cookie以及优化
cookie是浏览器的一种本地存储方式,一般用来帮助 客户端 和 服务端 通信的,常用来进行身份校验,结合服务端的 session 使用。
场景如下(简述):
在登陆页面,用户登陆了
此时,服务端会生成一个session
,session
中有对应用户的信息(如用户名、密码等)
然后会有一个sessionid
(相当于是服务端的这个session对应的key)
然后服务端在登录页面中写入cookie
,值就是: jsessionid=xxx
然后浏览器本地就有这个cookie
了,以后访问同域名
下的页面时,自动带上cookie,自动检验,在有效时间内无需二次登陆。
一般来说,cookie是不允许存放敏感信息的(千万不要明文存储用户名、密码),因为非常不安全,如果一定要强行存储,首先,一定要在cookie中设置httponly
(这样就无法通过js操作了),另外可以考虑RSA等非对称加密(因为实际上,浏览器本地也是容易被攻克的,并不安全)
另外,由于在同域名的资源请求时,浏览器会默认带上本地的cookie,针对这种情况,在某些场景下是需要优化的。
比如以下场景:
客户端在 域名A 下有cookie(这个可以是登录时由服务端写入的)
然后在 域名A 下有一个页面,页面中有很多依赖的静态资源(都是 域名A 的,譬如有20个静态资源)
此时就有一个问题,页面加载,请求这些静态资源时,浏览器会默认带上 cookie
也就是说,这20个静态资源的 http请求,每一个都得带上 cookie,而实际上静态资源并不需要 cookie 验证
此时就造成了较为严重的浪费,而且也降低了访问速度(因为内容更多了)
针对这种场景,是有优化方案的(多域名拆分
)。具体做法就是:
- 将静态资源分组,分别放到不同的域名下(如
static.base.com
) - 而
page.base.com
(页面所在域名)下请求时,是不会带上static.base.com
域名的cookie的,所以就避免了浪费
说到了多域名拆分,这里再提一个问题,那就是:
- 在移动端,如果请求的域名数过多,会降低请求速度(因为域名整套解析流程是很耗费时间的,而且移动端一般带宽都比不上pc)
- 此时就需要用到一种优化方案:
dns-prefetch
(让浏览器空闲时提前解析dns域名,不过也请合理使用,勿滥用)
关于cookie的交互,可以看下图总结
6. HTTP协议各版本的区别
HTTP协议的版本历经多次更新迭代,主要包括 HTTP/1.0
、HTTP/1.1
和HTTP/2
等版本,它们之间的主要区别如下:
1)HTTP/1.0:
- 浏览器与服务器只保持
短连接
,浏览器的每次请求都需要与服务器建立一个TCP连接,都要经过三次握手,四次挥手。而且是串行请求。 - 由于浏览器必须等待响应完成才能发起下一个请求,造成
“队头阻塞”
。
如果某请求一直不到达,那么下一个请求就一直不发送。(高延迟–带来页面加载速度的降低)
2)HTTP/1.1:目前使用最广泛的版本
- 支持
长连接
,通过Connection: keep-alive
保持HTTP连接不断开,避免重复建立TCP连接。 - 支持
管道化传输
,通过长连接实现一个TCP连接中同时处理多个HTTP请求;只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。
服务器会按照请求的顺序去返回响应的内容,无法存在并行响应。(http请求返回顺序按照服务器响应速度来排序,这里也会引入promise.then 和 async await 来控制接口请求顺序) - 新增了一些请求方法,新增了一些请求头和响应头(如下)
- 支持
断点续传
, 新增 Range 和 Content-Range 头表示请求和响应的部分内容 - 加入缓存处理(响应头新字段Expires、Cache-Control)
- 增加了重要的头
Host
字段;为了支持多虚拟主机的场景,使用同一个IP地址上可以托管多个域名,访问的都是同一个服务器,从而满足HTTP协议发展所需要的更高级的特性。 - 并且添加了其他请求方法:put、delete、options…
缺点:
- 队头阻塞
- 无状态通信模型(巨大的HTTP头部),也就是服务器端不保存客户端请求的任何状态信息。这样会造成一些需求频繁交互的应用程序难以实现,需要通过其他机制来保证状态的一致性等。
- 明文传输–不安全
- 不支持服务端推送
3)HTTP/2.0:
- 采用
二进制格式
而非文本格式 多路复用
,在同一个TCP连接上同时传输多条消息;每个请求和响应都被分配了唯一的标识符,称为“流(Stream)”,这样每条信息就可以独立地在网络上传输。- 使用 HPACK 算法
报头压缩
,降低开销。 服务器推送
,支持服务器主动将相关资源预测性地推送给客户端,以减少后续的请求和延迟。(例如 HTML、CSS、JavaScript、图像和视频等文件)
4)HTTP3.0
是 HTTP/3 中的底层支撑协议,该协议基于 UDP,又取了 TCP 中的精华,实现了即快又可靠的协议。
- 运输层由TCP改成使用UDP传输
- 队头堵塞问题的解决更为彻底
- 切换网络时的连接保持:基于TCP的协议,由于切换网络之后,IP会改变,因而之前的连接不可能继续保持。而基于UDP的QUIC协议,则可以内建与TCP中不同的连接标识方法,从而在网络完成切换之后,恢复之前与服务器的连接
- 升级新的压缩算法
注意: HTTP 1.1起支持长连接,keep-alive不会永远保持,它有一个持续时间,一般在服务器中配置(如apache),另外长连接需要客户端和服务器都支持时才有效。
管道传输和多路复用的区别
HTTP/2 的多路复用可以理解为一条公路上同时行驶多辆车的场景,每辆车对应一个请求或响应,而公路对应一个 TCP 连接。
在 HTTP/1.x 中,只能一辆车(请求或响应)通过这条公路,其他车必须等待前面的车通过后再行驶;
而在 HTTP/2 中,则允许多辆车同时在这条公路上行驶,它们之间不会互相干扰或阻塞,从而提高了公路的使用效率和通行能力。
关于HTTP协议这部分感兴趣的可以看 HTTP的前世今生
六、单独拎出来的缓存问题,HTTP缓存策略
浏览器缓存的特点:
- 浏览器每次发起请求,都会
先在浏览器缓存中查找该请求的结果以及缓存标识
- 浏览器每次拿到返回的请求结果都会
将该结果和缓存标识存入浏览器缓存中
根据是否需要向服务器重新发起HTTP请求将缓存过程分为两个部分,分别是强缓存
和协商缓存
。
- 强缓存:使用强缓存策略时,如果缓存资源在
过期时间
内,是的话直接从本地缓存中读取资源,不与服务器进行通信。常见的缓存控制字段有Expires
和Cache-Control
。注意,如果同时启用了Cache-Control与Expires,Cache-Control优先级高。 - 协商缓存:如果强缓存失效后,客户端将向服务器发出请求,进行协商缓存。浏览器携带上一次请求返回的响应头中的 缓存标识 向服务器发起请求(如ETag、Last-Modified等),由服务器判断资源是否更新。如果
资源没有更新
,则返回状态码304
Not Modified,告诉浏览器可以使用本地缓存;否则返回新的资源内容。强缓存优先级高于协商缓存
,但是协商缓存可以更加灵活地控制缓存的有效性。
七、页面渲染流程
注意
:本文的渲染流程是2016年之后chromium源码重构之后的,所以没有了DOM树和CSSOM树并行构建合成渲染树这一说法。
1. 解析HTML,构建DOM树
为什么要构建 DOM 树呢?这是因为浏览器无法直接理解和使用 HTML,所以需要将 HTML 转换为浏览器能够理解的结构——DOM 树。
对于DOM 树的构建过程,可以参考下图:
从图中可以看出,构建 DOM 树的输入内容是一个非常简单的 HTML 文件,然后经由 HTML 解析器
解析,最终输出树状结构的 DOM。
现在我们已经生成 DOM 树了,但是 DOM 节点的样式我们依然不知道,要让 DOM 节点拥有正确的样式,这就需要样式计算了。
2. 样式计算
样式计算的目的是 为了计算出 DOM 节点中每个元素的具体样式,这个阶段大体可分为三步来完成。
1) 把 CSS 转换为浏览器能够理解的结构
和 HTML 文件一样,浏览器也是无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets
。
为了加深理解,可以在 Chrome 控制台中查看其结构,只需要在控制台中输入 document.styleSheets
,然后就看到如下图所示的结构:
2)转换样式表中的属性值,使其标准化
比如下面标准化的转换过程,在这一过程中,很多预设值会变成绝对值,比如red
会变成rgb(255,0,0)
;相对单位会变成绝对单位,比如em
会变成px
。
3)计算出 DOM 树中每个节点的具体样式
这里涉及到 CSS 的继承规则
和层叠规则
了。
- CSS 继承就是每个 DOM 节点都包含有父节点的样式
- 层叠 定义了如何合并来自多个源的属性值的算法。它在 CSS 处于核心地位,CSS 的全称“
层叠样式表
”正是强调了这一点。
可以结合具体例子,看下面这样一张样式表是如何应用到 DOM 节点上的。
body { font-size: 20px }
p {color:blue;}
span {display: none}
div {font-weight: bold;color:red}
div p {color:green;}
从图中可以看出,所有子节点都继承了父节点样式。比如 body 节点的 font-size 属性是 20,那 body 节点下面的所有节点的 font-size 都等于 20。
总之,样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式
,在计算过程中需要遵守 CSS 的继承和层叠
两个规则。这个阶段最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。
3. 布局阶段(会发生回流)
现在,有了 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素
的几何位置
,我们把这个计算过程叫做布局
。
Chrome 在布局阶段需要完成两个任务:创建布局树
和布局计算
。
1)创建布局树
DOM 树还含有很多不可见的元素,比如 head 标签,还有使用了 display:none
属性的元素。所以在显示之前,我们还要额外地构建一棵只包含可见元素布局树
。使用了 display:none
属性的元素不在布局树中显示。
为了构建布局树,浏览器大体上完成了下面这些工作:
- 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中;
- 而不可见的节点会被布局树忽略掉,如 head 标签下面的全部内容,再比如 body.p.span 这个元素,因为它的属性包含 dispaly:none,所以这个元素也没有被包进布局树。又比如使用了
伪元素选择器
,虽然 DOM 树中不存在这些伪元素节点,但它们拥有几何信息,所以会生成到布局树中。
2)布局计算
现在有了一棵完整的布局树。接下来,就是计算布局树节点的坐标位置了。这一阶段浏览器要做的事情是要弄清楚各个节点在页面中的确切 位置和大小
。
在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。针对这个问题,Chrome 团队正在重构布局代码,下一代布局系统叫 LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。
4. 分层
渲染主线程会使用一套复杂的策略对整个布局树中进行分层。浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。
因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树
(LayerTree)。
分层的好处在于,将来某一个层改变后,仅会对该层进行后续处理,从而提升效率。
通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。
通常满足下面两点中任意一点
的元素就可以被提升为单独的一个图层:
- 拥有层叠上下文属性的元素会被提升为单独的一层。(拥有定位,透明,滤镜,z-index属性的元素)
- 需要剪裁(clip)的地方也会被创建为图层。(比如overflow: hidden;)
滚动条也是单独的一层,也可以通过will-change
属性更大程度的影响分层结果。
关于分层我们可以f12查看layers这一项,没有的话,就去浏览器更多工具里打开。
5. 图层绘制(会发生重绘)
在完成图层树的构建之后,渲染引擎会对图层树中的每个图层
进行绘制,然后把一个图层的绘制拆分成很多小的绘制指令
,然后再把这些指令按照顺序组成一个待绘制列表
,如下图所示:
从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表
。
下图采用网上老师的图片展示一下,更容易理解。
6. 栅格化(raster)操作
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程
来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系:
如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表
提交(commit)给合成线程
,那么接下来合成线程的工作就是:
在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口(用户只能看到页面的一部分),用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。
基于这个原因,合成线程会将图层
划分为图块
(tile),这些图块的大小通常是 256x256 或者 512x512。
然后 合成线程会按照视口附近的图块
来优先生成位图
,实际生成位图的操作是由栅格化
来执行的。所谓栅格化,是指将图块转换为位图
。 而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池
内执行的,运行方式如下图所示:
栅格化过程都会使用 GPU
来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存
中。 另外,GPU 操作是运行在 GPU 进程中,这就涉及到了跨进程操作。
7. 合成和显示
栅格化操作完成后,合成线程
会生成一个绘制命令,即"DrawQuad",并发送给浏览器进程。
浏览器进程中的 viz
组件接收到这个命令,根据这个命令,把页面内容绘制到内存,也就是生成了页面,然后把这部分内存发送给显卡。
为什么发给显卡呢?这就需要知道显示器显示图像的原理。
每个显示器都有固定的刷新频率,一般是 60 HZ,即 60 帧,也就是每秒更新 60 张图片,一张图片停留的时间约为 16.7ms。
而每次更新的图片都来自显卡的前缓冲区
。而显卡接收到浏览器进程传来的页面后,会合成相应的图像,并将图像保存到后缓冲区
,然后系统自动将前缓冲区和后缓冲区对换位置,如此循环更新。这样就能保证显示器读取到最新显卡合成的图像。
由此可见,当某个动画大量占用内存的时候,浏览器生成图像的时候会变慢,图像传送给显卡就会不及时,而显示器还是以不变的频率刷新,因此会出现卡顿,也就是明显的掉帧现象。
相关概念
有了上面介绍页面渲染流水线的基础,我们再来看看三个和渲染流水线相关的概念——“回流”“重绘”和“合成”。理解了这三个概念对于你后续 Web 的性能优化会有很大帮助。
(1)回流
从上图可以看出,如果通过 JavaScript 或者 CSS 修改元素的几何位置属性,例如 改变元素的宽度、高度等,那么浏览器会触发重新布局layout
,解析之后的一系列子阶段,这个过程就叫回流
。 无疑,回流需要更新完整的渲染流水线,所以开销也是最大的。
为了避免连续的多次操作导致布局树反复计算,浏览器会合并这些操作,当 JS 代码全部完成后再进行统一计算。所以,改动属性造成的 回流 是异步
完成的。
也同样因为如此,当 JS 获取布局属性时,就可能造成无法获取到最新的布局信息。
浏览器在反复权衡下,最终决定获取属性(比如 dom.clientWidth)立即 回流。
(2)重绘
重绘就是,比如通过 JavaScript 更改某些元素的背景颜色,渲染流水线会怎样调整呢?可以参考下图:
如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段
,然后执行之后的一系列子阶段,这个过程就叫重绘
。 相较于回流操作,重绘省去了布局和分层
阶段,所以执行效率会比重排操作要高一些。
回流重绘总结
- 回流(也叫重排):当 DOM结构发生变化 或者 元素样式 发生改变时,浏览器需要重新计算样式和渲染树,这个过程比较消耗性能。
- 重绘:指元素的外观样式发生变化(比如改变 背景色,边框颜色,文字颜色color等 ),但是布局没有变,此时浏览器只需要应用新样式绘制元素就可以了,比回流消耗的性能小一些。
回流必定会发生重绘,重绘却可以单独出现
。回流的成本开销要高于重绘,而且一个节点的回流往往回导致子节点以及同级节点的回流, 所以优化方案中一般都包括,尽量避免回流。
总之,对元素执行回流操作之后,还有可能引起重绘
或者合成
操作,形象地理解就是“牵一发而动全身
”。
什么情况引起回流?
- 页面的首次渲染
- 浏览器的窗口大小发生变化
- 元素内容发生变化
- 元素的尺寸或位置发生变化
- 元素的字体大小发生变化
- 添加或删除可见的DOM元素
- 激活CSS伪类
- 查询某些属性或者调用某些方法
所以一般会有一些优化方案,如:
- 使用 CSS 动画代替 JavaScript 动画:CSS 动画利用 GPU 加速,在性能方面通常比 JavaScript 动画更高效。使用 CSS 的
transform
和opacity
属性来创建动画,而不是改变元素的布局属性,如宽度、高度等。直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于回流和重排,合成
能大大提升绘制效率。 - 使用 translated3d 开启硬件加速:将元素的位移属性设置为 translated3d( 0,0,0 ),可以强制使用 GPU 加速。有助于避免回流,并提高动画流畅度。
- 避免频繁操作影响布局的样式属性:当需要对元素进行多次样式修改时,可以考虑将这些修改合并为一次操作。通过添加/移除 css类来一次性改变多个样式属性,而不是逐个修改。
- 使用 requestAnimationFrame:通过使用 requestAnimationFrame 方法调度动画帧,可以确保动画在浏览器的重绘周期内执行,从而避免不必要的回流。这种方式可确保动画在最佳时间点进行渲染。
- 使用文档片段(Document Fragment):当需要在 DOM 中插入大量新元素时,可以先将这些元素添加到文档片段中,然后再将整个文档片段一次性插入到 DOM 中。这样可以减少回流和重绘的次数。(vue 虚拟dom的做法)
- 使元素脱离文档流:
position: absolute
/position: fixed
/float:left
(只是减少回流,不是避免回流) - 使用 visibility:hidden 代替 display: none :visibility:hidden不会触发回流,因为元素仍然占据空间,只是不可见。而 display: none 会将元素从渲染树中移除,引起回流。
注意:改变字体大小会引发回流。
(3)直接合成阶段
如果更改一个既不要布局
也不要绘制
的属性,渲染引擎将跳过布局和绘制
,只执行后续的合成
操作,把这个过程叫做合成
。具体流程参考下图:
在上图中,使用了 CSS 的 transform
来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。
渲染流水线大总结:
一个完整的渲染流程大致可总结为如下:
- 渲染进程将 HTML 内容转换为能够读懂的
DOM 树
结构。- 渲染引擎将 CSS 样式表转化为浏览器可以理解的
styleSheets
,计算出 DOM 节点的样式。- 创建
布局树
,并计算元素的布局位置信息。(会发生回流)- 对布局树进行分层,并生成
分层树
。- 为每个图层生成
绘制列表
,并将其提交到合成线程
。(会发生重绘)- 合成线程将图层分成
图块
,并在光栅化线程池
中将图块转换成位图。栅格化过程都会使用GPU
来加速生成,生成的位图被保存在GPU 内存
中。- 合成线程发送绘制图块命令 DrawQuad 给
浏览器进程
。- 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。
额外注意事项
1. 需要注意加载JavaScript 和 css几个事项:
- 如果主线程解析到
link
位置,此时外部的 CSS 文件还没有下载解析好,主线程不会等待,继续解析后续的 HTML。这是因为下载和解析 CSS 的工作是在预解析线程
中进行的。这就是 CSS加载 不会阻塞 DOM树 解析的根本原因。- 但是CSSOM会阻塞渲染,只有当CSSOM构建完毕后才会进入下一个阶段构建渲染树。(这点与浏览器优化有关,防止css规则不断改变,避免了重复的构建),所以为了避免用户看到长时间的白屏,应该尽快提高CSS加载速度。1)使用CDN 2)CSS的压缩(使用webpack、gulp等打包工具 3)合理使用缓存(设置缓存控制、表达式和e-tag)
- 通常情况下DOM和CSSOM是并行构建的,但是当浏览器遇到一个script标签时,DOM构建将暂停,直至JS脚本下载完成并执行后才会继续解析HTML。因为 JavaScript 可以使用诸如 document.write() 更改整个 DOM 结构之类的东西来更改文档的形状,因此 HTML 解析器必须等待 JavaScript 运行才能恢复HTML文档解析。
- 如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件,建议将 script 标签放在 body 标签底部。或者将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上
async
或者defer
。
2. CSS会影响首次加载时的白屏时间
- css加载不会阻塞DOM树的解析。(开启预解析线程)
- css加载会阻塞DOM树的渲染。(生成布局树需要Dom Tree和CSSOM Tree结合)
- css加载会阻塞后面js语句的执行(可对照下图看,因为 JavaScript 有修改 CSSOM 的能力,所以在执行 JavaScript 之前,还需要依赖 CSSOM。)
从流程我们可以看出:
- 当渲染进程接收
HTML
文件字节流时,会先开启一个预解析线程
,如果遇到JavaScript
文件或者CSS
文件,那么预解析线程
会提前下载这些数据。由此可见 DOM解析和 CSS解析 是两个并行的进程,所以CSS加载不会阻塞DOM树的解析。 - 然而,由于Render Tree是依赖于DOM Tree和CSSOM Tree的,所以他必须等待到CSSOM Tree构建完成,也就是CSS资源加载完成(或者CSS资源加载失败)后,才能开始渲染。因此,CSS加载是会阻塞Dom的渲染的。
- 在解析 DOM 的过程中,如果遇到了
JavaScript
脚本,那么需要先暂停DOM
解析去执行JavaScript
,因为 JavaScript 有可能会修改当前状态下的 DOM。 - 在执行 JavaScript 脚本之前,如果页面中包含了外部
CSS
文件的引用,或者通过style
标签内置了 CSS 内容,那么渲染引擎还需要将这些内容转换为CSSOM
,因为 JavaScript 有修改CSSOM
的能力,所以在执行 JavaScript 之前,还需要依赖CSSOM
。也就是说 CSS 在部分情况下也会阻塞 DOM 的生成。CSS会阻塞后面 JavaScript 的执行
优化CSS文件加载:
- 将CSS文件放在
<head>
标签内:将CSS文件的引用放在<head>
标签中,确保浏览器在加载HTML时尽早获取并开始解析CSS。- 压缩和合并CSS文件:将多个CSS文件合并成一个文件,并进行压缩,可以减少网络请求次数和文件大小,加快下载速度。
- 使用媒体查询和条件加载CSS:对于大的 CSS 文件,可以通过媒体查询属性,将其拆分为多个不同用途的 CSS 文件,这样只有在特定的场景下才会加载特定的 CSS 文件。
- 内联关键CSS或异步加载CSS:将关键的CSS样式直接内联到HTML中,以避免额外的网络请求。或者可以使用异步加载CSS的方式,通过JavaScript控制CSS文件的加载时间,使其不会阻塞页面内容的渲染。
总结
本文的目的:梳理出自己的知识体系。
梳理出知识体系后,有了一个大致骨架,由于知识点是环环相扣的,后期也不容易遗忘。以后就算在这方面又学习了新的知识,有了这些基础学起来也会事半功倍些。更重要的是容易举一反三,可以由一个普通问题,深挖拓展到底层原理。
以后再有相关问题,也会继续在这个骨架上填充细节。
参考:
极客时间浏览器原理与实践
从输入URL到页面加载的过程?如何由一道题完善自己的前端知识体系!
超详细讲解页面加载过程
浏览器渲染
前端知识体系整理 - 浏览器页面加载过程