# 👉 从输入 URL 到展示页面的过程经历了些什么?
一道非常经典的面试题,可以用简单几句话就把大概流程说完,但每一个流程展开却可以详细得像一本书。所以借着这道题,把这个过程多加理解的同时,好好梳理一下自己的前端知识点。刚开始总结可能比较片面,多为参考整合网上资料,但会根据整个体系学习总结,慢慢加入自身理解补充得更加完整,有不足和错误之处,感谢提出!🙏
首先,非常感谢 👇 以下文章带来的启发和指导:
# 大致流程
- 浏览器接收 URL 到开启网络请求线程
从进程与线程之间的关系、浏览器多进程架构、URL 的解析展开 - 开启网络线程后,发出一个完整的 HTTP 请求:
从 DNS 查询、五层网络协议、TCP/IP 连接展开 - 服务器接收到请求,对应后台接收到请求
从负载均衡、安全拦截、后台的处理展开 - 后端和前端的 HTTP 交互
从 HTTP 报文结构、HTTP 的发展、HTTP 与 HTTPS 的关系、Cookie 等知识点展开 - HTTP 的缓存问题
从 HTTP 缓存头部、缓存标识优先机制等展开 - 浏览器接收到 HTTP 数据包后,对页面进行解析和渲染
从解析 html 词法分析然后解析成 DOM 树、解析 CSS 生成 CSS 规则树、合并布局树,复合图层的合成、GPU 绘制、外链资源的处理展开。 - CSS 的可视化格式模型
从包含块、控制框( Controlling Box )、 BFC 、 IFC 、定位体系、浮动层次等展开 - JS 引擎解析过程
从 JS 的解释阶段,预处理阶段,执行阶段生成执行上下文, VO ,作用域链、回收机制展开 - 过程中还涉及的其它问题
涉及跨域,web 安全等等内容
# 1. 从浏览器接收到 URL 开启网络请求线程
这部分内容从进程与线程之间的关系、浏览器多进程架构、浏览器内核、以及 URL 的解析展开。
# 1.1 进程和线程之间的关系
说到进程和线程的关系,看一次忘记一次,直到看到了阮大大一篇文章的类比,才理清了各自的关系,在这里结合个人理解梳理一下知识点。
类比例子:
单个 cpu 只可运行一个进程 --- cpu 就像一座工厂,由于电力有限,一个车间(单个进程)在工作时,其他车间非运行状态
不同进程相互独立 --- 车间相互独立
多个线程在进程中协作完成任务 ---- 一个车间有多个工人(多个线程),协同完成任务
每个进程都拥有自己的内存和资源,同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等) --- 车间多个房间,工人之间可共享空间
根据例子可以了解到:
进程是操作系统分配资源的最小单位,线程是程序执行的最小单位,即:
进程是 cpu 资源分配的最小单位(是能拥有资源和独立运行的最小单位)
线程是 cpu 调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)
# 1.2 浏览器多进程架构
跟多线程浏览器(一个进程处理多个页面,一个页面崩溃可能会造成其他页面一起崩溃)不一样,Chrome 浏览器使用了多个进程来隔离不同的网页。因此在 Chrome 中打开一个网页相当于起了一个进程,因为进程是互相独立的,一个页面崩了并不会导致别的页面进程崩溃,这样子的做法可以大大提高了浏览器进程的稳定性。
另外相对于线程,进程之间是不共享资源和地址空间的,所以不会存在太多的安全问题。
打开 Chrome 浏览器的任务管理器:
可以看出:
Chrome 是多进程的,每新建一个 Tab 页,相当于创建了一个新的独立进程。(但这个说法也并非绝对,因为浏览器有自己的优化机制,同时打开多个空白 Tab 页时进程会被合并。)
浏览器进程主要包括 Browser 主进程,第三方插件进程, GPU 进程 ,浏览器渲染进程(内核)。
(1) Browser 主进程:浏览器的主进程(负责协调、主控),只有一个。负责包括地址栏和前进后退按钮等部分的工作、负责处理浏览器的一些不可见的底层操作,比如网络请求和文件访问。
(2) 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建。
(3) GPU 进程:最多一个,用于 3D 绘制。
(4) 浏览器渲染进程(内核):默认每个 Tab 页面一个进程,互不影响,控制页面渲染,脚本执行,事件处理等(有时候会进行优化,如多个空白 tab 会合并成一个进程。同时,Chrome 也限制了最多的进程数,最大进程数量由设备的内存和 CPU 能力决定,当达到这一限制时,新打开的 Tab 会共用之前同一个站点的渲染进程。)
# 1.3 浏览器内核
从上面可知,Chrome 浏览器为每个 tab 页面单独启用一个进程,因此每个 tab 页都有由其独立的渲染引擎实例,而这个渲染引擎又包括了常见的子线程: JS 引擎线程、GUI 渲染线程、事件触发线程、定时触发线程、网络请求线程。
GUI 渲染线程
(1) 负责渲染浏览器界面,解析 HTML ,CSS ,构建 DOM 树和 Render 树,布局和绘制等。当界面需要重绘( repaint )或引发回流( reflow )时,该线程就会执行。(2) GUI 渲染线程与 JS 引擎线程是互斥的。
定时触发线程
(1) 定时触发线程是 setInterval 与 setTimeout 所在线程。(2) 浏览器定时计数器并不是由 JavaScript 引擎进行计数的,因为 JavaScript 引擎是单线程的,处于阻塞状态时会影响记计时的准确,因此通过单独的定时器线程来计时并触发定时。(计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行。用这两个函数的时候,一般实际的执行时间是大于或等于指定时间的,不保证能准确定时的)
(3)注意,W3C 在 HTML 标准中规定,规定要求 setTimeout 中低于 4 ms 的时间间隔算为 4 ms。
事件触发线程
(1) 事件触发线程用于控制事件循环。事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件。(2) 当 JS 引擎执行异步任务代码块时(可来自浏览器内核的其他线程,如鼠标点击、AJAX 异步请求等),会将对应任务添加到事件线程中。当对应事件符合触发条件被触发时,该线程会把事件添加到待处理任务队列的队尾,等待 JS 引擎空闲时进行处理。
网络请求线程
(1) 在 XMLHttpRequest 在连接后是通过浏览器新开一个线程进行请求。(2) 检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到处理队列中等待处理。
JS 引擎线程
(1) JS 引擎线程也称为 JS 内核,主要负责解析和执行 JavaScript 脚本程序,例如 V8 引擎。(2) JS 引擎一直等待着任务队列中任务的到来,然后加以处理。一个 Tab 页中无论什么时候都只有一个 JS 线程在运行 JS 程序。
(3) JS 引擎线程与 GUI 渲染线程是互斥的。
由于 JavaScript 是可操作 DOM 的,如果在修改 DOM 属性同时渲染界面(即 JS 引擎线程和 GUI 线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。因此,为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JS 引擎线程为互斥的关系。当 JS 引擎执行时 GUI 线程会被挂起,GUI 更新会被保存在一个队列中等到 JS 引擎执行完成后立即被执行。如果 JS 执行的时间过长,将会造成页面的渲染不连贯,导致页面渲染加载阻塞。(4) JS 引擎是单线程的,为了提高一些计算密集型或高延迟任务的执行效率,避免主线程被严重阻塞,浏览器给 JS 引擎开了外挂。 HTML5 中支持了 Web Worker (为 JavaScript 创造多线程环境):
创建 Worker 时, JS 引擎向浏览器申请开一个子线程(子线程是浏览器开的,完全受主线程控制,而且不能操作 DOM )。
JS 引擎线程与 worker 线程间通过特定的方式通信(postMessage API,需要通过序列化对象来与线程交互特定的数据)。
详细的 Web Worker 知识暂不进行展开,可以通过阮大大文章进一步了解 👇: http://www.ruanyifeng.com/blog/2018/07/web-worker.html
# 1.4 浏览器内核与 Browser 主进程
接下来,为了让知识点串起来,对知识点有个大概的整体认识,将对浏览器内核与 Browser 主进程如何通信进行讲解。先看个简化的步骤:
(1) Browser 进程收到用户请求,先获取网页内容(如通过网络下载),后将该任务通过 RendererHost 接口传递给浏览器渲染进程(注意,是进程);
(2) 浏览器渲染进程的 Renderer 接口收到消息,进行解释,后交给渲染线程,然后开始渲染网页;
(3) 渲染线程(注意,是线程)接收请求,加载网页并渲染网页,这其中可能需要浏览器渲染进程分配资源和需要 GPU 进程来帮助渲染,也可能会有 JS 线程操作 DOM (这样可能会造成回流并重绘)了;
(4) 最后浏览器渲染进程将结果传递给 Browser 进程,Browser 进程接收到结果并将结果绘制出来。
# 1.5 解析 URL
当用户在浏览器地址栏输入 URL 后,将会对其进行解析。
既然说到了 URL,这里也大概整理一下 URI 、 URL 、 URN 的关系:
(1) URI 分为三种,URL or URN or ( URL and URN ),如下图:URI = Universal Resource Identifier(统一资源标志符)
URL = Universal Resource Locator(统一资源定位符)
URN = Universal Resource Name(统一资源名称)(2) URI 可以唯一标识一个资源, URL 可以提供找到该资源的路径。“ URN 作用就好像一个人的名字, URL 就像一个人的地址。换句话说:URN 确定了东西的身份,URL 提供了找到它的方式”。
URL 一般包括几大部分:
(1)protocol
: 协议头,譬如有 http,ftp 等
(2)host
: 主机域名或 IP 地址( http 的默认主机是 www )
(3)port
: 端口号( http 的默认端口号是 80 )
(4)path
: 目录路径(如果省略,则文档必须位于网站的根目录中)
(5)query
: 即查询参数
(6)fragments
: 即 # 后的 hash 值,一般用来定位到某个位置例子如图:
# 为什么需要编码解析?
网络标准规定了 URL,"只有字母和数字[0-9a-zA-Z]
、一些特殊符号$-_.+!*'(),
[不包括双引号]、以及某些保留字,才可以不经过编码直接用于 URL。"其他字符需要进行解析编码。不转义的话可能会引起歧义以及有可能会导致 xss 攻击。
# 编码解析规则
- 网址路径的编码,用的是 utf-8 编码;
- 查询字符串的编码,用的是操作系统的默认编码;
- GET 和 POST 方法的编码,用的是网页的编码;
"春"和"节"的 utf-8 编码分别是"E6 98 A5"和"E8 8A 82",因此,"%E6%98%A5%E8%8A%82"就是按照顺序,在每个字节前加上%而得到的。
# encodeURIComponent 和 encodeURI 有什么区别?
encodeURIComponent 方法不会对下列字符编码 ASCII 字母 数字 ~!*()'
encodeURIComponent 编码范围更广,不会对
ASCII字母
、数字
~!*()
进行编码,适合给参数编码encodeURI 不会对
ASCII字母
、数字
、~!@#$&*()=:/,;?+'
进行编码,适合给 URL(locaion.origin)编码
encodeURI("http://www.cnblogs.com/season-huang/some other thing");
// "http://www.cnblogs.com/season-huang/some%20other%20thing";,编码完后依然可以访问
encodeURIComponent("http://www.cnblogs.com/season-huang/some other thing");
// "http%3A%2F%2Fwww.cnblogs.com%2Fseason-huang%2Fsome%20other%20thing", 会变成一个不能访问的网址
# 2. 开启网络线程到发出一个完整的 HTTP 请求
这部分的内容从 DNS 查询、TCP/IP 连接、五层因特网协议栈展开。
# 2.1 DNS 查询得到 IP
# 为什么需要 DNS 解析域名为 IP 地址
网络通讯大部分是基于 TCP/IP 的,而 TCP/IP 是基于 IP 地址的,所以计算机在网络上进行通讯时只能识别如 “202.96.134.133” 之类的 IP 地址,而不能认识域名。而我们一般无法记住多个网站 IP 地址,所以我们访问网站时,更多的是通过输入域名访问所需页面。这是因为有一个叫“ DNS 服务器”自动把我们的域名“翻译”成了相应的 IP 地址,然后调出 IP 地址所对应的网页(DNS 是基于 UDP 做的查询)。
# DNS 域名系统的结构
# DNS 查询 IP 的过程
DNS 域名解析分为递归查询和迭代查询两种方式。
(1) 浏览器缓存:
浏览器会按照一定的频率缓存 DNS 记录,当输入域名地址后,首先去找浏览器缓存,如果浏览器有缓存,直接使用浏览器缓存。
(2) 操作系统缓存:
如果浏览器缓存中找不到需要的 DNS 记录(专业点称为没命中),浏览器就会去检查操作系统缓存中有没有对应的已解析过的结果。而操作系统也有一个域名解析的过程。通过 hosts 的文件来设置,如果你在这里指定了一个域名对应的 ip 地址,那浏览器会首先使用这个 ip 地址,但修改 hosts 的情况一般比较少。
(3) 路由器缓存:
如果系统缓存中也找不到,那么查询请求就会发向路由器,它一般会有自己的 DNS 缓存。
(4) ISP 的 DNS 缓存:
运气实在不好,就只能查询 ISP 的 DNS 缓存服务器了。ISP 是互联网服务提供商( Internet Service Provider )的简称。在网络配置中都会有" DNS 服务器地址"这一项,操作系统会把这个域名发送给这里设置的 DNS,也就是本地区的域名服务器,通常是提供给你接入互联网的应用提供商。这个专门的域名解析服务器性能都会很好,它们一般都会缓存域名解析结果,当然缓存时间是受域名的失效时间控制的,一般缓存空间不是影响域名失效的主要因素。大约 80% 的域名解析都到这里就已经完成了,所以 ISP DNS 主要承担了域名的解析工作。
(5) 迭代查询:
最无奈的情况发生了, 在前面都没有办法命中的 DNS 缓存的情况下:
- 本地 DNS 服务器,将该请求转发到互联网上的根 DNS 服务器(即一个完整域名最后面的那个点,通常省略不写)。
- 根 DNS 服务器将所要查询域名中的顶级 DNS 服务器(假设要查询 ke.qq.com ,该域名的顶级域就是 com )的服务器 IP 地址返回到本地 DNS 。
- 本地 DNS 根据返回的 IP 地址,再向顶级 DNS 服务器(就是 com 域)发送请求。
- com 域服务器再将域名中的二级域(即 ke.qq.com 中的 qq )的 IP 地址返回给本地 DNS 。 - 本地 DNS 再向二级域发送请求进行查询。
- 之后不断重复这样的过程,直到本地 DNS 服务器得到最终的查询结果,本地域名服务器把返回的结果保存到缓存,以备下一次使用,同时将该结果反馈给主机。这时候主机才能通过这个 IP 地址与 web 服务器建立链接。
# DNS 有关的网络性能优化
DNS 是有成本的,它需要 20 到 120 毫秒去查找给定主机名的 IP 地址。在 DNS 查找完成之前,浏览器无法从主机名下载任何东西。 DNS 相关的网络性能优化主要从两方面展开:减少 DNS 请求数量、缩短 DNS 请求时间。
(1) 减少 DNS 查找
可通过浏览器 DNS 缓存、计算机 DNS 缓存、服务器 DNS 缓存、使用 Keep-alive 特性来减少 DNS 查找。
考虑影响 DNS 缓存的因素:
- 服务器可以设置 TTL 值表示 DNS 记录的存活时间。本机 DNS 缓存将根据这个 TTL 值判断 DNS 记录什么时候被抛弃,这个 TTL 值一般都不会设置很大,主要是考虑到快速故障转移的问题。
- 浏览器 DNS 缓存也有自己的过期时间,这个时间是独立于本机 DNS 缓存的,相对也比较短,例如 chrome 只有 1 分钟左右。
- 操作系统的 DNS 缓存也有很大机率保留着该记录,这样可以避免通过网络查询而带来的延迟。
(2) DNS Prefetch 预加载,缩短 DNS 请求时间
可以通过用 meta 信息来告知浏览器, 我这页面要做 DNS 预解析:
<meta http-equiv="x-dns-prefetch-control" content="on" />
可以使用 link 标签来强制对 DNS 做预解析:
<link rel="dns-prefetch" href="http://ke.qq.com/" />
同时也可以通过控制好页面组件中不同的主机名来避免 DNS 查找削减了响应时间:
如果客户端的 DNS 缓存是空的(包括浏览器的和操作系统的),DNS 查找数等于页面上不同的主机名数,包括页面 URL ,图片,脚本文件,样式表, Flash 对象等等组件中的主机名,减少不同的主机名就可以减少 DNS 查找。
# 2.2 五层网络协议模型和 OSI 七层协议模型
在介绍客户端如何向所获取到的 IP 地址的服务端发起连接之前,先简单介绍一下五层网络协议模型和OSI 七层协议模型。
# 五层网络协议模型
(1) 应用层( DNS 、 HTTP )
为特定应用程序提供数据传输服务,通过应用进程间的交互来完成特定网络应用。数据单位为报文。
(2) 传输层( TCP、 UDP )
为进程提供通用数据传输服务。由于应用层协议很多,定义通用的传输层协议就可以支持不断增多的应用层协议。“通用的”是指并不针对某一个特定的网络应用,而是多种应用可以使用同一个运输层服务。传输层主要使用以下两种协议:
- 传输控制协议 TCP :提供面向连接、可靠的数据传输服务,提供全双工通信,数据单位为报文段;
- 用户数据报协议 UDP :提供无连接、尽最大努力的数据传输服务,即不保证可靠交付,支持一对一、一对多、多对一和多对多的交互通信,数据单位为用户数据报。
其中, TCP 主要提供完整性服务, UDP 主要提供及时性服务。建立 TCP 连接(三次握手)也是在此阶段发生。
(3) 网络层( IP 、 ARP )
为主机提供数据传输服务。而传输层协议是为主机中的进程提供数据传输服务。网络层把传输层传递下来的报文段或者用户数据报 UDP 封装成分组。在 TCP/IP 体系结构中,由于网络层使用 IP 协议,因此分组也叫 IP 数据报,简称数据报。
(4) 数据链路层( PPP )
网络层针对的还是主机之间的数据传输服务,而主机之间可以有很多链路,链路层协议就是为同一链路的主机提供数据传输服务。数据链路层把网络层传下来的分组封装成帧。
(5) 物理层(利用物理介质传输比特流)
实现相邻计算机节点之间比特流的透明传送,尽可能屏蔽掉具体传输介质和物理设备的差异,使数据链路层感觉不到这些差异。
# OSI 七层协议模型
与五层网络协议相比,多了会话层、表示层,其中表示层和会话层用途如下:
(1) 表示层
主要处理两个通信系统中交换信息的表示方式,包括数据格式交换,数据加密与解密,数据压缩与终端类型转换等,这使得应用程序不必关心在各台主机中数据内部格式不同的问题。
(2) 会话层
它具体管理和维持不同用户和进程之间的对话,如控制登陆和注销过程。
# 2.3 TCP/IP 连接
终于拿到服务器 IP 了,下一步自然就是连接到对应的服务器。为了准确无误地把数据送达目标处, TCP 协议采用了三次握手策略,下面必然要说的就是三次握手以及断开连接时的四次挥手。
# 三次握手
三次握手是指建立一个 TCP 连接时,需要客户端和服务端总共发送 3 个包,以此确认双方的发送能力和接收能力是否正常,并且为之后的可靠性传输作准备。
三次握手过程
第一次握手:
客户端 client 发送一个位码为SYN=1
的 TCP 报文给服务端 server ,并指明自己的初始化序号 ISN
(ISN,Initial Sequence Number,也称序列号 SEQ)来请求建立连接。此时 client 处于SYN-SENT
状态。第二次握手:
server 收到 client 发来的报文,需要将接收到的序列号ISN
+ 1 作为 ACK 确认应答返送回去,表示已收到客户端的 SYN 报文;同时也会指定自己的初始化序号 ISN
来请求建立连接。此时 server 处于SYN-RECEIVED
状态。第三次握手:
client 收到 server 传回的报文,也会将服务端的序列号ISN
+ 1 作为确认应答(ACK 值)返回,表示已收到 server 的 SYN 报文,此时 client 处于ESTABLISHED
状态。server 收到ACK报文
之后,也处于ESTABLISHED
状态。此时,双方已建立起了连接。注意: SYN 报文段不能携带数据,但要消耗掉一个序号。ACK 报文段可以携带数据,不携带数据则不消耗序号。
为什么要进行三次握手
TCP 协议是不限制一个特定的连接(两端 socket 一样)被重复使用的。因此就有一个问题:如果这条连接突然断开重连后,TCP 怎么样识别之前旧链接重发的包?这时候就需要独一无二的 ISN (初始序列号)机制。
引用知乎对 TCP 传递的一个类比:
TCP 传递信息可以理解为美国与中国用货船来传货物,但因为一艘轮船穿放不下,货物要分开一只只轮船来发货。所以需要一个
序列号
来识别该货物是第几个,以便到达后将其拼接回原来的货物。因为同一条航道(也就是 TCP 连接)上,可能会有多批货物发送(复用 TCP 连接)。发货时,双方都需要通知对方这个序列号是从哪里开始(ISN,Initial Sequence Number)的,这样才能辨识过来的是不是一个对的货物,以及能否拼接成完整的货物。
货物运输拼接( TCP )最重要的是可靠性,如果没有用三次握手来确认双方都可以获得对方的序列号( SEQ )的话,就无法知道当前连接中,对的货物序号是怎么样的了。
理解图例(引用了一个复用连接时,旧在途包发往新连接中的例子):
TCP A TCP B 1. CLOSED LISTEN 2. SYN-SENT --> <SEQ=100><CTL=SYN> ... 3. (duplicate) ... <SEQ=90><CTL=SYN> --> SYN-RECEIVED 4. SYN-SENT <-- <SEQ=300><ACK=91><CTL=SYN,ACK> <-- SYN-RECEIVED 5. SYN-SENT --> <SEQ=91><CTL=RST> --> LISTEN 6. ... <SEQ=100><CTL=SYN> --> SYN-RECEIVED 7. SYN-SENT <-- <SEQ=400><ACK=101><CTL=SYN,ACK> <-- SYN-RECEIVED 8. ESTABLISHED --> <SEQ=101><ACK=401><CTL=ACK> --> ESTABLISHED 9. ESTABLISHED --> <SEQ=101><ACK=401><CTL=ACK><DATA> --> ESTABLISHED Recovery from Old Duplicate SYN
2 中, A 发送了 SEQ = 100,标志位是 SYN 。 客户端进入 SYN-SEND 状态,等待服务器的确认;
3 中,一个旧的重复的 SYN 延迟到达 B ;
4 中,B 分别不出是否旧的,照样子正常回包,B 发回了 ACK = 91 与 SEQ = 300 ,标志位是 SYN 与 ACK ;
5 中,A 检测到 B 返回的 ACK 不正确,所以返回 RST ( reset ) ,B 接收到 RST ( reset ) 信号,于是变成 LISTEN 状态;
6 中,新连接正常的 SYN 终于到达了,B 变成了 SYN - RECEIVED 状态;(为新连接正式的第一次握手)
7 中,B 发回了 ACK = 101 与 SEQ = 400,标志位是 SYN 与 ACK (确认序号和请求连接的两个过程合并了)。ACK = 101 意味着,B 希望接收到 101 序列号开始的数据段;(新连接的第二次握手)
8 中,A 返回了空的数据,SEQ=101,ACK=401,标志位为 ACK。双方的开始 SEQ(也就是 ISN)分别是 100 与 400 都被对方确认接收到了;(新连接的第三次握手,三次握手正常进行)
9 中,开始正式发送数据包, ACK 依旧是第 8 行的 401 。
看到这里,大概明白了,为什么要进行三次握手了吧?
表因:(1)二次握手只能确定 客服端的发送能力和接受能力 以及 服务端的接受能力 是正常的,
(2)为了防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误(服务端误等待以为客户端发起的新连接,造成连接资源的浪费)。实质:TCP 需要
SEQ 序列号
来做可靠重传或接收,避免连接复用时无法分辨出(传来的包) SEQ 是延迟或是旧链接的 SEQ ,因此需要三次握手来约定确定双方的 ISN (初始 SEQ 序列号)。因为没有网络全局时钟,只有都确认序号了才能证明这个包是否新的,还是在链路中延迟送达的。
# 四次挥手
数据传输完毕后,双方都可释放连接。最开始的时候,客户端和服务器都是处于 ESTABLISHED 状态,任何一方都可以在数据传送结束后发出连接释放的通知,待对方确认后进入半关闭状态(停止发送信息,但仍可接收信息,等待对方也请求释放连接)。当另一方也没有数据再发送的时候,则发出连接释放通知,对方确认后就完全关闭了 TCP 连接。
(1) 第一次挥手
client 发送一个 FIN
报文,同时会指定一个序列号 SEQ
= X,并停止再发送数据。此时 client 进入 FIN_WAIT_1
状态,等待 server 的确认。
(2) 第二次挥手
server 收到了 client 发来的 FIN
报文,会向 client 发回一个 ACK
报文,且把来自 client 的序列号 SEQ X + 1 作为 ACK 值,表示已经收到 client 的报文和同意关闭请求。此时 server 进入 CLOSE_WAIT
状态, client 进入 FIN_WAIT_2
状态,等待 server 发送连接释放报文,server 依然能够继续传送数据给 client。
(3) 第三次挥手
server 将最后的数据发送完毕后,就会像 client 一样发送 FIN
报文给 client 请求关闭连接,指定一个序列号 SEQ
= Y。此时 server 进入 LAST_ACK
状态,等待 client 的确认。
(4) 第四次挥手
client 收到 server 发来的 FIN
报文后,会向 server 发回 ACK
报文,把来自 server 的序列号 SEQ Y + 1 作为 ACK 值,并进入 TIME_WAIT
状态。 server 收到 client 的 ACK 包以后,进入 CLOSE
状态。
此时 TCP 未释放掉,需要经过时间等待计时器设置的时间 2MSL后,判断 server 已正式关闭,client 才会进入正常的 CLOSE
状态。
四次挥手的原因
之所以中间的两个动作没有合并,是因为 tcp 存在半关闭状态,也就是单向关闭。
关闭连接时,收到对方的 FIN 报文时,仅仅表示对方不再发送数据了但是还能接收数据,而自己也未必全部数据都发送给对方了,所以可允许对方立即关闭,也可以发送一些数据给对方后,才发送 FIN 报文给对方来表示同意现在关闭连接,因此,对方 ACK 和 FIN 一般都会分开发送,从而导致多了一次。
# 为什么需要 TIME_WAIT 状态
上面有一个非常特殊的状态 TIME_WAIT ,它是主动关闭的一方在回复完对方的挥手( FIN 报文)后进入的一个长期状态。
这个状态标准的持续时间是 4 分钟,4 分钟后才会进入到 CLOSED 状态,释放套接字资源。4 分钟是 2 个 MSL ,每个 MSL 是 2 分钟,不过在具体实现上这个时间是可以调整的。
MSL 就是 maximium segment lifetime ——报文最大生存时间。它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。
这么做有两个原因:
原因一:保证连接正确关闭
TIME-WAIT 的作用是等待足够的时间以确保最后的 ACK 报文能让被动关闭方接收,从而帮助其正常关闭。
如果被动关闭方没接收到最后一个 ACK 报文,将会重新发送连接释放请求 FIN 报文,而这个过程将会消耗 2MSL。原因二:防止已经关闭的旧连接的残余数据包干扰正常的数据包,造成数据流的不正常。
为了让本次连接中两个方向上产生的所有数据包都被丢弃,使得下一个新的连接不会出现旧的连接请求报文,再出现的数据包一定都是新建立连接所产生的。
# 3. 从服务器接收到请求到对应后台接收到请求
这部分内容将从负载均衡、安全拦截、后台的处理展开进行简单的介绍。
# 3.1 负载均衡
负载均衡是高可用网络基础架构的关键组件,通常用于将工作负载分布到多个服务器来提高网站、应用、数据库或其他服务的性能和可靠性。避免发生一个 Web 服务器宕机导致网站无法访问、多用户在同一时间内访问服务器,超过了服务器的处理能力而出现了响应速度慢甚至无法连接等问题。
用户发起的请求都指向调度服务器(反向代理服务器,譬如安装了 nginx 控制负载均衡),然后调度服务器根据实际的调度算法,分配不同的请求给对应集群中的服务器执行,然后调度器等待实际服务器的 HTTP 响应,并将它反馈给用户。
详细的负载均衡请戳:这里 🙋 (opens new window)
# 3.2 后台的处理
后台处理一般步骤如下:
(1) 一般后端会先进行统一的验证的,如安全拦截,跨域验证;
(2) 如果这一步不符合规则,就直接返回了相应的 http 报文(如拒绝请求等);
(3) 然后当验证通过后,才会进入实际的后台代码,此时是程序接收到请求,然后执行(譬如查询数据库,大量计算等等);
(4) 等程序执行完毕后,会返回一个 http 响应包(一般这一步也会经过多层封装);
(5) 然后就是将这个包从后端发送到前端,完成交互。
# 4. 后端和前端的 HTTP 交互
从 HTTP 报文结构、 HTTP1.*/HTTP2.0 、 HTTP 与 HTTPS 的关系、 Cookie 等知识点展开。
# 4.1 HTTP 存在的位置
HTTP 是在建立了数据连接以后,专门在两点之间传输文字、图片、音频、视频等超文本数据的约定和规范。HTTP 处于 OSI 模型中的在应用层。
HTTP 通常跑在 TCP/IP 协议栈之上,TCP 协议实现可靠数据传输、依靠 IP 协议实现寻址和路由、DNS 协议实现域名查找、SSL/TLS 协议实现安全通信。
应用层还有什么协议:DNS 域名解析、FTP 文件传输协议、SMTP 邮件发送协议。
# 4.2 HTTP 报文结构
HTTP 报文结构和 TCP 类似,主要是 header + body
的结构,具体就是:起始行 + 头部 + 空行 + 实体
。
其中,HTTP 请求/响应报文结构也有些区别,下面就分开进行介绍。报文一般包括了:通用头部,请求/响应头部,请求/响应实体。
# 起始行
- 对于请求报文,起始行的结构:
方法 + 路径 + 版本
POST /home HTTP/1.1
- 对于响应报文,起始行(也称状态行)的结构:
版本 + 状态码 + 状态描述
HTTP/1.1 200 OK
起始行中,每两个部分之间用空格隔开,最后一个部分后面接一个换行。
# 请求 / 响应头部
- 通用头部
除了请求头部和响应头部以外,在 F12 的网络请求工具中,有一个部分经常用到的通用头部。常见的通用头部有以下几个:
Request URL
:请求接口地址Request Method
:请求方法Status Code
: 响应状态码Remote Address
:请求的远程服务器地址(会转为 IP:端口 )Referrer Policy
:过滤请求头 Referrer 字段内容,referrerpolicy 的值有以下的类型:
1)origin:发送只包含 origin 的 referer,即协议域名
2)no-referer:不发送
3)unsafe-referer:都发送
4)same-origin:对同源链接/引用时发送 referer,其他不发送
5)no-deferer-when-downgrade(默认值):协议降级访问时不发送
6)origin-when-crossorigin:对同源链接/引用时发送 referer,非同源发送 origin
7)strict-origin:安全降级不发送(新加的标准)
8)strict-origin-when-cross-origin:安全降级不发送,同级同源才发送(新加的标准
- 常用的请求头部:
- Origin:服务器的域名(或者 ip)
- Host:服务器的域名和端口号(或者 ip:port)
- Referer:请求来源的页面详细地址(包括参数,除了 hash 字段,CSRF 攻击拦截常用字段)
- Connection:是否需要持久连接
- Content-Type:实体媒体类型
- Content-Length:实体的大小(字节)
- Range:bytes 0-xxx ,实体的字节请求范围
- Accept:客户端或者代理能够处理的 MIME 类型(对应服务端返回的 Content-Type )
- Accept-Encoding:优先可处理的编码格式
- Accept-Language:优先可处理的自然语言
- User-Agent:客户端程序信息
- Expire:缓存资源过期时间点
- Cache-Control:缓存资源过期时长
- If-Modified-Since:缓存资源更新时间
- If-None-Matchs:缓存资源的 hash 标示
- Cookie
- Access-Control-Request-Method
Access-Control-Request-Headers
- 常用的响应头部:
- Date:创建报文时间
- Content-Type:响应实体内容的类型
- Content-Length:响应实体长度
- Content-Range:bytes 0-xxx,响应实体长度范围
- Expired: 资源过期时间点
- Cache-Control:资源过期时长
- Last-Modified:资源最新修改时间
- ETag:资源 hash 标示
- Set-Cookie
- Access-Control-Allow-Methods
Access-Control-Allow-Origin
Access-Control-Allow-Headers
Access-Control-Allow-Credentials
Access-Control-Max-Age
Access-Control-Expose-Headers
一般来说,请求头部和响应头部是进行匹配分析的。
譬如,请求头部的 Accept 要和响应头部的 Content-Type 匹配,否则会报错。
譬如,跨域请求时,请求头部的 Origin 要匹配响应头部的 Access-Control-Allow-Origin ,否则会报跨域错误。
譬如,在使用缓存时,请求头部的 If-Modified-Since 、 If-None-Match 分别和响应头部的 Last-Modified 、 ETag 对应。
Content-Type 的值类型:
1)application/json:消息主体是序列化后的 JSON 字符串
2)application/x-www-form-urlencoded:
转换过程: {a: 1, b: 2} -> a=1&b=2 -> a%3D1%26b%3D2
在前端封装参数时一般通过对象形式的写法,所以需要通过 qs 序列化,数据会被编码成以&分隔的键值对,字符以 URL 编码方式编码,这样子后端才能正确解析提交的数据。
3)multipart/form-data:多用于通过表单提交的方式进行文件上传
请求头中的 Content-Type 字段会包含 boundary,且 boundary 的值有浏览器默认指定。
例:
Content-Type: multipart/form-data;boundary=----WebkitFormBoundaryRRJKeWfHPGrS4LKe
数据会分为多个部分,每两个部分之间通过分隔符来分隔,每部分表述均有 HTTP 头部描述子包体,如 Content-Type,在最后的分隔符会加上--表示结束。
在实际的场景中,对于图片文件、大量文本信息的上传,基本采用 multipart/form-data
,因为无需进行编码
Post 请求中 form-data 和 x-www-form 等格式的区别 (opens new window)
4)application/octet-stream:代表二进制流,一般用以下载文件
5)text/plain:数据以纯文本形式进行编码,不含任何控件和格式字符
# 空行
空行用于区分头部和实体,空行后的内容全部被视为实体。
# 请求/响应实体
请求/响应实体中需用到的参数。
# 4.3 HTTP 请求方法和状态码
# 请求方法
Method 有两种批次:
HTTP1.0 定义了三种请求方法: GET 、 POST 和 HEAD 方法。以及几种 Additional Request Methods : PUT 、 DELETE 、 LINK 、 UNLINK 。
HTTP1.1 定义了八种请求方法: GET 、 POST 、 HEAD 、 OPTIONS 、 PUT 、 DELETE 、 TRACE 和 CONNECT 方法。
各个方法的详情请戳:这里 🙋 (opens new window)
HTTP 请求方法中 GET 、 POST 和 PUT 的主要区别
参数:
GET 请求的参数会显示在 URL 上面,提交的数据量也跟 URL 的长度限制有直接关系。
POST 请求的一般参数不会显示在 URL 上面,而是通过请求实体 request body 传递参数,适合传输敏感信息。缓存:
GET 方式请求的数据会被浏览器缓存起来,有安全问题。POST 方法不会缓存数据,安全性比 GET 高。TCP:
GET 产生一个 TCP 数据包,对于 GET 方式的请求,浏览器会把 http header 和 data 一并发送出去,服务器响应 200(返回数据)。
POST 产生两个 TCP 数据包,浏览器先发送 header ,服务器响应 100 continue (客户端应继续其请求),浏览器再发送 data,服务器响应 200 ok(返回数据)。编码:
POST 支持更多的编码类型且不对数据类型限制。幂等:
PUT 和 POST 最主要的区别在于协议语义, PUT 是幂等的,而 POST 不是。
Methods can also have the property of "idempotence" in that (aside from error or expiration issues) the side-effects of N > 0 identical requests is the same as for a single request.”
意思是,如果一个方法重复执行多次,产生的效果是一样的,那就是幂等的。引用一个例子说明:
假如有一个博客系统提供一个 Web API ,模式是这样
http://superblogging/blogs/post/{blog-name}
,将{blog-name}替换为我们的 blog 名字,往这个 URI 发送一个 HTTP PUT 或者 POST 请求,HTTP 的 body 部分就是博文,这是一个简单的 REST API 例子。我们应该用 PUT 方法还是 POST 方法?取决于这个 REST 服务的行为是否是幂等的,假如我们发送两个http://superblogging/blogs/post/Sample
请求,服务器端是什么样的行为?如果产生了两个博客帖子,那就说明这个服务不是幂等的,因为多次使用产生了副作用了嘛;如果后一个请求把第一个请求覆盖掉了,那这个服务就是幂等的。前一种情况,应该使用 POST 方法,后一种情况,应该使用 PUT 方法。
# 状态码
在开发中,状态码是最常用到的字段,它能帮助快速定位问题,以下列举不同范围的状态码对应的意义:
分类 | 描述 |
---|---|
1** | 信息,服务器收到请求,需要请求者继续执行操作 |
2** | 成功,操作被成功接收并处理 |
3** | 重定向,需要进一步的操作以完成请求 |
4** | 客户端错误,请求包含语法错误或无法完成请求 |
5** | 服务器错误,服务器在处理请求的过程中发生了错误 |
其中,常见状态码:
200 - 表明该请求被成功地完成,所请求的资源发送回客户端
301 - 永久移动。请求的资源已被永久的移动,返回信息会包括Location
,表示新的 URI,浏览器会自动定向到新 URI。
302/307 - 临时移动。302 是 HTTP/1.0 的字段,307 则是 HTTP/2 版本中的字段。Location
表示需询问等待用户确认是否需跳转的重定向 URI。
302 Found 禁止从 POST 变成 GET,307 Temprary Redirect,请求方法和实体都不允许变动。
303 - 临时移动。资源临时被移动,返回信息会包括Location
表示直接通过 GET 请求的重定向 URI(会将 POST 请求变成 GET 请求)
304 - 自从上次请求后,所请求的资源未修改,请客户端使用本地缓存。服务器返回此状态码时,不会返回任何资源(和重定向不相关)
400 - 客户端请求参数出现错误
401 - Unauthorized 请求未经授权(需要身份验证后才能获取所请求的内容,407类似,表示需要登录认证代理服务器)
403 - Forbidden 禁止访问(客户端没有权利访问所请求内容,服务器拒绝本次请求)
404 - 资源未找到
405 - 请求方法不允许用于请求该资源
416 - 请求范围不合要求。请求头包含 Range,但 Range 指定的资源数据范围和服务端资源可用范围不重合
500 - 服务器内部错误,服务器遇到未知的错误导致无法完成请求
501 - 服务器不支持当前请求所需要的某个功能
502 - bad gateway 无效网关,服务器作为网关或代理,从上游服务器收到无效响应(或响应无法解析)
504 - bad gateway timeout 网关超时,服务器作为网关或代理,没有及时从上游服务器收到请求
503 - 服务器暂不可用(由于超载/停机维护)
505 - 服务器不支持请求中所用的 HTTP 协议版本
# 4.4 HTTP 的发展
# HTTP1.0 到 HTTP1.1 的一些变化
HTTP1.0 最早在网页中使用是在 1996 年,那个时候只是使用一些较为简单的网页上和网络请求上,而 HTTP1.1 则在 1999 年才开始广泛应用于现在的各大浏览器网络请求中,同时 HTTP1.1 也是当前使用最为广泛的 HTTP 协议。 其中的主要变化有以下几方面:
长连接
HTTP1.1 支持长连接和请求的流水线处理,在一个 TCP 连接上可以传送多个 HTTP 请求和响应,减少了建立和关闭连接的消耗和延迟。在 HTTP1.1 中默认开启 Connection: keep-alive ,一定程度上弥补了 HTTP1.0 每次请求都要创建连接的缺点。(⚠️ 注意: keep-alive 使用多了也会给服务端带来大量性能压力,并且对于单个文件被不断请求的服务(例如图片存放网站), keep-alive 可能会极大的影响性能,因为它在文件被请求之后还保持了不必要的连接很长时间。目前浏览器中对于同一个域名,默认允许同时建立 6 个 TCP 持久连接。)
Host 头处理
在 HTTP1.0 中认为每台服务器都绑定一个唯一的 IP 地址,因此,请求消息中的 URL 并没有传递主机名( hostname )。但随着虚拟主机技术的发展,在一台物理服务器上可以存在多个虚拟主机,并且它们共享一个 IP 地址。
HTTP1.1 的请求消息和响应消息都应支持 Host 头域,且请求消息中如果没有 Host 头域会报告一个错误( 400 Bad Request )。
错误通知的管理
在 HTTP1.1 中新增了 24 个错误状态响应码,如 409 表示请求的资源与资源的当前状态发生冲突;410 表示服务器上的某个资源被永久性的删除。支持 Range 字段,可以进行断点续传
HTTP1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能。HTTP1.1 则在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206 ,这样就方便了开发者自由的选择以便于充分利用带宽和连接。
缓存处理
在 HTTP1.0 中,主要使用 header 里的 If-Modified-Since , Expires 来做为缓存判断的标准。
HTTP1.1 则引入了更多的缓存控制策略例如 Entity tag 、 If-Unmodified-Since 、 If-Match 、 If-None-Match 等更多可供选择的缓存头来控制缓存策略。
# HTTPS 的诞生
尽管升级成了 HTTP1.1 ,但依然存在一些问题,如头部携带的内容过大,一定程度上增加了传输的成本,明文传输,难以确保数据的安全性等问题。HTTPS 因此而诞生, HTTPS 和 TLS ( SSL 演变,可以理解成同一个东西)一起使用。 HTTP 协议运行在 TCP 之上,使用明文进行内容传输,而 HTTPS 运行在 TLS 之上,TLS 运行在 TCP 之上,传输内容会进行加密传输。
# SPDY 的降临
SPDY 是 Goole 提出的综合了 HTTPS 和 HTTP 两者优点于一体的传输协议,主要体现在:
降低延迟
SPDY 采用多路复用的方式,通过多个请求 stream 共享一个 TCP 连接的方式,解决了 HOL blocking 和提高了宽带复用率。请求优先级
SPDY 允许为每个请求设置优先级header 压缩
基于 HTTPS 的加密协议传输
# HTTP/1.* 和 HTTP/2 的一些区别
多路复用
多路复用即连接共享,允许同时通过单一的 HTTP/2 连接发起多重的请求-响应消息。浏览器客户端在同一时间,针对同一域名下默认支持最多 6 个长连接请求,超过限制数目的请求会被阻塞。 在 HTTP/1.1 协议中,尽管支持长连接,但还有会出现队头阻塞的情况。因为多个请求是有顺序的,须处理完一个请求才会响应下一请求,如果第一个请求处理的特别慢,后面的所有请求就需要排队。(HTTP1.1 中采用合并雪碧图和合并域名减少请求连接优化,HTTP2 中则可以直接避免。)
头部压缩
采用 HPACK 算法,在客户端和服务端间维护一个头部信息表,将头部字段存进表里并生成索引号。再次请求时,检索对应的索引号作为请求内容,来减少报文头的大小。
(应用场景:一类业务请求中通过 cookie 标示用户,这个时候可以通过索引号来携带 cookie)
采取二进制分帧
HTTP1.x 的解析是基于文本。基于文本协议的格式解析存在天然缺陷,文本的表现形式多样,要做到健壮性考虑的场景必然很多。
HTTP2.0 在 应用层和传输层之间增加一个二进制分帧层,在分帧层里面,会将所有传输的信息分割为更小的消息和帧,并对它们采用二进制格式的编码。
其中 HTTP1.X 的首部信息会被封装到 HEADER frame,而相应的 Request Body 则封装到 DATA frame 里面。在不改动 HTTP1.X 的语义、方法、状态码、URI 以及首部字段的情况下, 改进 HTTP1.1 传输性能,实现低延迟和高吞吐量。
在一个连接里,客户端和浏览器都可以同时发送多个请求或回应。这些请求或回应在逻辑上分成了很多流(stream),每个流中可以传输若干消息(Message),每个消息由若干最小的二进制帧(Frame)组成。而且不用按照顺序一一对应(但是同一个请求或响应的帧必须是有序的,不同的可以无序,后续可以根据帧首部的流标示进行重装),这样就避免了"队头堵塞",减少了 TCP 连接数量和 TCP 连接慢启动造成的问题。
优先级策略
在 HTTP/2 中,每个请求都可以带一个 31bit 的优先值,0 表示最高优先级,数值越大优先级越低。有了这个优先值,客户端和服务器就可以在处理不同的流时采取不同的策略,以最优的方式发送流、消息和帧。
服务器推送
服务端推送是一种在客户端请求之前发送数据的机制。在 HTTP/2 中,服务器可以对客户端的一个请求发送多个响应。新建“流”主动向客户端发送消息。
比如,在浏览器刚请求 HTML 的时候就提前把可能会用到的 JS、CSS 文件发给客户端,减少等待的延迟,这被称为"服务器推送"( Server Push,也叫 Cache push)
安全性提高
目前 HTTP/2 普遍都是使用"https”协议名,跑在 TLS 上面。HTTP/2 协议定义了两个字符串标识符:“h2"表示加密的 HTTP/2,“h2c”表示明文的 HTTP/2。
# HTTP/2 的缺点
- TCP 以及 TCP+TLS 建立连接的延时
HTTP/2 使用 TCP 协议来传输的,而如果使用 HTTPS 的话,还需要使用 TLS 协议进行安全传输,而使用 TLS 也需要一个握手过程,这样就需要有两个握手延迟过程:
① 在建立 TCP 连接的时候,需要和服务器进行三次握手来确认连接成功,也就是说需要在消耗完 1.5 个 RTT 之后才能进行数据传输。
② 进行 TLS 连接,TLS 有两个版本——TLS1.2 和 TLS1.3,每个版本建立连接所花的时间不同,大致是需要 1~2 个 RTT。总之,在传输数据之前,我们需要花掉 3 ~ 4 个 RTT。
关于 HTTP 各版本的特点可阅读掘金上的文章:
解密 HTTP/2 与 HTTP/3 的新特性 (opens new window)
# 4.5 HTTP 与 HTTPS
# HTTP 与 HTTPS 的区别
- HTTP 协议传输数据的方式是明文传输,不会进行任何加密;(可能会被窃听、篡改、伪造)
HTTPS 协议在 HTTP 基础上加入了 TLS/SSL 协议,会对传输的数据进行加密和用户身份认证。(保证数据隐私、数据完整、身份认证) - HTTP 与 HTTPS 使用不同的连接方式,用的端口也不一样,前者是 80,后者是 443 。
- HTTPS 协议需要申请 CA 认证书,一般需要收费。
- HTTP 是无连接、无状态的。 其中,无连接、无状态分别指:
(1)无连接:新的无连接的含义是限制每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。
(2)无状态:协议对于事务处理没有记忆能力,对同一个 url 请求没有上下文关系服务器中没有保存客户端的状态,客户端必须每次带上自己的状态去请求服务器。
HTTPS 虽然使传输的安全性能大大提高,但也会因为大量的密钥算法计算,消耗大量的 CPU 资源,增加服务器的 CPU 压力。同时,因为 SSL 握手也会降低用户的访问速度,因此要有足够的优化和合理的部署,才能使 HTTPS 不逊于 HTTP 。
# 4.6 TLS 握手
HTTPS 如何保证安全?
HTTPS 是在 HTTP 和 TCP 之间建立了一个安全层(TLS),HTTP 与 TCP 通信的时候,必须先进过一个安全层,对数据包进行加密,然后将加密后的数据包传送给 TCP,相应的 TCP 必须将数据包解密,才能传给上面的 HTTP。
HTTPS 还是通过了 HTTP 来传输信息,但是信息通过 TLS(Transport Layer Security 协议进行了加密,TLS 安全层位于传输层之上,应用层之下。
在 TLS 协议中使用了两种加密技术,分别为:对称加密和非对称加密。
对称加密、非对称加密的区别:
(1)对称加密方法中,加解密用的都是相同的密钥。因为加解密是相同密钥,所以安全性不高。常见的有 AES、DES 算法。
(2)非对称加密方法中,会涉及到到两把密钥,一把是私钥(一般自己才有),一把是公钥(可以发布给任何人)。
公私密钥不能互相推导出,公钥加密需要用到私钥才能解密,私钥加密则需要用到公钥才能解密。相对对称加密,安全性较高。
缺点:
1)私钥加密的数据可以被任何持有公钥的第三方获取解密,并不能完全保证安全性;
2)公钥里也没有包含服务器的信息,也就是并不能确保公钥持有者的身份合法性;同时,非对称加密需要耗费一定时间,加密速度较慢。
对称加密算法:AES、DES、3DES、SM1、SM4
非对称加密算法:RSA、DSA、SM2
哈希散列加密:MD4、MD5、SHA-1、SHA256、SM3
为了提高效率,TLS 协议使用了这两种方式的混合加密机制,下面梳理一下 TLS 握手的过程。
# 传统的 RSA 握手过程
(1)Client Hello
客户端发送 client_random
、TLS 版本、客户端支持的加密套件(Cipher Suite)给服务端。
(2)Server Hello
服务端收到客户端发来的随机值,自己也会产生一个server_random
;
然后把确认使用的协议版本、所选择的一份加密套件(加密方法和压缩方法),服务端的数字证书(含服务器的公钥)一并传回给客户端;
(3)客户端验证来自服务端的公钥和证书(身份验证-RSA);
(4)客户端通过 RSA 算法生成pre_random
,并用服务端公钥加密回传(密钥交换-RSA);
(5)双方根据pre_random
、client_random
、server_random
,通过伪随机函数生成主密钥session_key
根据以上流程可以看出,RSA 不具备前向安全性:一旦服务端私钥被破解,方可获取到pre_random
、client_random
、server_random
,而导致之前发送的报文信息都能被破解。
# TLS1.2 握手的过程
(1)Client Hello
客户端发送 client_random
、TLS 版本、客户端支持的加密套件(Cipher Suite)给服务端。
(2)Server Hello
服务端收到客户端发来的随机值,自己也会产生一个server_random
;
然后把确认使用的协议版本、所选择的一份加密套件(加密方法和压缩方法),服务端的数字证书(含服务器的公钥)以及后面用于 DH 的参数server_params
,一并传回给客户端。
(3)客户端回应
Certificate Verify
接着,客户端会对服务端传过来的 CA 证书进行合法性和完整性验证,然后获取服务端的信息以及它的公钥(身份验证-RSA)。
Client Key Exchange
验证通过后取出证书中服务端的公钥,并生成一个随机数client_params
,传回至服务端。(也会先向服务端发送客户端的证书,让服务端来验证客户端的合法性。)
(此时,两端都同时拥有了server_random
、server_params
、client_random
、client_params
。)
客户端通过 ECDHE
算法计算出 pre_random
,其中传入两个参数: server_params
和 client_params
(密钥交换-ECDHE)。
(由于 ECDHE 基于椭圆曲线离散对数,这两个参数也称作椭圆曲线的公钥。)
客户端现在拥有了 client_random
、server_random
和 pre_random
,接下来将这三个数通过一个伪随机数函数PRF
来计算出最终的 master_secret
作为后续和服务端通信的对话密钥。
master_secret = PRF(
pre_random,
"master secret",
ClientHello.client_random + ServerHello.server_random
);
Change Cipher Spec
体现在数据包中就是一个字节的数据,用于向服务端表明客户端已切换到协商的加密套件状态,已生成加密密钥(主密钥)了。
Client Finish HandShake
在 ChangecipherSpec 传输完毕之后,客户端会使用之前协商好的加密套件和session secret
加密一段 Finish 的数据传送给服务端,此数据是为了在正式传输应用数据之前,对刚刚握手建立起来的加解密通道进行验证。
这一步也体现了 ECDHE 算法特点:
客户端发送完收尾消息后可以提前抢跑,直接发送 HTTP 报文,节省了一个 RTT,不必等到收尾消息到达服务器,然后等服务器返回收尾消息给自己,直接开始发请求。这也叫 TLS False Start。
(4)服务端最后回应(Server Finish HandShake)
服务端收到客户端传来的client_params
后,同样采用ECDHE
算法计算出 pre_random
,接着和客户端一样通过伪随机数函数
来计算出最终的 master_secret
。
生成完 secret 也会向客户端发送一个收尾的消息,这个收尾的消息也包括了两部分Change Cipher Spec
和Finish HandShake
。
告诉客户端以后就直接用对称加密来通信。当双方都验证通过之后,握手才正式结束。后面的 HTTP 正式开始传输加密报文。
加密套件
上述提及到的加密套件列表一般长这样
Cipher Suites(4 suites):
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
TLS_DHE_RSA_WITH_AES_128_GCM_SHA256
TLS_DHE_RSA_WITH_AES_256_GCM_SHA384
TLS_RSA_WITH_AES_128_CBC_SHA
每个 Cipher 主要是由以下的部分组成:
- 密钥交换算法
- 身份验证算法
- 对称加密算法_对称加密的密钥大小
- 对称加密的密码模式(可应用时)
- MAC 算法(可应用时)
- PRF(只有 TLS1.2 一定使用,其他版本取决于各自协议)
- 用于 Finished 消息的散列函数(TLS1.2)
- verify_data 结构的长度(TLS1.2)
例如 TLS_ECDHE_WITH_AES_128_GCM_SHA256
就表示:
- 握手基于 TLS 协议;
- 使用 ECDHE 作为密钥交换算法,即使用 ECDHE 算法生成
pre_master secret
; - 对称密钥采用 128 位的 AES 算法进行加密,加密的过程中使用主流的 GCM 分组模式;
- 最后一个是 MAC 算法,检验数据完整性的哈希摘要算法,包括 SHA,MD5 等,256 表示哈希值的长度为 256 位。 即客户端是通过这个算法将服务端证书的明文信息生成一个信息摘要 和 用私钥加密后把加密后的标识和自己的公钥传给客户端。
# 数字证书的验证过程
TLS 过程中,客户端需要对服务器的证书进行验证,在梳理数字证书的验证过程前,先简单说一下几个需要了解的名词:
# 名次解析
数字证书
数字证书是一个经证书授权中心(也称作数字认证机构/CA)颁发给服务器的文件。
数字证书里一般会包含数字签名、签名算法、公钥(非对称算法、参数)、公钥拥有者/使用者信息,颁发者的数字签名、颁发者单位、有效期、证书序列号等信息。
CA 数字证书颁发的过程:
- 服务器运营人员先向认证机构提交自己的公钥、组织信息、个人信息等申请认证;
- 认证机构获取这些信息后,通过线上线下各种渠道验证申请者提交信息的真实性;
- 申请者信息被通过后,认证机构会将申请者提交的明文信息(申请者公钥、组织信息、个人信息、认证机构信息)进行数字签名。(即通过哈希算法函数处理明文信息,生成信息摘要;接着,再通过认证机构的私钥对信息摘要进行加密处理。最终生成的文件称作认证机构的数字签名。)
- 最后,认证机构会将明文信息和数字签名组合而成的证书,颁发给申请者,即服务器。
数字签名
数字签名的产生主主要是为了解决 HTTP 中内容可能被篡改的问题,即校验数据的完整性。数字签名的产生过程主要为两步:
- 将原文信息用哈希算法(MD5/SHA)生成一个叫信息摘要的东西;
- 用发送方的私钥对这个信息摘要进行进行加密,这个产生的东西就叫做数字签名,它一般会与原文信息一起发送给接收者。
# 验证过程
验证过程 了解上面两个名词之后,再回来看看在 TLS 握手的Certificate Verify
步骤中,浏览器是如何会对服务器的证书进行验证(这里的验证主要是验证 服务端发来的公钥是否真实 和 数字证书中的数字签名):
(1)确保了服务器传来证书的明文信息有没有被篡改过和是不是完整的,即验证证书数字签名:
step1:数字证书由服务器的明文信息和颁发者的数字签名组成,客户端首先会根据约定好的哈希摘要算法对明文信息和公钥生成一个信息摘要(摘要 1);
step2:接着,会用浏览器内置可信的 CA 颁发者的公钥对数字证书里的数字签名进行解密,得到一个信息摘要(摘要 2)。(由证书颁发过程可知,里面的数字签名是用的是颁发者私钥加密的,所以只有颁发者的公钥才能解密。)
最后再将这两个信息摘要(摘要 1 和摘要 2)进行对比,一致则能保证明文信息没被攥改,通信方的身份是完整的、真实可信的。
(2)验证这个服务器信息是否真实可信,即验证给服务器颁发证书的机构的权威性:
验证这个服务器是否真实可信,即验证证书的明文信息是否是真实的(相关的域名、有效时间、是否在证书吊销列表 (CRL)里面、它的上一级是否有效等),所以要找到这个服务器证书的颁发者(他的上一级);
找到了上一级,证明了服务器是可信的(这个证明过程和(1)的步骤一致)。
但此时,客户端依然需要验证这个颁发者是不是可信的呢?
于是还得继续对上一级进行真实验证,这是一个递归的过程,直到验证到根证书也就是操作系统内置的 Root 证书或者浏览器内置的 Root 证书为止。
一路倒推,证书的颁发者可信,那么它所颁发的所有站点也是可信的,最终确定了所访问的服务端也是可信的。
ECDHE 握手 和 RSA 握手:
传统的 RSA 握手过程如下图:
对于 RSA 来说,客户端通过 RSA 算法生成预主密钥,然后用服务器公钥加密发给服务器,服务器用私钥来解密得到预主密钥,然后由预主密钥生成主密钥(会话密钥),最后用会话密钥来通信。
ECDHE 握手过程如下图:
对于 ECDHE 来说,客户端和服务器双方是交换椭圆曲线参数,私钥只是用来签名,这是为了保证这个消息是持有私钥的人给我发的,而不是冒充的。双方交换完参数之后生成预主密钥,再生成主密钥(会话密钥)。这就跟刚才 RSA 后面的流程一样了。
传统的 RSA 握手和 ECDHE 握手的主要区别在于:
生成
master_secret
(对称密钥)的过程不同。
RSA 握手会在验证完服务器证书后,通过 RSA 算法生成一个pre_random
,然后通过服务端公钥加密传给服务端,最终再各自用约定好的伪随机函数生成相同的master_secret
对称密钥。ECDHE 握手中则不会用到 RSA 算法,而是在验证完服务器证书后,明文传回一个客服端的
client_params
,然后再用约定好的 ECDHE 算法生成pre_random
( ECDHE 在每次握手时都会生成临时的密钥对),再通过伪随机函数生成最终的master_secret
对称密钥。这个过程中比 RSA 握手多了client_params
和server_params
两个参数。在生成完
master_secret
之后,ECDHE 握手在客户端发送完收尾消息后可以提前抢跑,直接发送 HTTP 报文,节省了一个 RTT,不必等到收尾消息到达服务器且收到服务器返回的收尾消息,就可以直接开始发请求。这也叫 TLS False Start。最大区别:RSA 不具备前向安全性,ECDHE 有。(前向安全性:一次破解并不影响历史信息的性质就是前向安全性)。
简单粗暴的理解:
因为 ECDHE 中服务器私钥不参与密钥交换的协商,故即使私钥泄漏,客户端和服务器之间加密的报文都无法被解密,这叫前向安全(forward secrity)。而 RSA 中传输
pre_random
中,使用了服务器公钥对其进行加密,一旦服务器私钥被泄露,被第三方截获了之前所有报文利用私钥进行解密,即会拿到了最终通信的对称密钥,那么每一个历史报文都能通过这样的方式进行破解,因此不具备前向安全性。
TLS 1.3 版本:
TLS1.3 在 2018 推出了,对于 TLS1.2 做了一系列的改进,TLS1.3 版本支持的加密套件如下:
TLS_AES_128_GCM_SHA256;
TLS_AES_256_GCM_SHA384;
TLS_CHACHA20_POLY1305_SHA256;
TLS_AES_128_GCM_SHA256;
TLS_AES_128_GCM_8_SHA256;
最后剩下的对称加密算法只有 AES 和 CHACHA20,之前主流的也会这两种。分组模式也只剩下 GCM 和 POLY1305, 哈希摘要算法只剩下了 SHA256 和 SHA384 了,默认密钥交换算法为 ECDHE。
TLS1.3 握手过程:
大体的方式和 TLS1.2 差不多,不过和 TLS 1.2 相比少了一个 RTT,服务端不必等待对方验证证书之后才拿到 client_params,而是直接在第一次握手的时候就能够拿到, 拿到之后立即计算 secret,节省了之前不必要的等待时间。同时,这也意味着在第一次握手的时候客户端需要传送更多的信息,一口气给传完。
这种 TLS 1.3 握手方式也被叫做 1-RTT 握手。但其实这种 1-RTT 的握手方式还是有一些优化的空间的。
可戳:详解 TLS1.3 的握手过程 (opens new window)
# 4.7 关于 Cookie
# Cookie 简介
由于 HTTP 是无状态的协议,因此每个请求都是独立的,协议对于事务处理没有记忆能力。客户端和服务端需要约定好共同维护一个状态,来告知服务端前后两个请求是否来自同一浏览器。
Cookie 存储在客户端:
Cookie 是服务器发送到浏览器并保存在本地的一小块数据。在第一次向服务器发送请求的时候,服务器会将 Cookie 放入响应头中,在浏览器下次访问同一服务器,再发起请求时会被携带并发送到服务器上,便于服务器进行修改或者识别。
Cookie 是不可跨域的:
每个 Cookie 都会绑定单一的域名,无法在别的域名下获取使用,一级域名和二级域名之间是允许共享使用的(domain 字段)。
# Cookie 常用属性
name = value
键值对。同一域名下不能存储相同的 Cookie 的名称。Cookie 的名称和值当存在分号、逗号、空格、中文时,为了服务器的兼容性和避免出现乱码,需要对 Cookie 的名称和值进行编码。domain
Cookie 绑定的域名,若没有指明会自动绑定到当前域。值得注意的是 Cookie 的获取和设置范围:
假如现在有以下域名,chieminchan.com // com 为顶级域名,chieminchan 为二级域名 small.chieminchan.com // small 为三级域名 big.chieminchan.com // big 为三级域名,big 和 small 为同级域名,big 和 small作为三级域名是 chieminchan.com 的子域名 super.small.chieminchan.com // super 为四级域名
(1)Cookie 只可以设置当前域名或者其父域名的 domain,设置其他级别的不会生效。
比如,small.chieminchan.com
无法设置其子域名super.small.chieminchan.com
和同级域名big.chieminchan.com
的 Cookie。(2)Cookie 只能获取domain 本身以及domain 下的所有子域名。
比如,domain 为small.chieminchan.com
的 Cookie ,只有该域名或者其子域名super.small.chieminchan.com
才能获取到这个 Cookie。path
指定 Cookie 生效的页面路径,默认是 '/'。这个值匹配的是页面路由:
如果在
chieminchan.com
下,设置为/abc
,则只有chieminchan.com/abc
下的路由可以访问到该 Cookie,如:chieminchan.com/abc/read
。Cookie 的作用域由 path 和 domain 属性共同决定。
expires/max-age
Cookie 的过期/有效时间。
expires 表示在设置的某个时间点后该 Cookie 就会失效。 expires 值缺省时,表示是会话性 Cookie。
max-age 单位是秒,默认为 -1。当 max-age 的值为正数时,它表示 Cookie 在 max-age 秒后失效被删除;当值为负数时,它表示 Cookie 为会话性 Cookie,关闭浏览器即失效,浏览器不会以任何形式保存该 Cookie;当值为 0时,则表示删除 Cookie,失效的 Cookie 会被浏览器自动从内存中删除。
如果 expires 和 max-age 都存在时,max-age 优先级更高。
size
Cookie 的大小。不同浏览器对 Cookie 的大小和个数有限制,Cookie 的体积不能超过 4KB。
httpOnly
当此属性为 true 时,无法通过 js 读取 Cookie 的值。不能防止 XSS 攻击,只能避免受到 XSS 攻击后,被恶意窃取 Cookie。
(但是仍然可以通过控制台的 Application 手动修改 Cookie,所以不是绝对的安全)secure
secure 属性值为 true 的 Cookie 只能用 HTTPS 协议发送给服务器,用 HTTP 协议是不发送的。使用 HTTPS 安全协议,可以保护 Cookie 在浏览器和 Web 服务器间的传输过程中不被窃取和篡改。
应用场景:
cookie 是在 https 的情况下创建的,而且他的 Secure=true,那么之后你一直用 https 访问其他的页面(比如登录之后点击其他子页面),cookie 会被发送到服务器,你无需重新登录就可以跳转到其他页面。但是如果这是你把 url 改成 http 协议访问其他页面,你就需要重新登录了,因为这个 cookie 不能在 http 协议中发送。
SameSite
SameSite 属性可以让 Cookie 在跨站请求时不会被发送,从而可以阻止跨站请求伪造攻击(CSRF)。SameSite 的属性值有三个:
(1)Strict:表示仅允许一方请求携带 Cookie,即浏览器将只发送相同站点请求的 Cookie,即当前网页 URL 与请求目标 URL 完全一致。(2)lax:允许发送安全 HTTP 方法带上 Cookie,如
Get
/OPTIONS
、HEAD
请求;get 方法 / href 属性 / a 标签发送 get 请求的情况下可以携带 Cookie,其他情况均不能。(保证页面的幂等性,后端接口不要在 GET 页面中做用户操作)(3)None: 表示无论是否跨站都会发送 Cookie。
其中,注意区分跨站和跨域是不同的。
同源指两个 URL 的协议/主域名/子域名/端口都需要一致,不同域之间相互请求资源,就算作“跨域”。同源保证了浏览器的安全。
而 Cookie 的同站则是指两个 URL 的
eTLD+1
相同即可。eTLD 表示有效顶级域名,注册于 Mozilla 维护的公共后缀列表(Public Suffix List)中。例如:
.com
.co.uk
、.github.io
等。eTLD+1 则表示有效顶级域名+二级域名,例如 ·taobao.com· 等。
www.taobao.com
和www.baidu.com
属于跨站,www.a.taobao.com
和www.b.taobao.com
是同站,a.github.io
和b.github.io
是跨站。以前 SameSite 的默认值为 None ,当默认值变成 Lax 后的影响(来自冴羽大神文章的图片 (opens new window)):
# Cookie 的操作方法
创建 Cookie
document.cookie = "userId=nick123; expires=Wed, 13 May 2020 12:00:00 UTC";
获取 Cookie
要访问具有特定名称的 Cookie,需要获取页面上所有的 cookie 并解析字符串,然后再查找得到 Cookie 对应名称的匹配项。const getCookieByName = (name) => { const cookieName = name + "="; const cookieVal = document.cookie.split(";").filter((item) => { return item.includes(cookieName); }); if (cookieVal.length) { return cookieVal[0].substring(cookieName.length); } else { return ""; } };
删除 Cookie
可以把特定名称的 Cookie 的过期时间设置成过去的任何一天:
document.cookie = "userId=; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
编辑 Cookie(尽量避免修改 Cookie)
可以通过创建的方式用新值覆盖需要修改的 Cookie:
document.cookie = "userId=new_value";
# 5. HTTP 缓存机制
在 HTTP 网络请求交互中,缓存是性能优化的重要手段。
没有缓存机制,浏览器就要多次重复请求服务器资源,而服务器也需要多次重复读取磁盘的对应资源,这样子大大延迟了浏览器获得和处理内容的时间以及增加了访问者的流量费用,造成非常差的用户体验。因此浏览器和服务端互相约定好缓存机制
。
浏览器首次加载资源成功时,服务器返回 200,此时浏览器不仅将资源下载下来,而且把 response 的 header(date 属性非常重要,用来计算第二次相同资源时当前时间和 date 的时间差)一并缓存。接着在二次请求时,将会进入 HTTP 缓存阶段。
HTTP 的缓存主要有两大类:强缓存和协商缓存。优先级较高的是强缓存,在命中强缓存失败的情况下才会进行协商缓存。
具体的缓存优先级和缓存的决策过程可详见另外一篇文章 (opens new window):
# 6. 页面的解析和渲染
在网络请求和响应完毕以后,浏览器对获取到的页面内容进行解析和渲染。
主要的流程可以简述为以下几点:
(1)首先,解析 HTML,构建 DOM 树;
(2)接着,读取 CSS 进行样式计算,生成 CSSOM 树;
(3)接着,根据 DOM 树和 CSSOM 树,计算各元素尺寸和位置的,生成布局树。
(4)DOM 树的绘制是在多个层上进行的,所以浏览器会先对特定的节点创建层;
(5)然后,浏览器会将各个层信息发送给 GPU(GPU 进程:最多一个,用于 3D 绘制等),GPU 会将各层合成(composite),最终显示在屏幕上。
# 6.1 页面解析
# 解析 HTML,构建 DOM 树
浏览器无法直接理解 HTML 字符串,需要将其将字节流转换成 DOM 树的数据结构。DOM 树本质上是一个以 document 为根节点的多叉树。
这个解析的过程主要分为下面几个步骤:
Step1: 从网络或者磁盘下读取的 HTML 原始字节码,通过设置的 charset 编码,转换成相字符;
Step2: 通过词法分析器,将字符串解析成 Token;Token 中会标注出当前的 Token 是 开始标签 ,还是 结束标签 ,或者 文本标签 等。
Step3: 生成 Nodes 并构建 DOM 树;浏览器会根据 Token 记录的开始标签和结束标签串联起来,生成对应的一个个 Node 节点,其中 Node 节点会包括这个节点的属性值。
事实上,Step2 和 Step3 是同时进行的,一边生成 Token 一边采取深度遍历算法消耗 Token 来生成 Node。
进一步细化过程:
HTML 解析器维护了一个 Token 栈结构,该 Token 栈主要用来计算节点之间的父子关系,生成的 Token 会被按照顺序压到这个栈中。具体的处理规则如下所示:
- 如果压入到栈中的是 StartTag Token,HTML 解析器会为对应的 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。
- 如果分词器解析出来是文本 Token,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。
- 分词器解析出来的是 EndTag 标签,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成。
通过分词器产生的新 Token 就这样不停地压栈和出栈,整个解析过程就这样一直持续下去,直到分词器将所有字节流分词完成。
这个过程中间可能还会涉及到资源外链的下载,比如:CSS 样式资源、JS 脚本资源、img 图片类资源。
值得注意的是:从 bytes 到 Tokens 的这个过程,浏览器都可以交给其他单独的线程去处理,不会堵塞浏览器的渲染线程。但是后面的部分就都在渲染线程下进行了,也就是我们常说的 js 单线程环境。
# 计算样式,生成 CSSOM 样式树
样式文件一般有三种引入方式:
(1)外部样式,通过 link 引用;
(2)行内样式,内部 <style>
标签;
(3)内联样式,元素标签属性。
浏览器也是不能直接理解 CSS 样式字符串的,CSSOM 的生成过程和 DOM 的生成过程十分相似,也是:解析、Token 化、生成 Nodes 并构建 CSSOMTree。
Step1: 渲染引擎接收到 CSS 文本后先进行格式化样式表,会将其转化为结构化的对象 styleSheets。(document.styleSheets
可以查看网页的样式表。)
Step2: 在格式化后,接着会进行样式表的标准化。对一些 CSS 样式的值统一转成浏览器可以理解的标准值,比如:bold -> 700
,white -> #ffffff
。
Step3: 通过格式化和标准化后,计算每个 DOM 节点具体样式,节点的样式遵从继承和层叠规则。
涉及问题:
强调 css 文件应该放在 html 文档中的前面引入,js 文件应该放在后面引入,这么做的原因是什么呢?
- CSSOM 树的解析不会阻塞 DOM 树的构建,但会阻塞渲染树的生成。未构建完的 CSSOMTree 是不准确的,浏览器必须等到 CSSOMTree 构建完毕后才能进入下一阶段;
- 直至 CSSOM 构建完毕,JavaScript 才会执行,因此 CSSOM 树的解析也会阻塞 JS 的运行。(GUI 渲染线程和 JS 引擎互斥)。
因为 JavaScript 引擎在解析 JavaScript 之前,是不知道 JavaScript 是否操纵了 CSSOM 的,所以渲染引擎在遇到 JavaScript 脚本时,不管该脚本是否操纵了 CSSOM,都会执行 CSS 文件下载,解析操作,再执行 JavaScript 脚本。(如果外联了 css 文件,也会需要等到外联 css 文件下载完和构建完 cssom 才进行执行 js。)所以说 JavaScript 脚本是依赖样式表的。
综上,CSS 的加载速度与构建 CSSOMTree 的速度将直接影响首屏渲染速度,因此在默认情况下 CSS 被视为阻塞渲染的资源,需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。
样式继承规则和样式层叠规则分别是怎么样的?
继承规则主要是指来自父级及之上的祖先级元素的样式影响,甚至还有可能是浏览器默认的样式(UserAgent)的影响。
层叠规则主要是不同优先级的层叠规则存在覆盖的问题,层叠是 CSS 的一个基本特征,它定义了如何合并来自多个源的样式属性。样式层叠的优先级如何计算的?
- 关于层叠的几个概念:
(1)“层叠上下文”是 HTML 中的一个三维的概念。如果一个元素含有层叠上下文,我们可以理解为这个元素在 z 轴上就“高人一等”。
以下条件会触发层叠上下文的创建:
- 页面根元素
- 包含有
position:relative/position:absolute
且其 z-index 值不是 auto 的定位元素会创建层叠上下文;(给非定位元素和非 flex 子元素设置 z-index 是没意义的) - position:fixed 且 z-index:auto 也会创建上下文;
- 父级 display:flex,子元素的 z-index 不是 auto,必须是数值。此时,这个子元素为层叠上下文元素;
- opacity 透明元素具有层叠上下文;
- transform 变换的元素具有层叠上下文;
(2)“层叠水平”决定了同一个层叠上下文中元素在 z 轴上的显示顺序。
层叠水平和 z-index 不能混为一谈,z-index 可以影响层叠水平,但是,只限于定位元素以及 flex 盒子的孩子元素;而层叠水平所有的元素都存在。
(3)“层叠顺序”表示元素发生层叠时候有着特定的垂直显示顺序。
层叠顺序的规则如下:
形成层叠上下文元素的background/border
->z-index负值
->block块状盒子
->float浮动盒子
->inline-block水平盒子
->z-index:0/auto /不依賴z-index的层叠上下文
->z-index正值
。元素一旦成为定位元素,其 z-index 就会自动生效,此时其 z-index 就是默认的 auto,根据上面的层叠顺序表,就会覆盖 inline/float/block 元素。
重要的层叠准则:
- 谁大谁上:当具有明显的层叠水平标示的时候,如识别的 z-indx 值,在同一个层叠上下文领域,层叠水平值大的那一个覆盖小的那一个。
- 后来居上:当元素的层叠水平一致、层叠顺序相同的时候,在 DOM 流中处于后面的元素会覆盖前面的元素。
层叠上下文的特性:
- 层叠上下文的层叠水平要比普通元素高;
z-index:0 所在的素是层叠上下文元素,而 z-index:auto 所在的素是一个普通的元素,因此前者的层叠水平会高于普通元素的。
层叠上下文可以嵌套,内部层叠上下文及其所有子元素都会受制于外部的层叠上下文;(即外部层叠上下文直接影响内部的,子层叠上下文无法超过其父级层叠的上下文。)
每个层叠上下文自成体系,当元素发生层叠的时候,整个元素被认为是在父层叠上下文的层叠顺序中。(即如果两个子元素发生堆叠,会优先对他们的父元素层叠进行比较)
# 计算元素的位置和布局,生成布局树(Layout Tree)
根据已经生成的 DOM 树和 CSSOM 树,浏览器布局系统会对元素进行位置计算和布局。主要过程是:
(1)遍历 DOM 树的可见节点,将可见的 DOM 节点逐个放进布局树中;对于不可见的节点,head,meta 标签等都会被忽略。
head 标签 和 样式为display: none
的元素不会放进布局树中。
(2)对布局树中的每个节点计算位置。
这一部分会涉及元素的高宽度和 margin 值的计算、盒子模型结构、定位方式和位置计算等,更详细的可以查看FE 团队的文章 (opens new window)。
涉及问题:
如何理解盒子模型?
JavaScript 脚本文件对 DOM 树解析的影响?
script 标签的 async 和 defer 属性的区别和使用。
<script src='script.js' />
没有 defer 或 async,浏览器会立即加载并执行指定的脚本,“立即”指的是在渲染该 script 标签之下的文档元素之前,也就是说不等待后续载入的文档元素,读到就加载并执行。
<script async src='script.js' />
有 async,加载和渲染后续文档元素的过程将和 script.js 的加载与执行并行进行(异步),执行时会阻塞文档元素的渲染。
<script defer src='myscript.js' />
有 defer,加载后续文档元素的过程将和 script.js 的加载并行进行(异步),但是 script.js 的执行要在所有元素解析完成之后,DOMContentLoaded 事件触发之前完成。
async 和 defer :
它俩的差别在于脚本下载完之后何时执行,显然 defer 是最接近我们对于应用脚本加载和执行的要求的。同时 defer 脚本执行过程会阻塞 DOMContentLoaded 事件的触发。
- defer / async 与 DOMContentLoaded
defer:
如果 script 标签中包含 defer,那么这一块脚本将不会影响 HTML 文档的解析,而是等到 HTML 解析完成后才会执行。
而 DOMContentLoaded 只有在 defer 脚本执行结束后才会被触发。 所以这意味着什么呢?HTML 文档解析不受影响,等 DOM 构建完成之后 defer 脚本执行,但脚本执行之前需要等待 CSSOM 构建完成。在 DOM、CSSOM 构建完毕,defer 脚本执行完成之后,DOMContentLoaded 事件触发。
async:
如果 script 标签中包含 async,则 HTML 文档构建不受影响,解析完毕后,DOMContentLoaded 触发,而不需要等待 async 脚本执行、样式表加载等等。
比如:文档解析完成的同时刚好有个脚本(async)也加载完成了,那么是不需要等待脚本(async)执行的,直接 DOMContentLoaded 触发。
# 6.2 页面渲染
# 分层
# 构建图层树
开发页面还会经常写入一些比较复杂的特效动画,如 3D 转换、页面滚动或使用 z-index 做 z 轴排序等,如果没有采用分层机制,从布局树直接生成目标图片的话,那么每次页面有很小的变化时,都会触发重排或者重绘机制,这种“牵一发而动全身”的绘制策略会严重影响页面的渲染效率。
为了提升每帧的渲染效率,Chrome 引入了分层和合成的机制。在 Chrome 的渲染流水线中,分层体现在生成布局树之后,渲染引擎会根据布局树的特点将其转换为图层树(Layer Tree),图层树是渲染流水线后续流程的基础结构。
通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。图层树中的每个节点都对应着一个图层,下一步的绘制阶段就依赖于层树中的节点。
那什么情况下,渲染引擎会为特定的节点创建新图层呢?通常满足下面任意一点的元素就可以被提升为单独的一个图层:
拥有层叠上下文属性的元素会被提升为单独的一层,比如:页面根元素、明确定位属性的元素、定义透明属性的元素、使用 CSS 滤镜 filter 的元素等,都拥有层叠上下文属性;
需要剪裁(clip)的地方也会被创建为图层,出现裁剪的例子:把 div 的大小限定为
200 * 200
像素,而 div 里面的文字内容比较多,文字所显示的区域肯定会超出200 * 200
的面积,这时候就产生了剪裁,渲染引擎会把裁剪文字内容的一部分用于显示在 div 区域,如果出现滚动条,滚动条也会被提升为单独的层。
# 关于图层的进一步了解(渲染层/合成层)
在 Chrome 中其实有几种不同的图层类型:
RenderLayers / PaintLayer
渲染层,这是负责对应 DOM 子树
在 DOM 树中每个节点都会对应一个 LayoutObject,当他们的 LayoutObject 处于相同的坐标空间时,一般来说,拥有相同的坐标空间的 LayoutObjects,属于同一个渲染层(PaintLayer/RenderLayers)。渲染层保证页面元素以正确的顺序合成,这时候就会出现层合成(composite),从而正确处理透明元素和重叠元素的显示。GraphicsLayers
图形层,这是负责对应 RenderLayers 子树。
对于某些特殊的渲染层会被认为是合成层,合成层拥有独立的渲染层(准确来说是单独的 GraphicsLayer),而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer 父层公用一个。每个 GraphicsLayer(合成层单独拥有的图层) 都有一个 GraphicsContext,GraphicsContext 会负责输出该层的位图,位图是存储在共享内存中,作为纹理上传到 GPU 中,最后由 GPU 将多个位图进行合成,然后显示到屏幕上。
什么是纹理?可以把它想象成一个从主存储器(例如 RAM)移动到图像存储器(例如 GPU 中的 VRAM)的位图图像(bitmapimage)。
Chrome 使用纹理来从 GPU 上获得大块的页面内容。通过将纹理应用到一个非常简单的矩形网格就能很容易匹配不同的位置(position)和变形(transformation)。这也就是 3DCSS 的工作原理,它对于快速滚动也十分有效。
# 合成层(CompositingLayer)
《Webkit 技术内幕》中称,在涉及硬件加速时,Webkit 会决定将哪些渲染层(RenderLayer)对象组合在一起,形成一个有后端存储的新层,这一新层不久后会用于合成,这里称之为合成层(CompositingLayer)。
每一个合成层都会对应一个或多个后端存储,由 RenderLayerBacking 类进行统一管理,后端存储空间使用图形层(GraphicsLayer) 来表示,也就是说 RenderLayerBacking 管理着一个或多个与对应的合成层有关的 GraphicsLsayer。
对于满足某些特殊条件的渲染层,会被浏览器直接自动提升为合成层。包括以下几类常见条件:
(1)具有 CSS 3D transforms (
translateZ()、translate3d()
)或 CSS 透视效果
(2)3D 或者 硬件加速的 2DCanvas
元素
(3)使用了硬件加速的视频解码技术的<video>
(4)对opacity、transform、fliter、backdropfilter
应用了animation
或者transition
(4)backface-visibility
为 hidden
(6)具有will-change
属性
以上的条件是显式提升成合成层的特殊条件,但在浏览器合成阶段会发生隐式提升,比如一个或多个非合成元素应出现在堆叠顺序上的合成元素之上,被提升到合成层:
(1)当一个合成层元素和普通元素的 border-box 有重叠,普通元素也会被提升为一个合成层;
(2)一个 overflow scroll 的元素(overflow:auto/scroll,只要是能 scroll 即可)同一个合成层重叠,则其可视的子元素也会和合成层重叠,进而都被提升为合成层;
( 可通过 Chrome 控制台的设置栏中展开 more tools -> Rendering 面板勾选 Layer borders
查看合成层,合成层会用黄色边框标识出来。)
提升为合成层的好处:
(1)合成层的位图,会直接交由 GPU 合成,比 CPU 处理要快;
(2)当需要 repaint 时,只需 repaint 自身图层,不会影响倒其他层;
(3)对于transform
和opacity
效果,不会触发 layout 和 paint(如果不是合成层会触发 repaint)。提升为合成层的缺点:
合成层的出现优化了渲染性能和加快页面加载速度,但会带来其他的成本。
(1)它会占用系统 RAM 和 GPU 上的内存(限于移动设备),当层的数量和大小达到一定量级时,可能会导致设备闪烁或者浏览器崩溃(特别是在移动设备上);(2)隐式提升合成层也可能会导致层爆炸(由于某些原因可能导致产生大量不在预期内的合成层)。
虽然有浏览器的层压缩机制,但是也有很多无法进行压缩的情况(比如当渲染层同合成层有不同的裁剪容器时该渲染层无法压缩,相对于合成层滚动的渲染层也无法被压缩),这就可能出现层爆炸的现象(简单理解就是,很多不需要提升为合成层的元素因为某些不当操作成为了合成层,比较好的处理方式是使用 3D 硬件加速提升动画性能时,最好给元素增加一个 z-index 属性,人为干扰合成的排序)。
关于合成层的性能优化点:
(1)把动画效果中的元素提升为合成层,避免影响到其他元素的绘制;提升合成层的最好方式是使用 CSS 的 will-change 属性。从合成层产生原因中,可以知道 will-change 设置为 opacity、transform、top、left、bottom、right 可以将元素提升为合成层。
(2)使用 transform 或者 opacity 来实现动画效果, 这样只需要做合成层的合并就好了。
(3)减少绘制区域;对于不需要重新绘制的区域应尽量避免绘制,比如:
一个 fix 在页面顶部的固定不变的导航 header,在页面内容某个区域 repaint 时,整个屏幕包括 fix 的 header 也会被重绘。对于固定不变的区域,我们期望其并不会被重绘,因此可以通过之前的方法,将其提升为独立的合成层。减少绘制区域,需要仔细分析页面,区分绘制区域,减少重绘区域甚至避免重绘。(此处的 fix 元素只是为他创建了渲染层,最终可以理解为是一个默认复合层,即没有被提升成单独的一个真正合成层)
关于更详细的合成层和性能优化推荐 FED 团队的文章无线性能优化:Composite (opens new window)
# 生成绘制列表
完成了层树的构建之后,渲染进程的主线程会对每个层开始进行绘制。
每个图层的绘制将会被拆分成一个个绘制指令,这些绘制指令会按顺序组成一个待绘制列表。
( 可通过 Chrome 控制台的设置栏中展开 more tools -> Layers 面板查看页面的绘制列表。展开 document,点击 paint profiler
可查看绘制流程。)
# 分块和栅格化
有了记录绘制顺序和绘制指令的绘制列表,就需要进入光栅化阶段了,光栅化就是按照绘制列表中的指令生成图片。主线程会将绘制列表提交给合成线程,让合成线程去绘制图层,每一个图层都对应一张图片,合成线程有了这些图片之后,会将这些图片合成为“一张”图片,并最终将生成的图片发送到后缓冲区,等待显示器读取到最新显卡合成的图像进行显示。这是一个大致的分层和合成流程。
通常一个页面可能很长,需要多次滚动滚动条才会到底,但是用户一开始只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。
如果页面需要等待整个图层绘制完才显示出来,会导致合成图片的时间变得更久,因此合成线程会先将图层划分成大小固定的图块(分块),接着将视口(viewport,指的是整个页面中用户看到的可视区域部分)附近的图块优先生成位图。图块生成位图的过程就是栅格化。
值得注意的是:
图块是栅格化的最小单位,由于栅格化计算量大,所以渲染进程一般会将生成图块指令发送给 GPU,由 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化。
如果 GPU 资源占满,则渲染进程则会维护一个栅格化线程池,专门用于把图块转换成位图数据,位图最终存储于 GPU 显存中(这个会涉及跨进程)。
不过有时候,考虑到浏览器内存上传到 GPU 内存的操作会比较慢(纹理上传)。即使只绘制优先级最高的图块,也会耗费不少的时间。针对这个问题,Chrome 采用了一个策略: 在首次合成图块时只采用一个低分辨率的图片。比如可以是正常分辨率的一半,分辨率减少一半,纹理就减少了四分之三。这样首屏展示的时候只是展示出低分辨率的图片,这个时候继续进行合成操作,当正常的图块内容绘制完毕后,会将当前低分辨率的图块内容替换。这也是 Chrome 底层优化首屏加载速度的一个手段。
# 合成和显示
栅格化操作完成后,合成线程会收集图块的信息,创建合成帧,接着生成一个绘制图块的指令DrawQuad
,并通过 IPC 消息传递给浏览器进程。
浏览器进程里的一个叫viz
的组件,接收这个绘制指令,根据指令将页面内容绘制到内存中,然后再把这部分的内存通过显卡和显示器显示在屏幕上。
涉及问题:
显示器如何显示图像的呢?
每个显示器都有固定的刷新频率,通常是 60HZ,也就是每秒更新 60 张图片,更新的图片都来自于显卡中一个叫前缓冲区的地方,显示器所做的任务很简单,就是每秒固定读取 60 次前缓冲区中的图像,并将读取的图像显示到显示器上。而显卡的职责就是合成新的图像,并将图像保存到后缓冲区中,一旦显卡把合成的图像写到后缓冲区,系统就会让后缓冲区和前缓冲区互换,这样就能保证显示器能读取到最新显卡合成的图像。通常情况下,显卡的更新频率和显示器的刷新频率是一致的。但有时候,在一些复杂的场景中,显卡处理一张图片的速度会变慢,这样就会造成视觉上的卡顿。
基于层合成的页面渲染优化方法有哪些? https://www.cnblogs.com/GarfieldEr007/p/12251511.html
https://www.lizenghai.com/archives/37187.html
# 6.3 关于页面的回流、重绘和合成
# 回流
页面回流,也称页面重排。当修改了 DOM 结构引发 DOM 的几何尺寸变化时就会导致页面发生回流的过程。回流需要更新完整的渲染流水线,所以开销也是最大的。
常见以下操作会触发回流:
(1)DOM 元素的几何属性变化:width
、height
、padding
、margin
、left
、right
等;
(2)DOM 树结构改变,比如删除和新增了某个节点、移动了某个节点;
(3)浏览器窗口大小发生改变;
(4)调用了 window.getComputedStyle
或者 IE 的 currentStyle
;
(5)读写 offset 族、scroll 族和 client 族属性的时候,浏览器为获取这些值,会进行回流操作;
# 重绘
当 DOM 元素的外观样式属性(背景颜色、字体颜色等)发生了变化,就会引发页面的回流。
重绘因没修过元素的几何信息而跳过了布局和分层阶段,直接重新生成绘制列表,然后继续进行分块、生成位图等绘制操作。
也由此可知,重绘不一定导致回流,但回流一定发生重绘。相较于回流,重绘的开销成本会小一些,执行效率会比回流操作高一些。
# 合成
除了回流和重绘,还有一种情况是合成。当只更改不用布局也不用绘制的属性时,比如 CSS3 的 transform、opacity、filter 等,渲染引擎将直接跳过布局和绘制阶段,进入非主线程处理的部分,交由合成线程执行合成操作。也就是前面提及到的硬件加速/GPU 加速。
这样子的好处是:在非主线程上进行合成,不占用主线程的资源;同时也能够借用 GPU 擅长处理位图数据的优势,流畅展示渲染效果。
因此,相较于回流和重绘,合成能大大提高执行效率。
# 针对回流/重绘/合成的开发优化
避免频繁修改 DOM 树结构,可通过
createDocumentfragments
对 DOM 节点进行批量修改;(减少回流)// 尽量避免以下的使用方式 function appendListsToElement(ul, data) { let li; for (let i = 0; i < data.length; i++) { li = document.createElement("li"); li.textContent = "text" + i; ul.appendChild(li); } } const ul = document.getElementById("lists"); appendListsToElement(ul, data); // 取而代之 const ul = document.getElementById("lists"); const fragments = document.createDocumentfragments(); appendListsToElement(fragments, data); ul.append(fragments);
避免频繁读取或修改 DOM 节点的布局信息属性(
offsetWidth
),可通过读取一次,缓存起来进行多次使用;(减少回流)// 尽量避免以下的使用方式 function initP() { for (let i = 0; i < paragraphs.length; i++) { paragraphs[i].style.width = box.offsetWidth + "px"; } } // 取而代之 const boxOffsetWidth = box.offsetWidth; function initP() { for (let i = 0; i < paragraphs.length; i++) { paragraphs[i].style.width = boxOffsetWidth + "px"; } }
避免频繁修改 DOM 样式,可通过
className
对 DOM 节点的样式进行统一修改;(减少重绘)// 尽量避免以下的使用方式 const el = document.getElementById("test"); el.style.padding = "5px"; el.style.borderLeft = "1px"; el.style.borderRight = "2px"; // 取而代之 const el = document.getElementById("test"); el.className += " active";
对于浏览器窗口
resize
、scroll
等进行防抖/节流处理;对于复杂的动画效果,可以根据实际情况考虑使用触发硬件加速(GPU 加速)的条件,比如
will-change: tranform
,让渲染引擎将其单独提升为一个合成层。
利用合成线程去处理这些变换动画,而不占用主线程,大大提高执行效率。善用
opacity
和transform
- 使用 opacity,直观视觉效果就是颜色变淡了,但最终显示的颜色其实仍然可以用 RGB 三个通道来表示。
从数值运算的角度来看,它实际上表示了它采用一般混合策略和其他颜色进行混合时的比例:显示颜色 = 合成色 * opacity + 底色 * (1 - opacity)
。
例如在网页默认的白底色上 rgba(255,255,255)显示一个包含透明度的 rgba(218,89,97,0.8)颜色,那么颜色的 RGB 分量都按照上述公式进行计算就得到 rgb(225,122,128)。
opacity 这个属性本身就是用在重叠部分颜色处理的过程中使用的,对于分层的图原来说就可以看作是与图层内容无关的系数,因为合成过程中当前层中所有像素都需要经历上面的颜色混合公式,所以 opacity 的动画过程既不会影响布局,也不需要重绘。这样图层中保存的 RGB 像素数据的缓存在动画过程中也就不需要更新了。
如果不使用 opacity 属性的话,每一帧对于变化部分都需要手动重计算 RGB 颜色值(这也就相当于是重绘了),因为这些区域的像素颜色一直都在变化,缓存也就没有意义。
transform
属性支持的位移函数 translate( )
,缩放比例函数 scale( )
和旋转函数 rotate( )
都可以转换为线性映射的形式,也就是matrix( )
表示的方式,简单来说就是所有 transform 实现的效果都可以对原坐标系中的点[x,y],按照齐次矩阵进行计算得到变换后的点坐标[x',y']。
transform 在动画过程中也不需要改变缓存的记录,而在图层合成时遍历当前层的点然后用上述公式来计算出对应的新坐标点就可以了,它也可以视作一种与图层内容无关的变换,图层中的元素首次生成的位图信息缓存可以被反复使用。
一段平移动画,如果使用绝对定位 + 改变 left值
的方式来实现,就需要不断计算动画元素的布局并更新它的像素信息。
但如果使用 translate 来实现,动画元素在文档流中的位置并不需要改变,无论后续平移到多远,都可以使用位图缓存中保存的初始位置信息,再加上变换矩阵的影响在层合并时计算出来,同样既不影响布局,也不需要重绘,这就是它高性能的原因。
opacity 和 transform 动画的高性能是由于其数学原理决定了可以使用缓存信息,而并不是因为它被硬件加速了。
更详细可以参考网上看到的一片博文:
高性能 Web 动画和渲染原理系列(3)——transform 和 opacity 为什么高性能 (opens new window) ad
← 👉 移动适配方案问题总结 👉 设计模式 →