《HTTP/2 基础教程》 读书笔记

28次阅读

共计 19516 个字符,预计需要花费 49 分钟才能阅读完成。

最近粗线了不少 HTTP2 相关的帖子和讨论,感觉新一轮的潮流在形成,所以最近找了本 HTTP2 相关书籍做知识储备,刚好记成笔记以备后询 ~
这本书本身不错,缺点就是翻译的有点蹩脚,另外因为是 2017 年出的书,所以有些内容时效性不太好,比如关于 Chrome 的部分,所以我根据 Chrome 的官方文档增加了点内容 ????
1. HTTP 进化史
1.1 HTTP/0.9、HTTP/1.0、HTTP/1.1

HTTP/0.9:是个相当简单的协议。它只有一个方法(GET),没有首部,其设计目标也无非是获取 HTML(也就是说没有图片,只有文本)。

HTTP/1.0:多了很多功能,首部、错误码、重定向、条件请求等,但仍存在很多瑕疵,尤其是不能让多个请求共用一个连接、缺少强制的 Host 首部、缓存的选择也相当简陋,这三点影响了 Web 可扩展的方式。

HTTP/1.1:增加了缓存相关首部的扩展、OPTIONS 方法、Upgrade 首部、Range 请求、压缩和传输编码、管道化等功能。因为强制要求客户端提供 Host 首部,所以虚拟主机托管成为可能,也就是在一个 IP 上提供多个 Web 服务。另外使用了 keep-alive 之后,Web 服务器也不需要在每个响应之后关闭连接。这对于提升性能和效率而言意义重大,因为浏览器再也不用为每个请求重新发起 TCP 连接了。

1.2 HTTP/2
HTTP2 被希望达到以下特性:

相比 HTTP/1.1,性能显著提高;
解决 HTTP 中的队头阻塞问题;
并行的实现机制不依赖与服务器建立多个连接,从而提升 TCP 连接的利用率,特别是在拥塞控制方面;
保留 HTTP/1.1 的语义,可以利用已有的文档资源,包括(但不限于)HTTP 方法、状态码、URI 和首部字段;
明确定义 HTTP/2.0 和 HTTP/1.x 交互的方法,特别是通过中介时的方法(双向);
明确指出它们可以被合理使用的新的扩展点和策略。

2. HTTP/2 快速入门
2.1 启动并运行
很多网站已经在用 HTTP/2(h2)了,比如 Facebook、Instagram、Twitter 等,下面介绍以下如何自己搭建 h2 服务器。要运行 h2 服务器,主要分两步:

获取并安装一个支持 h2 的 Web 服务器
下载并安装一张 TLS 证书,让浏览器和服务器通过 h2 连接

2.2 获取证书
证书可以通过三种方式获取:

使用在线资源
自己创建一张证书
从数字证书认证机构(CA)申请一张证书

前两个方法 将创建自签名证书,仅用于测试,由于不是 CA 签发的,浏览器会报警
后面关于创建 h2 服务器的步骤就不记了,可以百度下
3. Web 优化『黑魔法』的动机与方式
3.1 当前的性能挑战
3.1.1 剖析 Web 页面请求
从用户在浏览器中点击链接到页面呈现在屏幕上,在此期间到底发生了什么?浏览器请求 Web 页面时,会执行重复流程,获取在屏幕上绘制页面需要的所有信息。为了更容易理解,我们把这一过程分成两部分:资源获取、页面解析 / 渲染。
资源请求流程图:

流程为:

把待请求 URL 放入队列
解析 URL 中域名的 IP 地址(A)
建立与目标主机的 TCP 连接(B)
如果是 HTTPS 请求,初始化并完成 TLS 握手(C)
向页面对应的 URL 发送请求。

资源响应流程图:

接收响应
如果(接收的)是主体 HTML,那么解析它,并针对页面中的资源触发优先获取机制(A)
如果页面上的关键资源已经接收到,就开始渲染页面(B)
接收其他资源,继续解析渲染,直到结束(C)

页面上的每一次点击,都需要重复执行前面那些流程,给网络带宽和设备资源带来压力。Web 性能优化的的核心,就是加快甚至干脆去掉其中的某些步骤。
3.1.2 关键性能指标
下面网络级别的性能指标,它会影响整个 Web 页面加载。

延迟:指 IP 数据包从一个网络端点到另一个网络端点所花费的时间。

带宽:只要带宽没有饱和,两个网络端点之间的连接会一次处理尽可能多的数据量。

DNS 查询:在客户端能够获取 Web 页面前,它需要通过域名系统(DNS)把主机名称转换成 IP 地址。

建立连接时间:在客户端和服务器之间建立连接需要三次握手。握手时间一般与客户端和服务器之间的延迟有关。

TLS 协商时间:如果客户端发起 HTTPS 连接,它还需要进行传输层安全协议(TLS)协商,TLS 会造成额外的往返传输。

首字节时间(TTFB):TTFB 是指客户端从开始定位到 Web 页面,至接收到主体页面响应的第一字节所耗费的时间。它包含了之前提到的各种耗时,还要加上服务器处理时间。对于主体页面上的资源,TTFB 测量的是从浏览器发起请求至收到其第一字节之间的耗时。

内容下载时间:等同于被请求资源的最后字节到达时间(TTLB)。

开始渲染时间:客户端的屏幕上什么时候开始显示内容?这个指标测量的是用户看到空白页面的时长。

文档加载完成时间:这是客户端浏览器认为页面加载完毕的时间。

3.1.3 HTTP/1 的问题
HTTP/1 的问题自然是 HTTP/2 要解决的核心问题
1. 队头阻塞
浏览器很少只从一个域名获取一份资源,一般希望能同时获取许多资源。h1 有个特性叫管道化(pipelining),允许一次发送一组请求,但是只能按照发送顺序依次接收响应。管道化备受互操作性和部署的各种问题的困扰,基本没有实用价值。在请求应答过程中,如果出现任何状况,剩下所有的工作都会被阻塞在那次请求应答之后。这就是『队头阻塞』,它会阻碍网络传输和 Web 页面渲染,直至失去响应。为了防止这种问题,现代浏览器会针对单个域名开启 6 个连接,通过各个连接分别发送请求。它实现了某种程度上的并行,但是每个连接仍会受到的影响。
2. 低效的 TCP 利用
传输控制协议(TCP)的设计思路是:对假设情况很保守,并能够公平对待同一网络的不同流量的应用。涉及的核心概念就是拥塞窗口(congestion window)。『拥塞窗口』是指,在接收方确认数据包之前,发送方可以发出的 TCP 包的数量。例如,如果拥塞窗口指定为 1,那么发送方发出 1 个数据包之后,只有接收方确认了那个包,才能发送下一个。
TCP 有个概念叫慢启动(Slow Start),它用来探索当前连接对应拥塞窗口的合适大小。慢启动的设计目标是为了让新连接搞清楚当前网络状况,避免给已经拥堵的网络继续添乱。它允许发送者在收到每个确认回复后额外发送 1 个未确认包。这意味着新连接在收到 1 个确认回复之后,可以发送 2 个数据包;在收到 2 个确认回复之后,可以发 4 个,以此类推。这种几何级数增长很快到达协议规定的发包数上限,这时候连接将进入拥塞避免阶段。

这种机制需要几次往返数据请求才能得知最佳拥塞窗口大小。但在解决性能问题时,就这区区几次数据往返也是非常宝贵的时间成本。如果你把一个数据包设置为最大值下限 1460 字节,那么只能先发送 5840 字节(假定拥塞窗口为 4),然后就需要等待接收确认回复。理想情况下,这需要大约 9 次往返请求来传输完整个页面。除此之外,浏览器一般会针对同一个域名开启 6 个并发连接,每个连接都免不了拥塞窗口调节。
传统 TCP 实现利用拥塞控制算法会根据数据包的丢失来反馈调整。如果数据包确认丢失了,算法就会缩小拥塞窗口。这就类似于我们在黑暗的房间摸索,如果腿碰到了桌子就会马上换个方向。如果遇到超时,也就是等待的回复没有按时抵达,它甚至会彻底重置拥塞窗口并重新进入慢启动阶段。新的算法会把其他因素也考虑进来,例如延迟,以提供更妥善的反馈机制。
前面提到过,因为 h1 并不支持多路复用,所以浏览器一般会针对指定域名开启 6 个并发连接。这意味着拥塞窗口波动也会并行发生 6 次。TCP 协议保证那些连接都能正常工作,但是不能保证它们的性能是最优的。
3. 臃肿的消息首部
虽然 h1 提供了压缩被请求内容的机制,但是消息首部却无法压缩。消息首部可不能忽略,尽管它比响应资源小很多,但它可能占据请求的绝大部分(有时候可能是全部)。如果算上 cookie,就更大了。
消息首部压缩的缺失也容易导致客户端到达带宽上限,对于低带宽或高拥堵的链路尤其如此。『体育馆效应』(Stadium Effect)就是一个经典例子。如果成千上万人同一时间出现在同一地点(例如重大体育赛事),会迅速耗尽无线蜂窝网络带宽。这时候,如果能压缩请求首部,把请求变得更小,就能够缓解带宽压力,降低系统的总负载。
4. 受限的优先级设置
如果浏览器针对指定域名开启了多个 socket(每个都会受队头阻塞问题的困扰),开始请求资源,这时候浏览器能指定优先级的方式是有限的:要么发起请求,要么不发起。然而 Web 页面上某些资源会比另一些更重要,这必然会加重资源的排队效应。这是因为浏览器为了先请求优先级高的资源,会推迟请求其他资源。但是优先级高的资源获取之后,在处理的过程中,浏览器并不会发起新的资源请求,所以服务器无法利用这段时间发送优先级低的资源,总的页面下载时间因此延长了。还会出现这样的情况:一个高优先级资源被浏览器发现,但是受制于浏览器处理的方式,它被排在了一个正在获取的低优先级资源之后。
5. 第三方资源
如今 Web 页面上请求的很多资源完全独立于站点服务器的控制,我们称这些为第三方资源。现代 Web 页面加载时长中往往有一半消耗在第三方资源上。虽然有很多技巧能把第三方资源对页面性能的影响降到最低,但是很多第三方资源都不在 Web 开发者的控制范围内,所以很可能其中有些资源的性能很差,会延迟甚至阻塞页面渲染。令人扫兴的是,h2 对此也束手无策。
3.2 Web 性能优化技术
2010 年,谷歌把 Web 性能作为影响页面搜索评分的重要因素之一,性能指标开始在搜索引擎中发挥作用。对于很多 Web 页面,浏览器的大块时间并不是用于呈现来自网站的主体内容(通常是 HTML),而是在请求所有资源并渲染页面。
因此,Web 开发者逐渐更多地关注通过减少客户端网络延迟和优化页面渲染性能来提升 Web 性能。
3.2.1 Web 性能的最佳实践
1. DNS 查询优化
在与服务主机建立连接之前,需要先解析域名;那么,解析越快就越好。下面有一些方法:

限制不同域名的数量。当然,这通常不是你能控制的
保证低限度的解析延迟。了解你的 DNS 服务基础设施的结构,然后从你的最终用户分布的所有地域定期监控解析时间
在 HTML 或响应中利用 DNS 预取指令。这样,在下载并处理 HTML 的同时,预取指令就能开始解析页面上指定的域名

<link rel=”dns-prefetch” href=”//ajax.googleapis.com”>
2. 优化 TCP 连接
本章前面提到过,开启新连接是一个耗时的过程。如果连接使用 TLS(也确实应该这么做),开销会更高。降低这种开销的方法如下

尽早终止并响应。借助 CDN,在距离请求用户很近的边缘端点上,请求就可以获得响应,所以可以终止连接,大幅减少建立新连接的通信延迟。
实施最新的 TLS 最佳实践来优化 HTTPS。
利用 preconnect 指令,连接在使用之前就已经建立好了,这样处理流程的关键路径上就不必考虑连接时间了,preconnect 不光会解析 DNS,还会建立 TCP 握手连接和 TLS 协议(如果需要)

<link rel=”preconnect” href=”//fonts.example.com” crossorigin>
如果要从同一个域名请求大量资源,浏览器将自动开启到服务器的并发连接,避免资源获取瓶颈。虽然现在大部分浏览器支持 6 个或更多的并发连接数目,但你不能直接控制浏览器针对同一域名的并发连接数。
3. 避免重定向
重定向通常触发与额外域名建立连接。在移动网络中,一次额外的重定向可能把延迟增加数百毫秒,这不利于用户体验,并最终会影响到网站上的业务。简单的解决方案就是彻底消灭重定向,因为对于重定向的使往往并没有合理原因。如果它们不能被直接消灭,你还有两个选择:

利用 CDN 代替客户端在云端实现重定向
如果是同一域名的重定向,使用 Web 服务器上的 rewrite 规则,避免重定向

4. 客户端缓存
没有什么比直接从本地缓存获取资源来得更快,因为它根本就不需要建立网络连接。

所谓的纯静态内容,例如图片或带版本的数据,可以在客户端永久缓存。即便 TTL 被设置得很长,比如一个月,它还是会因为缓存提早回收或清理而过期,这时客户端可能不得不从源头再次获取。
CSS/JS 和个性化资源,缓存时间大约是会话(交互)平均时间的两倍。这段时间足够长,保证大多数用户在浏览网站时能够从本地拉取资源;同时也足够短,几乎能保证下次会话时从网络上拉取最新内容。

可以通过 HTTP 首部指定 cache control 以及键 max-age(以秒为单位),或者 expires 首部。
5. 网络边缘的缓存
个人信息(用户偏好、财务数据等)绝对不能在网络边缘缓存,因为它们不能共享。时间敏感的资源也不应该缓存,例如实时交易系统上的股票报价。这就是说,除此之外其他一切都是可以缓存的,即使仅仅缓存几秒或几分钟。对于那些不是经常更新,然而一旦有变化就必须立刻更新的资源,例如重大新闻,可以利用各大 CDN 厂商提供的缓存清理(purging)机制处理。这种模式被称为『一直保留,直到被通知』(Hold til Told),意思是永久缓存这些资源,等收到通知后才删除。
6. 条件缓存
如果缓存 TTL 过期,客户端会向服务器发起请求。在多数情况下,收到的响应其实和缓存的版本是一样的,重新下载已经在缓存里的内容也是一种浪费。HTTP 提供条件请求机制,客户端能以有效方式询问服务器:『如果内容变了,请返回内容本身;否则,直接告诉我内容没变。』当资源不经常变化时,使用条件请求可以显著节省带宽和性能;但是,保证资源的最新版迅速可用也是非常重要的。使用条件缓存可以通过以下方法。

在请求中包含 HTTP 首部 Last-Modified-Since。仅当最新内容在首部中指定的日期之后被更新过,服务器才返回完整内容;否则只返回 304 响应码,并在响应首部中附带上新的时间戳 Date 字段。
在请求体中包含实体校验码,或者叫 ETag;它唯一标识所请求的资源。ETag 由服务器 提供,内嵌于资源的响应首部中。服务器会比较当前 ETag 与请求首部中收到的 ETag,如果一致,就只返回 304 响应码;否则返回完整内容。

一般来说,大多数 Web 服务器会对图片和 CSS/JS 使用这些技术,但你也可以将其用到其他资源。
7. 压缩和代码极简化
所有的文本内容(HTML、JS、CSS、SVG、XML、JSON、字体等),都可以压缩和极简化。这两种方法组合起来,可以显著减少资源大小。更少字节数对应着更少的请求与应答,也就意味着更短的请求时间。
极简化(minification,混淆)是指从文本资源中剥离所有非核心内容的过程。通常,要考虑方便人类阅读和维护,而浏览器并不关心可读性,放弃代码可读性反而能节省空间。在极简化的基础上,压缩可以进一步减少字节数。它通过可无损还原的算法减少资源大小。在发送资源之前,如果服务器进行压缩处理,可以节省 90% 的大小。
8. 避免阻塞 CSS/JS
在屏幕上绘制第一个像素之前,浏览器必须确保 CSS 已经下载完整。尽管浏览器的预处理器很智能,会尽早请求整个页面所需要的 CSS,但是把 CSS 资源请求放在页面靠前仍然是种最佳实践,具体位置是在文档的 head 标签里,而且要在任何 JS 或图片被请求和处理之前。
默认情况下,如果在 HTML 中定位了 JS,它就会被请求、解析,然后执行。在浏览器处理完这个 JS 之前,会阻止其后任何资源的下载渲染。然而大多数时候,这种默认的阻塞行为导致了不必要的延迟,甚至会造成单点故障。为了减轻 JS 阻塞带来的潜在影响,下面针对己方资源(你能控制的)和第三方资源(你不能控制的)推荐了不同的策略

定期校验这些资源的使用情况。随着时间的变迁,Web 页面可能会持续下载一些不再需要的 JS,这时最好去掉它。

如果 JS 执行顺序无关紧要,并且必须在 onload 事件触发之前运行,那么可以设置 async 属性,像这样:
<script async src=”/js/myfile.js”>
只需做到下载 JS 与解析 HTML 并行,就能极大地提升整体用户体验。慎用 document.write 指令,因为很可能中断页面执行,所以需要仔细测试。

如果 JS 执行顺序很重要,并且你也能承受脚本在 DOM 加载完之后运行,那么请使用 defer 属性。像这样
<script defer src=”/js/myjs.js”>

对不会影响到页面初次展示的 JS 脚本,必须在 onload 事件触发之后请求(处理)它。
如果你不想延迟主页面的 onload 事件,可以考虑通过 iframe 获取 JS,因为它的处理独立于主页面。但是,通过 iframe 下载的 JS 访问不了主页面上的元素。

9. 图片优化
对大多数网站而言,图片的重要性和比重在不断增加。既然图片主导了多数现代网站,优化它们就能够获得最大的性能回报。所有图片优化手段的目标都是在达到指定视觉质量的前提下传输最少的字节。

图片元信息,例如题材地理位置信息、时间戳、尺寸和像素信息,通常包含在二进制数据里,应该在发送给客户端之前去掉(务必保留版权和色彩描述信息)。这种无损处理能够在图片生成时完成。对于 PNG 图片,一般会节省大概 10% 的空间。
图片过载(image overloading)是指,图片最终被浏览器自动缩小,要么因为原始尺寸超过了浏览器可视区中的占位大小,要么因为像素超过设备的显示能力。这不仅浪费带宽,消耗的 CPU 资源也很可观,这些计算资源有时在手持设备上相当宝贵。想要解决图片过载,可以使用技术手段,针对用户的设备、网络状况和预期的视觉质量,提供裁剪过的图片(就尺寸和质量而言)。

3.2.2 反模式
HTTP/2 对每个域名只会开启一个连接,所以 HTTP/1.1 下的一些诀窍对它来说只会适得其反。
详细看 6.7 节
3.3 小结
HTTP/1.1 孕育了各种性能优化手段与诀窍,可以帮助我们深入理解 Web 及其内部实现。HTTP/2 的目标之一就是淘汰掉众多(并不是全部)此类诀窍。
4. HTTP/2 迁移
在升级到 HTTP/2 之前,你应该考虑:

浏览器对 h2 的支持情况
迁移到 TLS(HTTPS)的可能性
对你的网站做基于 h2 的优化(可能对 h1 有反作用)
网站上的第三方资源
保持对低版本客户端的兼容

4.1 浏览器的支持情况
任何不支持 h2 的客户端都将简单地退回到 h1,并仍然可以访问你的站点基础设施。

4.2 迁移到 TLS
所有主流浏览器只能访问基于 TLS(即 HTTPS 请求)的 h2。
4.3 撤销针对 HTTP/1.1 的优化
Web 开发者之前花费了大量心血来充分使用 h1,并且已经总结了一些诀窍,例如资源合并、域名拆分、极简化、禁用 cookie 的域名、生成精灵图,等等。所以,当得知这些实践中有些在 h2 下变成反模式时,你可能会感到吃惊。例如,资源合并(把很多 CSS/JS 文件拼合成一个)能避免浏览器发出多个请求。对 h1 而言这很重要,因为发起请求的代价很高;但是在 h2 的世界里,这部分已经做了深度优化。放弃资源合并的结果可能是,针对单个资源发起请求的代价很低,但浏览器端可以进行更细粒度的缓存。
详细看 6.7 节
5. HTTP/2 协议
本章将全面探讨 HTTP/2 的底层工作原理,深入到数据层传输的帧及其通信方式。
5.1 HTTP/2 分层
HTTP/2 大致可以分为两部分

分帧层 即 h2 多路复用能力的核心部分,主要目的还是传输 HTTP

数据或 http 层 其中包含传统上被认为是 HTTP 及其关联数据的部分,向后兼容 HTTP/1.1

h2 有些特点需要关注一下:

二进制协议:h2 的分帧层是基于帧的二进制协议,这方便了机器解析,但是肉眼识别比较困难

首部压缩:仅使用二进制协议还不够,h2 的首部还会被深度压缩。这将显著减少传输中的冗余字节

多路复用:在调试工具里查看基于 h2 传输的连接的时候,你会发现请求和响应交织在一起

加密传输:线上传输的绝大部分数据是加密过的,所以在中途读取会更加困难

5.2 连接
与完全无状态的 h1 不同的是,h2 把它所承载的帧(frame)和流(stream)共同依赖的连接层元素捆绑在一起,其中既包含连接层设置也包含首部表。也就是说,与之前的 HTTP 版本不同,每个 h2 连接都有一定的开销。之所以这么设计,是考虑到收益远远超过其开销。
在连接的时候,HTTP/2 提供两种协议发现的机制:

在连接不加密的情况下,客户端会利用 Upgrade 首部来表明期望使用 h2。如果服务器也可以支持 h2,它会返回一个 101 Switching Protocols(协议转换)响应。这增加了一轮完整的请求 - 响应通信
如果连接基于 TLS,情况就不同了。客户端在 ClientHello 消息中设置 ALPN(Application-Layer Protocol Negotiation,应用层协议协商)扩展来表明期望使用 h2 协议,服务器用同样的方式回复。如果使用这种方式,那么 h2 在创建 TLS 握手的过程中完成协商,不需要多余的网络通信。

在协议制定过程中,很早就把小数点去掉了,这表明未来的 HTTP 版本不能保证语义的向后兼容,也就是说只有 HTTP/2 没有 HTTP/2.0、HTTP/2.2
5.3 帧
HTTP/2 是基于帧(frame)的协议,采用分帧是为了将重要信息都封装起来,让协议的解析方可以轻松阅读、解析并还原信息。相比之下,h1 不是基于帧的,而是以文本分隔。所以解析 h1 的请求或响应可能会出现以下问题:

一次只能处理一个请求或响应,完成之前不能停止解析
无法预判解析需要多少内存。这会带来一系列问题:你要把一行读到多大的缓冲区里;如果行太长会发生什么;应该增加并重新分配内存,还是返回 400 错误

从另一方面来说,有了帧,处理协议的程序就能预先知道会收到什么。下图是一个 HTTP/2 帧的结构

前 9 个字节对于每个帧是一致的。解析时只需要读取这些字节,就可以准确地知道在整个帧中期望的字节数。其中每个字段的说明如下

名称
长度
描述

Length
3 字节
表示帧负载的长度(取值范围为 2^14~2^24-1 字节)。请注意,214 字节是默认的最大帧大小,如果需要更大的帧,必须在 SETTINGS 帧中设置

Type
1 字节
当前帧类型

Flags
1 字节
具体帧类型的标识

R
1 位
保留位,不要设置,否则可能带来严重后果

Stream Identifier
31 位
每个流的唯一 ID

Frame Payload
长度可变
真实的帧内容,长度是在 Length 字段中设置的

相比依靠分隔符的 h1,h2 还有另一大优势:如果使用 h1 的话,你需要发送完上一个请求或者响应,才能发送下一个;由于 h2 是分帧的,请求和响应可以交错甚至多路复用。多路复用有助于解决类似队头阻塞的问题。
h2 有十种不同的帧类型:

名称
ID (Type)
描述

DATA
0x0
数据帧,传输流的核心内容

HEADERS
0x1
报头帧,包含 HTTP 首部,和可选的优先级参数

PRIORITY
0x2
优先级帧,指示或者更改流的优先级和依赖

RST_STREAM
0x3
流终止帧,允许一端停止流(通常由于错误导致的)

SETTINGS
0x4
设置帧,协商连接级参数

PUSH_PROMISE
0x5
推送帧,提示客户端,服务器要推送些东西

PING
0x6
PING 帧,测试连接可用性和往返时延(RTT)

GOAWAY
0x7
GOAWAY 帧,告诉另一端,当前端已结束

WINDOW_UPDATE
0x8
窗口更新帧,协商一端将要接收多少字节(用于流量控制)

CONTINUATION
0x9
延续帧,用以扩展 HEADER 数据块

扩展帧:HTTP/2 内置了名为扩展帧的处理新的帧类型的能力。依靠这种机制,客户端和服务器的实现者可以实验新的帧类型,而无需制定新协议。按照规范,任何客户端不能识别的帧都会被丢弃,所以网络上新出现的帧就不会影响核心协议。当然,如果你的应用程序依赖于新的帧,而中间代理会丢弃它,那么可能会出现问题。
5.4 流
HTTP/2 规范中的流(stream):HTTP/2 连接上独立的、双向的帧序列交换。你可以将流看作在连接上的一系列帧,用来传输一对请求 / 响应消息。如果客户端想要发出请求,它会开启一个新的流,服务器也在这个流上回复。这与 h1 的请求、响应流程类似,区别在于,因为有分帧,所以多个请求和响应可以交错,而不会互相阻塞。流 ID(帧首部的第 6~9 字节)用来标识帧所属的流。
客户端到服务器的 h2 连接建立之后,通过发送 HEADERS 帧来启动新的流,如果首部需要跨多个帧,可能还发会送 CONTINUATION 帧。
5.4.1 消息
HTTP 消息泛指 HTTP 请求或响应。一个消息至少由 HEADERS 帧(用来初始化流)组成,并且可以另外包含 CONTINUATION 和 DATA 帧,以及其他的 HEADERS 帧。下图是普通 GET 请求的示例流程

POST 和 GET 的主要差别之一就是 POST 请求通常包含客户端发出的大量数据。下图是 POST 消息对应的各帧可能的样子

h1 的请求和响应都分成消息首部和消息体两部分;与之类似,h2 的请求和响应分成 HEADERS 帧和 DATA 帧。HTTP/1 和 HTTP/2 消息的下列差别是需要注意

一切都是 header:h1 把消息分成请求 / 状态行、首部两部分。h2 取消了这种区分,并把这些行变成了魔法伪首部

没有分块编码(chunked encoding):只有在无法预先知道数据长度的情况下向对方发送数据时,才会用到分块。在使用帧作为核心协议的 h2 里,不再需要分块

不再有 101 的响应:Switching Protocol 响应是 h1 的边缘应用。它如今最常见的应用可能就是用以升级到 WebSocket 连接。ALPN 提供了更明确的协议协商路径,往返的开销也更小

5.4.2 流量控制
h2 的新特性之一是基于流的流量控制。h1 中只要客户端可以处理,服务端就会尽可能快地发送数据,h2 提供了客户端调整传输速度的能力,服务端也同样可以调整传输的速度。
WINDOW_UPDATE 帧用于执行流量控制功能,可以作用在单独某个流上(指定具体 Stream Identifier)也可以作用整个连接(Stream Identifier 为 0x0),只有 DATA 帧受流量控制影响。初始化流量窗口后,发送多少负载,流量窗口就减少多少,如果流量窗口不足就无法发送,WINDOW_UPDATE 帧可以增加流量窗口大小。流建立的时候,窗口大小默认 65535(2^16-1)字节。
5.4.3 优先级
流的最后一个重要特性是依赖关系。
现代浏览器会尽量以最优的顺序获取资源,由此来优化页面性能。在没有多路复用的时候,在它可以发出对新对象的请求之前,需要等待前一个响应完成。有了 h2 多路复用的能力,客户端就可以一次发出所有资源的请求,服务端也可以立即着手处理这些请求。由此带来的问题是,浏览器失去了在 h1 时代默认的资源请求优先级策略。假设服务器同时接收到了 100 个请求,也没有标识哪个更重要,那么它将几乎同时发送每个资源,次要元素就会影响到关键元素的传输。
h2 通过流的依赖关系来解决上面这个问题。通过 HEADERS 帧和 PRIORITY 帧,客户端可以明确地和服务端沟通它需要什么,以及它需要这些资源的顺序。这是通过声明依赖关系树和树里的相对权重实现的。

依赖关系 为客户端提供了一种能力,通过指明某些对象对另一些对象有依赖,告知服务器这些对象应该优先传输

权重 让客户端告诉服务器如何确定具有共同依赖关系的对象的优先级

流可以被标记为依赖其他流,所依赖的流完成后再处理当前流。每个依赖 (dependency) 后都跟着一个权重 (weight),这一数字是用来确定依赖于相同的流的可分配可用资源的相对比例。
其他时候也可以通过 PRIORITY 帧调整流优先级。
设置优先级的目的是为了让端点表达它所期望对端在并发的多个流之间如何分配资源的行为。更重要的是,当发送容量有限时,可以使用优先级来选择用于发送帧的流。
5.5 服务端推送
升单个对象性能的最佳方式,就是在它被用到之前就放到浏览器的缓存里面。这正是 h2 服务端推送的目的。
5.5.1 推送对象
如果服务器决定要推送一个对象(RFC 中称为『推送响应』),会构造一个 PUSH_PROMISE 帧。这个帧有很多重要属性:

PUSH_PROMISE 帧首部中的流 ID(Promised Stream ID)用来响应相关联的请求。推送的响应一定会对应到客户端已发送的某个请求。如果浏览器请求一个主体 HTML 页面,如果要推送此页面使用的某个 JavaScript 对象,服务器将使用请求对应的流 ID 构造 PUSH_PROMISE 帧。

PUSH_PROMISE 帧的首部块与客户端请求推送对象时发送的首部块是相似的。所以客户端有办法放心检查将要发送的请求。
被发送的对象必须确保是可缓存的。

:method 首部的值必须确保安全。安全的方法就是幂等的那些方法,这是一种不改变任何状态的好办法。例如,GET 请求被认为是幂等的,因为它通常只是获取对象,而 POST 请求被认为是非幂等的,因为它可能会改变服务器端的状态。
理想情况下,PUSH_PROMISE 帧应该更早发送,应当早于客户端接收到可能承载着推送对象的 DATA 帧。假设服务器要在发送 PUSH_PROMISE 之前发送完整的 HTML,那客户端可能在接收到 PUSH_PROMISE 之前已经发出了对这个资源的请求。h2 足够健壮,可以优雅地解决这类问题,但还是会有些浪费。

PUSH_PROMISE 帧会指示将要发送的响应所使用的流 ID

如果客户端对 PUSH_PROMISE 的任何元素不满意,就可以按照拒收原因选择重置这个流(使用 RST_STREAM),或者发送 PROTOCOL_ERROR(在 GOAWAY 帧中)。常见的情况是缓存中已经有了这个对象。
假设客户端不拒收推送,服务端会继续进行推送流程,用 PUSH_PROMISE 中指明 ID 对应的流来发送对象

5.5.2 选择要推送的资源
如果服务器接收到一个页面的请求,它需要决定是推送页面上的资源还是等客户端来请求。决策的过程需要考虑到如下方面

资源已经在浏览器缓存中的概率
从客户端看来,这些资源的优先级 (参见 5.4.3 节)
可用的带宽,以及其他类似的会影响客户端接收推送的资源

如果服务器选择正确,那就真的有助于提升页面的整体性能,反之则会损耗页面性能。
5.6 首部压缩
现代网页平均有很多请求,这些请求之间几乎没有新的的内容,这是极大的浪费。
首部列表 (Header List) 是零个或多个首部字段 (Header Field) 的集合。当通过连接传送时,首部列表通过压缩算法(即下文 HPACK) 序列化成首部块 (Header Block),不用 GZIP 是因为它有泄漏加密信息的风险。HPACK 是种表查找压缩方案,它利用霍夫曼编码获得接近 GZIP 的压缩率。
然后,序列化的首部块又被划分成一个或多个叫做首部块片段 (Header Block Fragment) 的字节序列,并通过 HEADERS、PUSH_PROMISE,或者 CONTINUATION 帧进行有效负载传送。
假设客户端按顺序发送如下请求首部:
Header1: foo
Header2: bar
Header3: bat
当客户端发送请求时,可以在首部数据块中指示特定首部及其应该被索引的值。它会创建一张表:

索引
首部名称

62
Header1
foo

63
Header2
bar

64
Header3
bat

如果服务端读到了这些请求首部,它会照样创建一张表。客户端发送下一个请求的时候,如果首部相同,它可以直接发送:62 63 64,服务器会查找先前的表格,并把这些数字还原成索引对应的完整首部。首部压缩机制中每个连接都维护了自己的状态。
HPACK 的实现比这个要复杂得多,比如:

请求端和响应端各维护了两张表格。其中之一是动态表,创建方法和上面差不 多。另一张是静态表,它由 61 个最常见的首部的键值组合而成。例如 :method: GET 在静态表中索引为 2。按规定,静态表包含 61 个条目,所以上例索引编号从 62 开始。

关于字段如何索引,有很多控制规则:

发送索引编号和文本值仅发送文本值,不对它们进行索引(对于一次性或敏感首部)
发送索引的首部名,值用文本表示,但不进行索引处理(如:path: /foo.html,其值每次都不同)
发送索引过的首部名和值(如上例中的第二个请求)

使用打包方案的整数压缩,以实现极高的空间效率
利用霍夫曼编码表进一步压缩字符串

5.7 线上传输
线上传输的 h2 信息是经过压缩的二进制数据。
一个简单的 GET 请求
下面是一个简单的 h2 的 get 请求
:authority: www.akamai.com
:method: GET
:path: /
:scheme: https
accept: text/html,application/xhtml+xml,…
accept-language: en-US,en;q=0.8
cookie: sidebar_collapsed=0; _mkto_trk=…
upgrade-insecure-requests: 1
user-agent: Mozilla/5.0 (Macintosh;…
下面是 h2 的一个响应
:status: 200
cache-control: max-age=600
content-encoding: gzip
content-type: text/html;charset=UTF-8
date: Tue, 31 May 2016 23:38:47 GMT
etag: “08c024491eb772547850bf157abb6c430-gzip”
expires: Tue, 31 May 2016 23:48:47 GMT
link: <https://c.go-mpulse.net>;rel=preconnect
set-cookie: ak_bmsc=8DEA673F92AC…
vary: Accept-Encoding, User-Agent
x-akamai-transformed: 9c 237807 0 pmb=mRUM,1
x-frame-options: SAMEORIGIN
在这个响应中,服务器表示请求已成功受理(状态码 200),设置了 cookie(cookie 首部),表示返回的内容使用 gzip 压缩(content-encoding 首部)
6. HTTP/ 2 性能
HTTP/2 大部分情况下传输 web 页面比 HTTP/1.1 快。

对于包含很多小型资源的页面,h2 加载页面的时间比 h1 更短。这是因为 h1 下 (有 6 个 TCP 连接) 服务器只能并行发送 6 个资源 (由于队头阻塞),而 h2 下多个流(stream) 可以共用连接。进一步说,随着网络条件变差,h1 和 h2 下页面加载时间 (PLT) 都会增加;但是 h2 由于单连接,如果唯一的连接发生了丢包,所有工作都会受影响。
对于包含少量大型资源的页面,在所有网络条件下,h1 性能上都比 h2 表现要好。这个多少令人感到意外的结果是初始拥塞窗口导致的。如果开启 6 个连接,h1 的初始拥塞窗口大小实际上是 h2 的 6 倍。因此在会话开始阶段,h2 的连接窗口尚未增长到最佳值,但 h1 早就能更快地传输更多数据了。这个问题目前仍在解决,因为它导致初始拥塞窗口对 h2 而言太小,然而对 h1 而言又太大。此外,h2 比 h1 更容易受丢包的影响。
对于包含一些极大型资源的 Web 页面,两者没有任何差异。h2 的初始拥塞窗口劣势被整体下载时长掩盖了,多路复用此时也不再具有优势。

6.1 客户端实现
网络条件相同,使用不同浏览器客户端,同样的网站页面加载性能可能差别很大。

协议的具体实现很重要
并非所有请求在任何情况下都会从 HTTP/2 受益,即便如此,URL 使用 h2 后性能提升的比例也依旧高于下降的比例

6.2 延迟
延迟是指数据包从一个端点到另一个端点所花的时间。有时,它也表示数据包到达接收方然后返回发送方所需的时间,又称为往返时延(RTT),长度一般以毫秒计。
影响延迟的因素众多,但有两个是最重要的:端点间的距离,以及所用传输介质
两点之间的网线不会是笔直的,另外各种网关、路由器、交换机以及移动基站等 (也包括服务器应用本身) 都会增加延迟
6.3 丢包
如果网络中传输的数据包没有成功到达目的地,就会发生丢包,这通常是由网络拥堵造成的。
频繁丢包会影响 h2 的页面传输,主要是因为 h2 开启单一 TCP 连接,每次有丢包 / 拥堵时,TCP 协议就会缩减 TCP 窗口。
如果 TCP 流上丢了一个数据包,那么整个 h2 连接都会停顿下来,直到该数据包重发并被接收到。
6.4 服务端推送
服务端推送让服务器具备了在客户端请求之前就推送资源的能力。测试表明,如果合理使用推送,页面渲染时间可以减少 20%~50%。
然而,推送也会浪费带宽,这是因为服务端可能试图推送那些在客户端已经缓存的资源,导致客户端收到并不需要的数据。客户端确实可以发送 RST_STREAM 帧来拒绝服务器的 PUSH_PROMISE 帧,但是 RST_STREAM 并不会即刻到达,所以服务器还是会发送一些多余的信息。
如果用户第一次访问页面时,就能向客户端推送页面渲染所需的关键 CSS 和 JS 资源,那么服务端推送的真正价值就实现了。不过,这要求服务器端实现足够智能,以避免『推送承诺』(push promise)与主体 HTML 页面传输竞争带宽。
理想情况下,服务端正在处理 HTML 页面主体请求时才会发起推送。有时候,服务端需要做一些后台工作来生成 HTML 页面。这时候服务端在忙,客户端却在等待,这正是开始向客户端推送所需资源的绝佳时机。

6.5 首字节时间
首字节时间 (TTFB) 用于测量服务器的响应能力。是从客户端发起 HTTP 请求到客户端浏览器收到资源的第一个字节所经历的时间。由 socket 连接时间、发送 HTTP 请求所需时间、收到页面第一个字节所需时间组成。
h1 中,客户端针对单个域名在每个连接上依次请求资源,而且服务器会按序发送这些资源。客户端只有接收了之前请求的资源,才会再请求剩下的资源,服务器接着继续响应新的资源请求。这个过程会一直重复,直到客户端接收完渲染页面所需的全部资源。
与 h1 不同,通过 h2 的多路复用,客户端一旦加载了 HTML,就会向服务器并行发送大量请求。相比 h1,这些请求获得响应的时间之和一般会更短;但是因为是请求是同时发出的,而单个请求的计时起点更早,所以 h2 统计到的 TTFB 值会更高。
HTTP/2 比 h1 确实做了更多的工作,其目的就是为了从总体上提升性能。下面是一些 h1 没有,但 h2 实现了的

窗口大小调节
依赖树构建
维持首部信息的静态 / 动态表
压缩 / 解压缩首部
优先级调整(h2 允许客户端多次调整单一请求的优先级)
预先推送客户端尚未请求的数据流

下图是使用 h1 和 h2 加载同一个页面的加载时序对比,总体来说 h2 体验更好

6.6 第三方资源
许多网站会使用各种统计、跟踪、社交以及广告平台,就会引入各种第三方的资源。

第三方请求往往通过不同域名发送;由于浏览器需要解析 DNS、建立 TCP 连接、协商 TLS,这将严重影响性能;
因为第三方资源在不同域名下,所以请求不能从服务端推送、资源依赖、请求优先级等 h2 特性中受益。这些特性仅是为请求相同域名下的资源设计的;
你无法控制第三方资源的性能,也无法决定它们是否会通过 h2 传输;

6.7 HTTP/ 2 反模式
h1 下的一些性能调优办法在 h2 下会起到反作用。下面列出了一些用于优化 h1 请求的常用技巧,并标注了 h2 方面的考虑。

名称
描述
备注

资源合并
把多个文件(JavaScript、CSS)合成一个文件,以减少 HTTP 请求
在 HTTP/2 下这并非必要,因为请求的传输字节数和时间成本更低,虽然这种成本仍然存在

极简化
去除 HTML、JavaScript、CSS 这类文件中无用的代码
很棒的做法,在 HTTP/2 下也要保留

域名拆分
把资源分布到不同的域名上面去,让浏览器利用更多的 socket 连接
HTTP/2 的设计意图是充分利用单个 socket 连接,而拆分域名会违背这种意图。建议取消域名拆分,但请注意本表格之后的附注框会介绍这个问题相关的各种复杂情况

禁用 cookie 的域名
为图片之类的资源建立单独的域名,这些域名不用 cookie,以尽可能减少请求尺寸
应该避免为这些资源单独设立域名(参见域名拆分),但更重要的是,由于 HTTP/2 提供了首部压缩,cookie 的开销会显著降低

生成精灵图
把多张图片拼合为一个文件,使用 CSS 控制在 Web 页面上展示的部分
与极简化类似,只不过用 CSS 实现这种效果的代价高昂;不推荐在 HTTP/2 中使用

6.7.1 生成精灵图和资源合并 / 内联
精灵图(spriting)是指把很多小图片拼合成一张大图,这样只需发起一个请求就可以覆盖多个图片元素。在 HTTP/2 中,针对特定资源的请求不再是阻塞式的,很多请求可以并行处理;就性能而言,生成精灵图已失去意义,因为多路复用和首部压缩去掉了大量的请求开销。
与之类似,小的文本资源,例如 JS 和 CSS,会依照惯例合并成一份更大的资源,或者直接内嵌在主体 HTML 中,这也是为了减少客户端 - 服务器连接数。这种做法有个问题是,那些小的 CSS 或 JS 自身也许可缓存,但如果它们内嵌在不可缓存的 HTML 中的话,当然也就不可缓存了。把很多小的 JS 脚本合并成一个大文件可能仍旧对 h2 有意义,因为这样可以更好地压缩处理并节省 CPU。
6.7.2 域名拆分
域名拆分(sharding)是为了利用浏览器针对每个域名开启多个连接的能力来并行下载资源。对于包含大量小型资源的网站,普遍的做法是拆分域名,以利用现代浏览器针能对每个域名开启 6 个连接的特性,充分利用可用带宽。
因为 HTTP/2 采取多路复用,所以域名拆分就不是必要的了,并且反而会让协议力图实现的目标落空。比较好的办法就是继续保持当前的域名拆分,但是确保这些域名共享同一张证书 [通配符 / 存储区域网络(SAN)],并保持服务器 IP 地址和端口相同,以便从浏览器网络归并(network coalescence)中收益,这样可以节省为单个域名连接建立的时间。
6.7.3 禁用 cookie 的域名
在 HTTP/1 下,请求和响应首部从不会被压缩。随着时间推移,首部大小已经增长了,超过单个 TCP 数据包的 cookie 可以说司空见惯。因此,在内容源和客户端之间来回传输首部信息的开销可能造成明显的延迟。
因此,对图片之类不依赖于 cookie 的资源,设置禁用 cookie 的域名是个合理的建议。
但是 HTTP/2 中,首部是被压缩的,并且客户端和服务器都会保留『首部历史』,避免重复传输已知信息。所以,如果你要重构站点,大可不必考虑禁用 cookie 的域名,这样能减少很多包袱。
静态资源也应该从同一域名提供;使用与主页面 HTTP 相同的域名,消除了额外的 DNS 查询以及(潜在的)socket 连接,它们都会减慢静态资源的获取。把阻塞渲染的资源放在同样的域名下,也可以提升性能。
6.7.4 资源预取
资源预取也是一项 Web 性能优化手段,它提示浏览器只要有可能就继续下载可缓存资源,并把这些资源缓存起来。尽管如此,如果浏览器很忙,或者资源下载花的时间太 长,预取请求将会被忽略。资源预取可以在 HTML 中插入 link 标签实现:
<link rel=”prefetch” href=”/important.css”>
也可以使用 HTTP 响应中的 Link 首部: Link: </important.css>; rel=prefetch
资源预取与 h2 引入的服务端推送并没多少关联。服务端推送用于让资源更快到达浏览器,而资源预取相比推送的优点之一是,如果资源已经在缓存里,浏览器就不会浪费时间和带宽重复请求它。所以,可以把它看作 h2 推送的补充工具,而不是将被替代的特性。
6.8 现实情况中的性能
网络丢包是 h2 的命门,一次丢包机会就会让它的所有优化泡汤。
7. HTTP/2 实现
7.1 桌面 Web 浏览器
所有浏览器在进行 HTTP/2 传输时都需要使用 TLS(HTTPS),即使事实上 HTTP/2 规范本身并没有强制要求 TLS。这个原因是:

从之前对 WebSocket 和 SPDY 的实验看来,使用 Upgrade 首部,通过 80 端口 (明文的 HTTP 端口) 通信时,通信链路上代理服务器的中断等因素会导致非常高的错误率。如果基于 443 端口 (HTTPS 端口) 上的 TLS 发起请求,错误率会显著降低,并且协议通信也更简洁。
人们越来越相信,考虑到安全和隐私,一切都应该被加密。HTTP/2 被视为一次推动全网加密通信发展的机会。

7.1.2 禁用 HTTP/2
HTTP/2 毕竟是新鲜事物,现在很多浏览器都支持启用或禁用 h2。
7.1.3 支持 HTTP/2 服务端推送
服务端推送是 h2 中最令人兴奋也最难正确使用的特性之一,现在所有的主流浏览器都已经支持了此特性。
7.1.4 连接归并
如果需要建立一个新连接,而浏览器支持连接归并,那么通过复用之前已经存在的连接,就能够提升请求性能。这意味着可以跳过 TCP 和 TLS 的握手过程,改善首次请求新域名的性能。如果浏览器支持连接归并,它会在开启新连接之前先检查是否已经建立了到相同目的地的连接。
相同目的地具体指的是:已经存在连接,其证书对新域名有效,此域名可以被解析成那个连接对应的 IP 地址。如果上述条件都满足,那么浏览器会在已建立的连接上向该域名发起 HTTP/2 请求。
7.2 服务器、代理以及缓存
如果要通过 h2 传输内容,我们有几个选择。支持 HTTP/2 的网络设施大致有以下两类。
Web 服务器:通常所说的提供静态和动态内容服务的程序。
代理 / 缓存:一般处在服务器和最终用户之间,可以提供缓存以减轻服务器负载,或进行额外加工。许多代理也能扮演 Web 服务器的角色。
在选择 HTTP/2 服务器时,我们需要检查、评估一些关键点。除了基本的通用性能、操作系统支持、学习曲线、可扩展性以及稳定性,还应当关注 Web 请求的依赖项和优先级,以及对服务端推送的支持。
7.3 内容分发网络 CDN
内容分发网络 (CDN) 是反向代理服务器的全球性分布式网络,它部署在多个数据中心。CDN 的目标是通过缩短与最终用户的距离来减少请求往返次数,以此为最终用户提供高可用、高性能的内容服务。
大多数主流 CDN 是支持 HTTP/2 的,选择 CDN 时主要考虑的两点是:对服务端推送的支持,以及它们处理优先级的方式。这两点对现实世界中的 Web 性能影响重大。
8. HTTP/ 2 调试
8.1 chrome devtools 可视化
Chrome 开发者工具中的 Network 栏,有助于简单直观地跟踪客户端和服务端的通讯,它 按下面表格的形式展示了若干信息:

资源名
资源大小
状态码
优先级
总加载时间
使用时间线方式分解加载时间

打开 devtools 的 Network 栏,鼠标放在瀑布流 Waterfall 的资源上,就会看到资源加载过程中各个阶段的详细时间

Connection Setup (连接设置)

Queueing:请求被渲染引擎或者网络层延迟的时间,浏览器在以下情况下对请求排队

存在更高优先级的请求。
此源已打开六个 TCP 连接,达到限值。仅适用于 HTTP/1.0 和 HTTP/1.1
浏览器正在短暂分配磁盘缓存中的空间

Connection Start (开始连接阶段)

Stalled:请求可能会因 Queueing 中描述的任何原因而停止

Proxy negotiation:浏览器与代理服务器协商请求花费的时间

DNS Lookup:浏览器解析请求的 IP 地址花费的时间

Request/Response (请求 / 响应)

Request Sent:发送请求包含的数据花费的时间

Waiting (TTFB):等待初始响应花费的时间,也就是所说的首字节时间;这个数字包括等待服务器传输响应的时间,以及往返服务器的延迟

Content Download:接收响应的数据所花费的时间

Explanation (总时间)

其他

ServiceWorker Preparation:浏览器正在启动 Service Worker

Request to ServiceWorker:正在将请求发送到 Service Worker

Receiving Push:浏览器正在通过 HTTP/2 服务器推送接收此响应的数据

Reading Push:浏览器正在读取之前收到的本地数据

9. 展望未来
HTTP/2 的弱点之一就是依赖主流 TCP 实现。在 3.1.3 节中已经讨论过,TCP 连接受制于 TCP 慢启动、拥塞规避,以及不合理的丢包处理机制。用单个链接承载页面涉及的所有资源请求,就能享受多路复用带来的好处;然而面对 TCP 层级的队首阻塞时,我们还是束手无策。所以 Google 开发的 QUIC 采纳了 HTTP/2 的优点,并且避免了这些缺点。

推介阅读:
HTTP2 详解 | Wangriyu’s Blog

PS:欢迎大家关注我的公众号【前端下午茶】,一起加油吧~

正文完
 0