浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

文章目录


前言

写这篇文章的初衷:

记得最开始学前端知识时,是一点一点的积累,一个知识点一个知识点的攻克。
就这样,虽然在很长一段时间内积累了不少的知识,但是,总是无法将它串联到一起。每次梳理时都是很分散的,无法保持思路连贯性。
直到最近,在将DNS域名解析、建立TCP连接、构建HTTP请求、浏览器渲染过程‘’流程梳理一遍后,感觉就跟打通了任督二脉一样,有了一个整体的架构,以前的知识点都连贯起来了,至少现在知道了它的大部分骨架。
梳理出一个知识体系,以后就算再学新的知识,也会尽量往这个体系上靠拢,环环相扣,更容易理解,也更不容易遗忘。这也是本文的目标。

前端领域 的知识为重点,并且本文内容超多,建议先了解主干,然后分批次阅读。
这篇文章真的写了好久好久…


一、梳理主干流程

知识体系中,最重要的是骨架,脉络。有了骨架后,才方便填充细节。所以,先梳理下主干流程:

  1. 浏览器接收url并开启一个新进程(这一部分可以展开浏览器的进程与线程的关系)
  2. 浏览器解析输入的 URL,提取出其中的协议、域名和路径等信息。(这部分涉及URL组成部分)
  3. 浏览器向 DNS 服务器发送请求,DNS服务器通过 多层查询 将该 域名 解析为对应的 IP地址 ,然后将请求发送到该IP地址上,与 服务器 建立连接和交换数据。(这部分涉及DNS查询)
  4. 浏览器与服务器建立 TCP 连接。(这部分涉及TCP三次握手/四次挥手/5层网络协议)
  5. 浏览器向服务器发送 HTTP 请求,包含请求头和请求体。(4,5,6,7包含http头部、响应码、报文结构、cookie等知识)
  6. 服务器接收并处理请求,并返回响应数据,包含状态码、响应头和响应体。
  7. 浏览器接收到响应数据,解析响应头和响应体,并根据状态码判断是否成功。
  8. 如果响应成功,浏览器接收到http数据包后的解析流程(这部分涉及到html – 词法分析,解析成DOM树,解析CSS生成CSSOM树(样式树),合并生成render渲染树(样式计算)。然后layout布局,分层,调用GPU绘制等,最后将绘制的结果合成最终的页面图像,显示在屏幕上。这个过程会发生回流和重绘)。
  9. 连接结束 -> 断开TCP连接 四次挥手

梳理出主干骨架,然后就需要往骨架上填充细节内容。


二、浏览器接收url并开启一个新进程

这部分内容开始之前我们需要先通过一张图对 进程 和 线程 的关系有一个初步的了解。

浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

1. 浏览器是多进程的

浏览器是多进程的,有一个主进程,每打开一个tab页面都会新开一个进程(某些情况下多个tab会合并进程)。

注意:在这里浏览器应该也有自己的优化机制,有时候打开多个tab页后,比如打开多个空白标签页。可以在Chrome任务管理器中看到,进程被合并了。

进程可能包括主进程,插件进程,GPU,tab页(浏览器内核)等等。

  • Browser进程:浏览器的主进程(负责协调、主控),只有一个。
  • 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建。
  • GPU进程:最多一个,用于3D绘制等。
  • 浏览器渲染进程(浏览器内核)(内部是多线程的):默认每个Tab页面一个进程,互不影响。作用是页面渲染,脚本执行,事件处理等。(浏览器有时候会优化,如多个空白页合并成一个进程)

强化记忆:在浏览器中打开一个网页相当于新起了一个进程(进程内有自己的多线程)

下图以 chrome浏览器 为例。我们可以自己通过Chrome的更多工具 =》 任务管理器 自行验证查看,可以看到chrome的任务管理器中有多个进程(分别是每一个Tab页面有一个独立的进程,以及一个主进程)
然后能看到每个进程的内存资源信息以及cpu占有率。

浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

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引擎线程执行。

浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

可以看到,里面的JS引擎是内核进程中的一个线程,这也是为什么常说JS引擎是单线程的。

虽然 JS 是单线程的,但实际上参与工作的线程一共有四个:
浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

后面三个只是协助,只有 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脚本是不能保证加载顺序的。(因为只要该模块加载完成,就执行该模块,不确定模块什么时候能加载完)

关于defer/async用法解释


二、解析URL

输入URL后,会进行解析(URL的本质就是统一资源定位符)

URL一般包括几大部分:

  1. 协议(Protocol):指访问资源时使用的协议,常见的协议有 HTTP、HTTPS、FTP 等。
  2. 主机名(Host):指服务器的域名或 IP 地址,用于唯一标识一个服务器。
  3. 端口号(Port):指服务器上提供服务的端口号,可以省略。例如,默认的 HTTP 端口为 80,HTTPS 端口为 443。
  4. 路径(Path):指服务器上资源的路径,表示访问资源时需要进入的目录层级以及资源的名称。
  5. 查询参数(Query):指对资源请求的参数,格式为 key=value,多个参数间使用 & 连接。
  6. 锚点(Fragment):指 # 后的hash值,一般用来定位到某个位置。

举个例子,www.example.com/index.html?… 表示了一个 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. 域名服务器概念图

浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

从上面这张图可以看到,域名的管理是分层次的。最高级是根,也叫做根服务器。从上往下功能逐渐细化。DNS就是和这些服务器进行打交道。
有了上面的这些概念,现在我们再来认识一下DNS域名解析过程就容易多了。

4. DNS域名解析过程

  1. 首先会在浏览器缓存中查询是否有该域名对应的IP地址,若有则直接返回,解析过程结束。
  2. 如果浏览器缓存中没有该域名对应的IP地址,则向本地DNS服务器发送查询请求。
  3. 如果本地DNS服务器缓存中有该域名对应的IP地址,则直接返回,解析过程结束。
  4. 如果本地DNS服务器缓存中没有该域名对应的IP地址,则向根域名服务器发送查询请求。
  5. 根域名服务器返回一个所查询域的顶级域名服务器地址。
  6. 本地DNS服务器向 顶级域名服务器 发送查询请求。
  7. 顶级域名服务器返回下一级DNS服务器的地址(权威DNS服务器)。
  8. 本地DNS服务器向权威DNS服务器发送查询请求。
  9. 权威DNS服务器返回该域名对应的IP地址,并将结果返回给本地DNS服务器。
  10. 本地DNS服务器将结果保存在缓存中,便于下次使用。并将结果返回给浏览器。
  11. 浏览器将结果保存在缓存中,并使用该IP地址访问对应的网站。

这个过程大体大体由一张图可以表示:从网上找的图片方便理解。
而且,需要知道dns解析是很耗时的,因此如果解析域名过多,会让首屏加载变得过慢,可以考虑dns-prefetch优化

浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)
浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

关于 本地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

可通过下方图文结合方式字理解三次握手:
浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)
三次握手​​​​​​​原理:

第一次握手:客户端发送一个带有 SYN(synchronize同步)标志的数据包给服务端。
第二次握手:服务端接收成功后,回传一个带有 SYN/ACK 标志的数据包传递确认信息,表示我收到了。
第三次握手:客户端再回传一个带有 ACK 标志的数据包,表示我知道了,握手结束。

其中:SYN标志位数置1,表示建立TCP连接;ACK表示响应,置1时表示响应确认。

三次握手过程详细说明:
刚开始客户端处于 Closed 的状态,服务端处于 Listen 状态。

  1. 第一次握手: 客户端发送标识位SYN = 1,随机产生序列号seq = x的数据包到服务端,服务端由SYN = 1知道客户端要建立连接,并进入SYN_SENT状态,等待服务器确认;(SYN=1,seq=x,x为随机生成的数值)
  2. 第二次握手: 服务器收到请求并确认联机信息后,向客户端发送标识位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可以理解为时确认和谁建立连接。
  3. 第三次握手:客户端收到后检查确认码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. 四次挥手

模拟四次挥手(场景对话版):

主动方:我已经关闭了向你那边的主动通道了,只能被动接收了
被动方:收到通道关闭的信息,我这还有数据没有发送完成,你等下
被动方:那我也告诉你,我这边向你的主动通道也关闭了
主动方:最后收到数据,之后双方无法通信

可通过下方图文结合方式理解四次挥手​​​​​​​:
浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

四次挥手​​​​​​​原理:

第一次挥手:客户端发送一个FIN,用来关闭客户端到服务器的数据传送,并且指定一个序列号。客户端进入FIN_WAIT_1状态。
第二次挥手:服务器收到FIN后,发送一个ACK给客户端,确认序号为客户端的序列号值 +1 ,表明已经收到客户端的报文了,此时服务器处于 CLOSE_WAIT 状态。
第三次挥手:服务器发送一个FIN,用来关闭服务器到客户端的数据传送,服务器进入LAST_ACK状态。
第四次挥手:客户端收到FIN后,客户端进入TIME_WAIT状态,接着发送一个ACK给服务器,确认序号为收到序号+1 ,服务器收到确认后进入CLOSED状态,完成四次挥手。

其中:FIN标志位数置1,表示断开TCP连接。

四次挥手过程详细说明:

刚开始双方都处于 ESTABLISHED 状态,假如是客户端先发起关闭请求。

  1. 第一次挥手:客户端发送一个FIN = 1、初始化序列号seq = u,到服务器,表示需要断开TCP连接,客户端进入FIN_WAIT_1状态,等待服务器的确认。(FIN = 1,seq = u,u由客户端随机生成)
  2. 第二次挥手:服务器收到这个FIN,它发回ACK = 1、seq序列号(由回复端随机生成)、确认序号ack为收到的序号加1(ack = u+1);以便客户端收到信息时,知晓自己的TCP断开请求已经得到验证。服务器进入CLOSE_WAIT,等待关闭连接;客户端进入FIN_WAIT_2,稍后关闭连接。(ACK = 1,seq = v,ack = u+1)
  3. 第三次挥手:服务器在回复完客户端的TCP断开请求后,不会马上进行TCP连接的断开。服务器会先确保断开前,所有传输到客户端的数据是否已经传输完毕,一旦确认传输完毕,就会发回FIN = 1,ACK = 1,seq = w和确认码ack = u+1给客户端,服务器进入LAST_ACK 状态,等待最后一次ACK确认;(FIN = 1,ACK = 1,seq = w,ack = u+1 ,w由服务器端随机生成)
  4. 第四次挥手:客户端收到服务器的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时表示发端完成发送任务。用来释放连接,表明发送方已经没有数据发送了。

为什么需要四次挥手呢?

  1. TCP协议 的连接是全双工的,即数据传输可以同时在两个方向上进行。所以终止连接时,需要每个方向都单独关闭。(单独一方的连接关闭,只代表不能再向对方发送数据,连接处于的是半关闭状态)
  2. 客户端发送FIN报文终止连接后,服务器可能还有数据需要发送(比如上一次的响应),所以服务器会先发送ACK报文确认收到FIN报文,并将未发送的数据发送出去,然后再发送自己的FIN报文终止连接。
  3. 客户端接收到服务器的FIN报文后也需要发送ACK报文确认收到,才能正式关闭连接。

3. 为什么是三次握手?不是两次、四次?

为了确认双方的 接收能力发送能力 都正常。

如果是用两次握手,则会出现下面这种情况:
如客户端发出连接请求,但因连接请求报文丢失而未收到确认,于是客户端再重传一次连接请求。后来收到了确认,建立了连接。数据传输完毕后,就释放了连接,此时客户端共发出了两个连接请求报文段。
其中第一个丢失,第二个到达了服务端,但是第一个丢失的报文段只是在某些网络节点长时间滞留了,延误到连接释放以后的某个时间才到达服务端,此时服务端误以为客户端又发出一次新的连接请求,于是就向客户端发出确认报文段,同意建立连接,不采用三次握手;只要服务端发出确认,就建立新的连接了。此时客户端忽略服务端发来的确认,也不发送数据,则服务端一直等待客户端发送数据,浪费了资源。

4. TCP/IP的分层管理

按层次分为以下四层:应用层传输层网络层数据链路层

为什么要分层呢?

分层是有一定好处的。比如,如果互联网是由一个协议统筹,某个地方需要改变设计时,就必须把所有部分整体替换掉。而分层之后只需把变动的层替换掉即可。把各层之间的接口部分规划好之后,每个层次内部的设计就能够自由改动了。

各层的作用

  1. 应用层(DNS,HTTP协议)DNS将域名解析成IP地址并发送HTTP请求,OSI 参考模型中最靠近用户的一层。
  2. 传输层(TCP,UDP) 建立TCP连接(三次握手),客户端和服务端数据传输就是在这层进行的。
  3. 网络层(IP,ARP地址解析协议)IP寻址及路由选择;所起的作用就是在众多的选项内选择一条传输线路。
  4. 数据链路层:用来处理连接网络的硬件部分,硬件上的范畴均在链路层的作用范围之内。

其实就是一个概念:从客户端发出HTTP请求到服务器接收,中间会经过一系列的流程。

简括就是:

从应用层 DNS 将域名解析成 IP 地址,并发送 HTTP 请求,到传输层通过三次握手建立tcp/ip连接,再到网络层的ip寻址,再到数据链路层的封装成帧,利用物理介质传输。

当然,服务端的接收就是反过来的步骤。发送端从应用层往下走,接收端则从链路层往上走
浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

举例,其实分层这部分大致了解下,知道怎么回事就可以啦。

我们用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请求通常不需要发送请求体。

浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

为什么 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:最初的请求是从哪里发起的(只会精确到端口),OriginReferer更尊重隐私
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-SinceIf-None-Match分别和响应头部的Last-ModifiedETag对应。

注意点

请求头 和 响应头 中的 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请求发送给接口的数据)

浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

注意点

  1. 不是所有的HTTP请求都必须带有请求体,像GET请求通常不需要发送请求体。
  2. 响应完成之后怎么办?TCP 连接就断开了吗?

不一定。这时候要判断Connection字段, 如果请求头或响应头中包含Connection: Keep-Alive
表示建立了持久连接,这样TCP连接会一直保持,之后请求统一站点的资源会复用这个连接。否则断开TCP连接, 请求-响应流程结束。

5. cookie以及优化

cookie是浏览器的一种本地存储方式,一般用来帮助 客户端服务端 通信的,常用来进行身份校验,结合服务端的 session 使用。

场景如下(简述):

在登陆页面,用户登陆了
此时,服务端会生成一个sessionsession中有对应用户的信息(如用户名、密码等)
然后会有一个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的交互,可以看下图总结
浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

6. HTTP协议各版本的区别

HTTP协议的版本历经多次更新迭代,主要包括 HTTP/1.0HTTP/1.1HTTP/2等版本,它们之间的主要区别如下:

1)HTTP/1.0:

  1. 浏览器与服务器只保持短连接,浏览器的每次请求都需要与服务器建立一个TCP连接,都要经过三次握手,四次挥手。而且是串行请求。
  2. 由于浏览器必须等待响应完成才能发起下一个请求,造成 “队头阻塞”
    如果某请求一直不到达,那么下一个请求就一直不发送。(高延迟–带来页面加载速度的降低)

2)HTTP/1.1:目前使用最广泛的版本

  1. 支持长连接,通过Connection: keep-alive保持HTTP连接不断开,避免重复建立TCP连接。
  2. 支持 管道化传输,通过长连接实现一个TCP连接中同时处理多个HTTP请求;只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。
    服务器会按照请求的顺序去返回响应的内容,无法存在并行响应。(http请求返回顺序按照服务器响应速度来排序,这里也会引入promise.then 和 async await 来控制接口请求顺序)
  3. 新增了一些请求方法,新增了一些请求头和响应头(如下)
  4. 支持断点续传, 新增 Range 和 Content-Range 头表示请求和响应的部分内容
  5. 加入缓存处理(响应头新字段Expires、Cache-Control)
  6. 增加了重要的头 Host 字段;为了支持多虚拟主机的场景,使用同一个IP地址上可以托管多个域名,访问的都是同一个服务器,从而满足HTTP协议发展所需要的更高级的特性。
  7. 并且添加了其他请求方法:put、delete、options…

缺点:

  1. 队头阻塞
  2. 无状态通信模型(巨大的HTTP头部),也就是服务器端不保存客户端请求的任何状态信息。这样会造成一些需求频繁交互的应用程序难以实现,需要通过其他机制来保证状态的一致性等。
  3. 明文传输–不安全
  4. 不支持服务端推送

3)HTTP/2.0:

  1. 采用二进制格式而非文本格式
  2. 多路复用,在同一个TCP连接上同时传输多条消息;每个请求和响应都被分配了唯一的标识符,称为“流(Stream)”,这样每条信息就可以独立地在网络上传输。
  3. 使用 HPACK 算法报头压缩,降低开销。
  4. 服务器推送,支持服务器主动将相关资源预测性地推送给客户端,以减少后续的请求和延迟。(例如 HTML、CSS、JavaScript、图像和视频等文件)

4)HTTP3.0

是 HTTP/3 中的底层支撑协议,该协议基于 UDP,又取了 TCP 中的精华,实现了即快又可靠的协议。

  1. 运输层由TCP改成使用UDP传输
  2. 队头堵塞问题的解决更为彻底
  3. 切换网络时的连接保持:基于TCP的协议,由于切换网络之后,IP会改变,因而之前的连接不可能继续保持。而基于UDP的QUIC协议,则可以内建与TCP中不同的连接标识方法,从而在网络完成切换之后,恢复之前与服务器的连接
  4. 升级新的压缩算法

注意: HTTP 1.1起支持长连接,keep-alive不会永远保持,它有一个持续时间,一般在服务器中配置(如apache),另外长连接需要客户端和服务器都支持时才有效。

管道传输和多路复用的区别

HTTP/2 的多路复用可以理解为一条公路上同时行驶多辆车的场景,每辆车对应一个请求或响应,而公路对应一个 TCP 连接。
在 HTTP/1.x 中,只能一辆车(请求或响应)通过这条公路,其他车必须等待前面的车通过后再行驶;
而在 HTTP/2 中,则允许多辆车同时在这条公路上行驶,它们之间不会互相干扰或阻塞,从而提高了公路的使用效率和通行能力。

关于HTTP协议这部分感兴趣的可以看 HTTP的前世今生


六、单独拎出来的缓存问题,HTTP缓存策略

浏览器缓存的特点:

  • 浏览器每次发起请求,都会先在浏览器缓存中查找该请求的结果以及缓存标识
  • 浏览器每次拿到返回的请求结果都会将该结果和缓存标识存入浏览器缓存中

根据是否需要向服务器重新发起HTTP请求将缓存过程分为两个部分,分别是强缓存协商缓存

  1. 强缓存:使用强缓存策略时,如果缓存资源在过期时间内,是的话直接从本地缓存中读取资源,不与服务器进行通信。常见的缓存控制字段有ExpiresCache-Control。注意,如果同时启用了Cache-Control与Expires,Cache-Control优先级高。
  2. 协商缓存:如果强缓存失效后,客户端将向服务器发出请求,进行协商缓存。浏览器携带上一次请求返回的响应头中的 缓存标识 向服务器发起请求(如ETag、Last-Modified等),由服务器判断资源是否更新。如果资源没有更新,则返回状态码 304 Not Modified,告诉浏览器可以使用本地缓存;否则返回新的资源内容。强缓存优先级高于协商缓存,但是协商缓存可以更加灵活地控制缓存的有效性。

七、页面渲染流程

1. 流程简述

浏览器内核拿到内容后,渲染步骤大致可以分为以下几步:

1)解析HTML: 解析 HTML 并构建 DOM 树。
2)解析CSS: 解析 CSS 构建 CSSOM 树(样式树)。
3)合成渲染树:将 DOMCSSOM 合并成一个 渲染树(Render Tree) 。
4)布局计算:根据渲染树的结构,计算每个节点在屏幕上的大小位置等属性,生成布局信息(Layout)。这个过程会发生回流和重绘。
5)绘制页面:将生成的布局信息交给浏览器的绘图引擎,通过 GPU 加速将像素绘制(Paint)到屏幕上。
6)浏览器回流和重绘:如果页面发生改变,浏览器需要重新计算布局和绘制,这可能会导致性能问题。因此我们应尽量避免频繁的 DOM 操作和调整元素样式,以减少不必要的回流和重绘。

2. 解析HTML,构建DOM树

解析过程中遇到 CSS 解析 CSS,遇到 JS 执行 JS。为了提高解析效率,浏览器在开始解析前,会启动一个预解析的线程,率先下载 HTML 中的外部 CSS 文件和 外部的 JS 文件。
如果主线程解析到link位置,此时外部的 CSS 文件还没有下载解析好,主线程不会等待,继续解析后续的 HTML。这是因为下载和解析 CSS 的工作是在预解析线程中进行的。这就是 CSS 不会阻塞 HTML 解析的根本原因。
如果主线程解析到script位置,会停止解析 HTML,转而等待 JS 文件下载好,并将全局代码解析执行完成后,才能继续解析 HTML。这是因为 JS 代码的执行过程可能会修改当前的 DOM 树,所以 DOM 树的生成必须暂停。这就是 JS 会阻塞 HTML 解析的根本原因。

浏览器会遵守一套步骤将 HTML 文件转换为 DOM 树。宏观上,可以分为几个步骤:
浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)
浏览器从磁盘或网络读取HTML的原始字节,并根据文件的指定编码(例如UTF-8)将它们转换成字符串。

在网络中传输的内容其实都是0和1这些字节数据。当浏览器接收到这些字节数据以后,它会将这些数据转换为字符串,就是我们的代码。

比如假设有这样一个HTML页面:(以下部分的内容出自参考来源,修改了下格式)

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Critical Path</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
  </body>
</html>

浏览器的处理过程如下:(以下图片出自参考来源)

浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

注意点:

  1. 将字符串转换成Token,例如:<html><head><body>等。Token中会标识出当前Token是 “开始标签” 或是 “结束标签” 亦或是 “文本” 等信息。
  2. 构建DOM的过程中,不是等所有Token都转换完成后再去生成节点对象,而是一边生成Token,一边消耗Token来生成节点对象。换句话说,每个Token被生成后,会立刻消耗这个Token创建出节点对象。注意:带有结束标签标识的Token不会再去创建节点对象。

3. 解析CSS,构建CSSOM树

构建 CSSOM 树的过程与 构建DOM 的过程非常相似,当浏览器接收到一段CSS,浏览器首先要做的是识别出Token,然后 构建节点 并生成 CSSOM。简述为:

浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

这一过程中,CSS匹配HTML元素是一个相当复杂和有性能问题的事情,浏览器得递归CSSOM树,确定每一个节点的样式到底是什么,所以DOM树要小,CSS尽量用id和class,千万不要过渡层叠下去。

4. 合成渲染树(样式计算)

当我们生成 DOM 树和 CSSOM 树以后,就需要将这两棵树组合为 渲染树。
(以下图片出自参考来源)

浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)
在这一过程中,不是简单的将两者合并就行了。渲染树只会包括需要显示的节点和这些节点的样式信息,如果某个节点是display: none的,那么就不会在渲染树中显示

主线程会遍历得到的 DOM 树,依次为树中的每个节点计算出它最终的样式,称之为 Computed Style。
在这一过程中,很多预设值会变成绝对值,比如red会变成rgb(255,0,0);相对单位会变成绝对单位,比如em会变成px
这一步完成后,会得到一棵带有样式的 DOM 树。

5. 布局

布局完成后会生成布局树。
当浏览器生成渲染树以后,就会根据渲染树来进行布局(也可以叫做 回流 )。这一阶段浏览器要做的事情是要弄清楚各个节点在页面中的确切 位置和大小。通常这一行为也被称为“自动重排”。

布局流程的输出是一个“盒模型”,它会精确地捕获每个元素在视口内的确切位置和尺寸,所有相对测量值都将转换为屏幕上的绝对像素。

布局阶段会依次遍历 DOM 树的每一个节点,计算每个节点的几何信息。例如节点的宽高、相对包含块的位置。
大部分时候,DOM 树和布局树并非一一对应
比如 display:none 的节点没有几何信息,因此不会生成到布局树;又比如使用了 伪元素选择器,虽然 DOM 树中不存在这些伪元素节点,但它们拥有几何信息,所以会生成到布局树中。还有匿名行盒、匿名块盒等等都会导致 DOM 树和布局树无法一一对应。

下图采用网上老师的图片展示一下,更容易理解。
浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

6. 分层

主线程会使用一套复杂的策略对整个布局树中进行分层。
分层的好处在于,将来某一个层改变后,仅会对该层进行后续处理,从而提升效率
滚动条、堆叠上下文、transform、opacity 等样式都会或多或少的影响分层结果,也可以通过will-change属性更大程度的影响分层结果。

关于分层我们可以f12查看layers这一项,没有的话,就去浏览器更多工具里打开。
浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

7. 绘制

主线程会为每个层单独产生绘制指令集,用于描述这一层的内容该如何画出来。
完成绘制后,主线程将每个图层的绘制信息提交给合成线程,剩余工作将由合成线程完成。
合成线程首先对每个图层进行分块,将其划分为更多的小区域。
它会从线程池中拿取多个线程来完成分块工作。

下图采用网上老师的图片展示一下,更容易理解。
浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)
浏览器从输入URL到页面渲染加载的过程(浏览器知识体系整理)

6. 浏览器回流和重绘

(1)回流
回流 的本质就是重新计算 layout 树
当进行了会影响布局树的操作后,需要重新计算布局树,会引发 layout。
为了避免连续的多次操作导致布局树反复计算,浏览器会合并这些操作,当 JS 代码全部完成后再进行统一计算。所以,改动属性造成的 回流 是异步完成的。

也同样因为如此,当 JS 获取布局属性时,就可能造成无法获取到最新的布局信息。
浏览器在反复权衡下,最终决定获取属性(比如 dom.clientWidth)立即 回流。

(2)重绘
重绘 的本质就是重新根据分层信息计算了绘制指令。
当改动了可见样式后,就需要重新计算,会引发 重绘。
由于元素的布局信息也属于可见样式,所以 回流 一定会引起 重绘。

(3)最后总结

  • 回流(也叫重排):当 DOM结构发生变化 或者 元素样式 发生改变时,浏览器需要重新计算样式和渲染树,这个过程比较消耗性能。
  • 重绘:指元素的外观样式发生变化(比如改变 背景色,边框颜色,文字颜色color等 ),但是布局没有变,此时浏览器只需要应用新样式绘制元素就可以了,比回流消耗的性能小一些。

回流必定会发生重绘,重绘却可以单独出现 。回流的成本开销要高于重绘,而且一个节点的回流往往回导致子节点以及同级节点的回流, 所以优化方案中一般都包括,尽量避免回流。

(4)什么情况引起回流?

  1. 页面的首次渲染
  2. 浏览器的窗口大小发生变化
  3. 元素内容发生变化
  4. 元素的尺寸或位置发生变化
  5. 元素的字体大小发生变化
  6. 添加或删除可见的DOM元素
  7. 激活CSS伪类
  8. 查询某些属性或者调用某些方法

所以一般会有一些优化方案,如:

  • 使用 CSS 动画代替 JavaScript 动画:CSS 动画利用 GPU 加速,在性能方面通常比 JavaScript 动画更高效。使用 CSS 的transformopacity 属性来创建动画,而不是改变元素的布局属性,如宽度、高度等。
  • 使用 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 会将元素从渲染树中移除,引起回流。

注意:改变字体大小会引发回流。

浏览器渲染小结:
整个过程如下:

DOM TREE(DOMContentLoaded事件触发) => 「执行JS」没完成会阻止接下来的渲染 => CSSOM TREE => RENDER TREE渲染树「浏览器未来是按照这个树来绘制页面的」=> Layout布局计算「回流/重排」=> Painting绘制「重绘」{ 分层绘制 }

需要注意几个事项:

1. CSSOM会阻塞渲染,只有当CSSOM构建完毕后才会进入下一个阶段构建渲染树。(这点与浏览器优化有关,防止css规则不断改变,避免了重复的构建)
2. 通常情况下DOM和CSSOM是并行构建的,但是当浏览器遇到一个script标签时,DOM构建将暂停,直至JS脚本下载完成并执行后才会继续解析HTML。因为 JavaScript 可以使用诸如 document.write() 更改整个 DOM 结构之类的东西来更改文档的形状,因此 HTML 解析器必须等待 JavaScript 运行才能恢复HTML文档解析。
3. 如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件,建议将 script 标签放在 body 标签底部。
4. 如果主线程解析到link位置,此时外部的 CSS 文件还没有下载解析好,主线程不会等待,继续解析后续的 HTML。这是因为下载和解析 CSS 的工作是在预解析线程中进行的。这就是 CSS 不会阻塞 HTML 解析的根本原因。


总结

本文的目的:梳理出自己的知识体系。

梳理出知识体系后,有了一个大致骨架,由于知识点是环环相扣的,后期也不容易遗忘。以后就算在这方面又学习了新的知识,有了这些基础学起来也会事半功倍些。更重要的是容易举一反三,可以由一个普通问题,深挖拓展到底层原理。

以后再有相关问题,也会继续在这个骨架上填充细节。

可参考:
从输入URL到页面加载的过程?如何由一道题完善自己的前端知识体系!
超详细讲解页面加载过程
浏览器渲染
前端知识体系整理 – 浏览器页面加载过程

原文链接:https://juejin.cn/post/7316775422187061300 作者:铁锤妹妹i

(0)
上一篇 2023年12月28日 下午4:40
下一篇 2023年12月28日 下午4:51

相关推荐

发表回复

登录后才能评论