合成 URL
我在浏览器中输入了一个东西,有两种情况:
- 我想要用搜索引擎搜索些什么,我输入的是关键词
- 我知道我想要访问那个网站,我输入的是网站的具体网址
浏览器会判断我们的输入属于哪一种方式,最终合成一个可访问的完备的 URL 地址。
关键词
将浏览器的默认搜索引擎与输入的关键词拼接起来,并对其中非标准的字符进行 百分号编码。
例如用百度搜索“快乐”得到的 URL 是:
https://www.baidu.com/baidu?tn=monline_4_dg&ie=utf-8&wd=%E5%BF%AB%E4%B9%90
这是一个完备的 URL 地址,有协议(https
)、域名(www.baidu.com
)以及查询参数。
查询参数中的非标准 URL 字符串已经被编码成 %XX
形式,X
代表一个十六进制数。
具体网址
如果我输入的内容符合 URL 的规则(看起来就像 URL,至少得有个域名),浏览器会自动补全剩余的部分并标准化 URL 字符串,例如加上协议头和非标准的 URL 字符,例如输入:
souyisou.com?q=快乐
最终合成的标准 URL 就是:
https://souyisou.com?q=%E5%BF%AB%E4%B9%90
百分号编码
URL 的标准字符集只是 ASCII 字符集的一个子集,其余字符都需要进行百分号编码。
这种转码一般发生在查询参数中,当查询参数的值中出现了像 ?=&
等在 URL 中有特殊含义的字符时,它们都需要被转码。
转码的规则来自 UTF-8 编码,由于每个汉字的 UTF-8 编码通常都是三字节,因此一个汉字转码后通常对应三个百分号编码。
JavaScript 提供了两个 API 做这个事情:
encodeURI
编码整个 URLencodeURIComponent
编码参数部分,要求更加严格
检查缓存
浏览器合成一个有效的 URL 后是不是就开始向外发送请求了呢?并没有,在发送请求前还需要检查一下自己有没有缓存数据,如果一个万年不变的网站,每次访问都要重新请求那也太浪费流量和时间了。
浏览器会在发请求前检查缓存资源,首先看有没有,然后看过没过期。
在有效期内的缓存可以直接使用,这时浏览器就不会发请求了,而是直接从缓存中拿东西,这叫做 强缓存。不同的浏览器显示的内容可能不太一样,例如 Chrome 使用强缓存时状态码仍然是 200,但是 size 会变成 memory cache
或者 disk cache
,分别表示从内存或者磁盘中拿出缓存。资源被缓存在内存还是磁盘中,是由浏览器自己分配的,比如内存不够了,就只能放到磁盘里了咯。数据可能在内存和磁盘里都有缓存,一般来说,不关闭标签页直接刷新,从内存中取;如果关闭了标签页,内存被释放掉了,再重新打开,就只能从磁盘里读了。
过期了的缓存,浏览器不能直接使用,需要先咨询一下服务器我们能不能使用过期了的缓存资源,结果无外乎两种:
- 可以使用过期缓存,响应状态码为 304,服务器不会发送额外数据
- 不能使用过期缓存,响应状态码为 200,服务器会附带新的数据,此时与正常请求-响应一致
DNS域名解析
如果本地没有缓存或者缓存过期了,就需要向服务器请求新的数据,或者询问服务器能否继续使用过期的缓存。在发起请求时,首先要做的就是定位服务器在互联网上的位置,即 DNS 解析。
人只能记住域名,记不住那么一长串的 IP 地址(除了 127.0.0.1),然而计算机只认识数字(还是二进制的),不认识 abcd。域名系统(Domain Name System, DNS)就负责将 域名 转化为 IP地址 供计算机使用。
域名查找大致可以分为两步,先查找本地缓存,找到了就 OK 了。如果没找到,就向外部 DNS 服务器发出查询请求。
本地缓存
本地缓存也分了很多层,按照离浏览器的距离可以分为:
- 浏览器的缓存:最优先查找
- 本地 hosts 文件
- 本地 DNS 解析器的缓存
- 本地 DNS 服务器
从上到下,由近到远依次查找,一旦找到,就返回结果,这是一个 递归 过程。
当然实际场景中的缓存层级可能更多,例如:
- 路由器的缓存
- 互联网服务提供商(Internet Service Provider, IPS)的缓存
迭代查询
如果以上缓存中均找到相关域名,那就只好走标准流程了,比如下面的 URL:
https://mail.163.com?user=barwe
首先本地 DNS 服务器请求 根域名服务器,根域名服务器看到 .com
结尾告诉我应该去找 .com
顶级域名服务器,然后本地 DNS 服务器又去请求 .com
顶级域名服务器,.com
顶级域名服务器看到 163
告诉我应该去找 163
权威域名服务器,然后我又去找 163
权威域名服务器,163
权威域名服务器接收到了我的请求,看到了 mail
,于是它明白了应该给我提供邮件服务。
图片来源: https://juejin.cn/post/6871947938701475847
这个查询过程是一个 迭代 过程:本地 DNS 服务器依次请求根域名服务器、顶级域名服务器和权威域名服务器等,最终确定提供该服务的服务器位置。
建立 TCP 连接
当前架构下,WEB 应用的协议头只有两种:
http
: 普通的协议,需要 TCP 三次握手https
: HTTP + SSL\TLS,需要 TCP 三次握手和 SSL 握手
HTTP 是明文传输,HTTPS 在 HTTP 的基础上进行了数据加密,传输的是加密后的数据。
传输层与应用层
TCP/IP 是 传输层协议/网际协议,HTTP 是 应用层协议。
IP 就像连接每户人家的公路,它标识除了每户人家在地理上的位置。
TCP 和 UDP 就像跑在公路上的卡车,负责运输货物(数据),但是如果我们将东西一股脑的塞进卡车里,而不对货物进行标记,这些货物就是无意义的,因为我不知道该将什么东西送到谁手里。
所以 TCP/IP 解决的是数据怎么在网络中传输,但是不能识别数据内容。
HTTP 属于应用层协议,它解决的就是 TCP/IP 传输的数据的识别问题,比如 HTTP/HTTPS 负责将数据分发给 WEB 服务器,FTP 表示将数据分发给文件传输服务器,SMTP 表示将数据分发给邮件服务器。
如果说卡车运输的是快递,应用层的协议就相当于快递上的标签,标识了哪些快递送到哪里。
详细信息参考 OSI 分层模型。
TCP 三次握手
不管是 HTTP 还是 HTTPS 都需要先建立客户端和服务器之间连接,建立连接需要三次握手:
- 客户端告诉服务器,我想与你建立连接,自己进行连接请求已发送状态(SYN_SEND):SYN = 1, ACK = 0, seq = x
- 服务器收到客户端的连接请求,分配连接资源,并告诉客户端我已转备好与你建立连接,服务器进入已接受连接请求状态(SYN_RECV):SYN = 1, ACK = 1, ack = x + 1, seq = y
- 客户端收到服务器的返回消息,得知服务器已经准备好连接之后,自己进入连接状态(ESTABLISHED),并再次告诉服务器我已转备好连接了:SYN = 0, ACK = 1, ack = y + 1, seq = x + 1
客户端开始愉快的请求了~
上面涉及到的 TCP 数据报头的标志位主要有:
SYN
:1 表示该数据包是一个 连接请求 或者 响应连接请求 的数据包,所以只在前两次握手中值为 1ACK
:1 表示需要确认数据包的序号(ack 确认序列),所以只在后两次握手中值为 1seq
&ack
:第一次握手时客户端随机生成一个 seq 序号,它表示该数据包的起始编号,客户端将seq = x
的连接请求数据报发送非服务器,服务器返回的响应数据报必须要设置确认序号ack = x + 1
才能被客户端正确接受,同时服务器也会生成自己第一个数据包的序号seq = y
。客户端收到响应数据报后再次发送的数据包需要设置确认序号ack = y + 1
才能被服务器正确接受,同时设置自己的数据包序号为seq = x + 1
说的再简单点:
- 客户端发送 建立连接 的请求
- 服务器返回对建立连接请求的确认,并未该连接分配资源
- 客户端发送对服务器确认消息的确认,并未该连接分配资源
☺ 为什么需要三次握手?
如果客户端发送了一个失效的建立连接的请求,服务器收到请求后分配资源,然后将确认消息返回给客户端,如果此时客户端已经关闭了,服务器就会一直等待客户端的请求,所以需要客户端再次发送一个关于确认的确认消息,如果服务器收到了这个客户端的确认,那么连接就成功了,如果没有收到,就说明客户端已经放弃了连接,服务器就可以释放自己的资源。
SSL 握手
SSL/TLS 是一个安全通信框架,上面可以承载 HTTP 协议或者 SMPT/POP3 协议等。
TLS (Transport Layer Security) 是在 SSL 3.0 (Secure Socket Layer) 的基础上设计的改进版本。
TLS 握手设计到三个步骤:
- 非对称加密:身份认证和秘钥协商
- 对称加密:用协商的秘钥对数据进行加密
- 散列算法:验证信息的完整性
非对称加密的特点是秘钥成对出现,一般称为 公钥 和 私钥,公钥加密的信息只能通过私钥解密,私钥加密的信息只能通过公钥解密,例如 RSA 算法。
TLS 握手的第一步,就是客户端和服务器通过非对称加密协商出一个 对称加密的秘钥。因为非对称加密成本高、耗时长,因此在正式的数据传输阶段都使用对称加密,这个秘钥只有参与协商的客户端和服务器知道。
握手过程分为五步,可参考阮一峰图解SSL/TLS协议。
- 客户端发送一个自己生成的随机数(client random)给服务器
- 服务器收到 client random,将自己的 公钥(证书)和另一个随机数(server random)返回给客户端
- 客户端收到服务器的证书(公钥),然后又生成一个新的随机数(premaster secret),然后使用服务器发过来的公钥对这个随机数进行加密,然后将加密后的结果发送给服务器
- 服务器接收到客户端发来的经过自己公钥加密后的随机数,使用自己的私钥进行解密
- 客户端和服务器根据上面四步传输的三个随机数生成 对称秘钥(session key),用来加密接下来的整个对话过程
在上面的过程中,client random 和 server random 都是明文传输的,它们都可以被第三方接收到。
而 premaster secret 是通过服务器的公钥吉加密过的,第三方虽然可以截获服务器的公钥,但是却不知道这个加密结果对应的原文是什么(唯一获取的原文的方式可能就是暴力破解了)。因此这个 premaster secret 理论上来说只有参与协商的客户端和服务器知道,所以这第三个随机数能否被破译是整个 TLS 安全机制的关键点。
通过这种非对称加密的方式(服务器将公钥提供给客户端进行加密,然后自己对加密结果进行解密),协商的客户端和服务器能够获得只有它两知道的 对称秘钥,利用对称秘钥解码接下来的会话,极大节省使用非对称加密进行通话的开销。
发送请求 & 接收响应
在 TCP 连接(HTTPS 还需要进行 TSL 确认)完成后,客户端就能愉快地向服务器发起请求了。
客户端使用 HTTP 协议(80 端口)或者 HTTPS 协议(443 端口)发送请求数据报给服务器。
请求
一个 HTTP 请求报文的例子:
该报文大概由五部分构成:
- 请求方法:最最常用的就是 GET 和 POST 了
- 请求 URL:和请求头中的
Host
属性一起组成了完整的 URL - HTTP 协议和版本
- 请求头:一些关于请求和客户端的额外信息
Accept
: 告诉服务器我期望接收到什么类型的数据(MIME 类型)Cookie
Referer
: 请求是从哪个页面发起的,常用来设置防盗Cache-Control
: 告诉服务器你返回的数据我客户端会怎么去缓存- ……
- 请求体:请求的参数部分
客户端将一股脑儿信息塞到请求数据报里面,然后发送给服务器。
服务器收到请求,根据缓存客户端的缓存需求确定是否需要协商缓存。
协商缓存可参考:前端缓存之 http 缓存
响应
服务器可在响应头中设置缓存相关的参数。
服务器返回响应之后,一次浏览器和服务器之间友好的交流就结束啦。
关闭 TCP 连接
当浏览器与服务器会话结束后,需要关闭 TCP 连接。
在建立连接时,需要客户端主动发起建立请求;而断开连接时,客户端和服务器都可以主动断开。
以客户端主动断开连接为例,整个过程被叫做 四次挥手。
浏览器渲染
浏览器通过缓存或者请求拿到了需要的数据,然后就是将数据渲染到页面上。
构建 DOM 树
浏览器请求到的数据都是 字节流,需要先转化为 DOM 树,具体步骤是:
- 解码:通过指定的编码方式(例如 UTF-8)将请求到的字节流数据解码成 html 字符串
- 能解析出带
charset
属性的meta
标签,边接收边解码 - 找不到
charset
的meta
标签,接收完了使用默认的编码方式解码
- 能解析出带
- 分词(token):根据一些特殊的字符例如
</>=
等从 html 字符串中提取出标签和属性列表 - 创建 & 添加节点:在分词步骤中,每解析出一个 token 就新建一个节点,并将节点加入到 DOM 树中
经过解码、分词、创建和添加节点,最终得到了一颗 DOM 树,这棵树包含了各个节点间的父子兄弟关系。
样式计算
css 样式需要应用到节点上才有意义,一个节点的样式也会受到它的所有祖先节点样式的影响。
css 样式来源主要有三个:
- 通过
.css
文件从外部引入的,常通过class
属性与元素关联起来 - 在头部的
style
标签中定义的样式 - 在元素
style
属性中定义的样式
计算样式主要做这几个事情:
- 属性值的标准化:例如将 em, rem, % 等等数值转化为 px 标准值,颜色代码转化等等
- 处理样式的继承和层叠
样式计算最终产生样式表,可通过 document.styleSheets
查看。
页面布局
经过前面两步,我们计算出了所有节点的 DOM 树和 CSS 样式表。
然后就要根据标签类型或者样式信息对 DOM 的节点过滤,计算可见节点的位置和样式,最终生成一颗只包含可见元素的树。
剪枝:
- 去掉
script
,meta
等非视觉化的标签 - 去掉
display: none
的标签 - ……
这个过程就是将 DOM 树与样式表结合,构造布局树的过程。
页面更新时会发生 回流(reflow)和 重排(repaint)。
生成分层树
为了更方便的实现某些页面效果,例如滚动、z-index等,浏览器会为特殊的节点生成专用的图层。
渲染引擎会划分出多个图层,这些图层的叠加就构成了最终的视觉页面。
并不是布局树上的每一个节点都会生成一个图层,一个没有对应图层的节点属于它的父节点的图层。
渲染引擎在生成图层时有它自己的一套规则,这里略。
栅格化
现在分层树也建好了,渲染线程的任务就算完成了。
哪些图层、哪些节点需要展示给用户就需要 合成线程 进行调度。
栅格化负责将图层进行叠加组合,然后分块,并将视口区域附近的分块展示给用户。
如果页面很大,浏览器不需要一下绘制出所有页面,因为视口区域实际上非常有限,视口区域之外的内容绘制那么多也没啥实际作用。
显式
合成线程发送绘图指令给浏览器主进程,浏览器按照绘图指令绘图展示给用户。
评论区