前端性能面面观
总结了一波前端性能相关的东西, 包括 HTTP, 渲染级别的性能优化等等.
网络级别关键性能指标
延迟
延迟是指 IP 数据包从一个网络端点到另一个网络端点所花费的时间, 与之相关的是往返时延(RTT), 它延迟的时间的两倍. 延迟是制约 Web 性能的主要瓶颈, 尤其对于 HTTP 这样的协议, 因为其中包含大量往返于服务器的请求.
带宽
只要带宽没有饱和, 两个网络端点之间的连接会一次处理尽可能多的数据量. 依据 Web 页面引用资源的大小和网络连接的传输能力, 带宽可能会成为性能的瓶颈.
DNS 查询
在客户端能够获取 Web 页面前, 它需要通过域名系统(DNS)把主机名称转换成 IP 地址获取的 HTML 页面中所引用的各个不同域名也需要转换; 幸运的是, 一个域名只需转换一次.
建立连接时间
在客户端和服务器之间建立连接需要往返数据应答, 称为"三次握手". 握手时间一般与客户端和服务器之间的延迟有关. 握手过程包括客户端向服务器发起一个 SYN 包, 接着服务器返回对应 SYN 的 ACK 响应以及新的 SYN 包, 然后客户端返回对应的 ACK.
TLS 协商时间
如果客户端发起 HTTPS 连接, 它还需要进行传输层安全协议(TLS)协商; TLS 用来取代安全套接层(SSL). 除了服务器和客户端的计算处理耗时之外, TLS 还会造成额外的往返传输.
上面这些, 客户端还没有真正发起 HTTP 请求, 却已经用掉了 DNS 查询的往返时间, 以及 TCP 和 TLS 的耗时. 下面的指标严重依赖于页面内容本身或服务器性能, 而不是网络.
首字节时间(TTFB)
TTFB 是指客户端从开始定位到 Web 页面, 至接收到主体页面响应的第一字节所耗费的时间. 它包含了之前提到的各种耗时, 还要加上服务器处理时间. 对于主体页面上的资源, TTFB 测量的是从浏览器发起请求至收到其第一字节之间的耗时.
内容下载时间
等同于被请求资源的最后字节到达时间(TTLB).
开始渲染时间
表示客户端的屏幕上什么时候开始显示内容. 这个指标测量的是用户看到空白页面的时长.
文档加载完成时间(又叫页面加载时间)
这是客户端浏览器认为页面加载完毕的时间.
当下与未来 Web 页面面临的问题
上面是 Web 性能的核心指标, 而互联网的发展趋势导致现在的网页资源越来越多, 以下几个日益增长的问题:
更多的字节
毫无疑问的是, Web 页面引用的内容每年都在增长, 图片越来越大, JavaScript 和 CSS 也越来越大. 内容体量变大意味着(客户端需要)下载更多的字节, 也意味着更长的页面加载时间.
更多的资源
页面引用的资源不仅变大, 而且数量也增多了. 引用更多的资源会导致总耗时增加, 因为所有的资源都需要获取并解析.
更高的复杂度
随着我们添加更多, 更丰富的功能, Web 页面和所依赖的资源正变得越来越复杂. 复杂度提升, 伴随而来的是计算渲染 Web 页面的时间不断延长, 尤其是在处理能力较弱 的移动设备上.
更多的域名
Web 页面并不是从单一的域名拉取下来的, 大多数 Web 页面会关联数十个域名. 每出现一个新域名都会增加 DNS 查询耗时, 建立连接耗时, 以及 TLS 协商耗时.
更多的 TCP socket
为了应对某些方面的增加, 客户端会对同一个域名开启多个 socket. 这增加了与域名对应的服务器协商建立连接的开销, 也加重了设备负担, 还有可能导致网络连接过载, 引发出错重传和缓存过满, 并降低有效带宽.
网络性能优化最佳实践
- DNS 查询优化: 比如使用 dns-prefetch;
- TCP 优化: 使用 preconnect; 通过使用 CDN, 大幅减少建立新连接的通信延迟; 实施最新的 TLS 最佳实践来优化 HTTPS;
- 避免重定向: 重定向通常触发与额外域名建立连接, 增加耗时. 你可以通过利用 CDN 代替客户端在云端实现重定向; 如果是同一域名的重定向, 使用 Web 服务器上的 rewrite 规则, 避免重定向.
- 客户端缓存(强缓存): 不多说, 看 [HTTP 系列] 第 3 篇 —— HTTP 缓存那些事 即可.
- 条件缓存(协商缓存): 不多说, 看 [HTTP 系列] 第 3 篇 —— HTTP 缓存那些事 即可.
- 网络边缘的缓存: 除了隐私和时间敏感性资源, 只要在多用户间可共享, 并且能够接受一定程度的旧数据, 都可以放到网络边缘的缓存. 哪怕即使仅仅缓存几秒或几分钟, 只要可以接受, 也可以缓存起来, 当需要更新, 手动刷新 CDN 即可.
- 压缩和代码极简化: 源代码的压缩; Web 服务器 gzip 和 deflate, Brotli 的压缩.
- 避免阻塞 CSS/JS: CSS 放
`<haed>`
; JS 放`<body>`
之后. script 标签用好 async, defer. 可以看 关于 script 标签 async 和 defer 属性分析 这篇文章. - 图片优化: 去掉图片元信息, 例如题材地理位置信息, 时间戳, 尺寸和像素信息; 避免图片过载, 也就是需要小图的地方放了个大图, 没必要.
HTTP/2 网络性能优化最佳实践
有一些在 HTTP/1.1 上的优化, 在 HTTP/2 反倒会起到反作用. 下面介绍一下.
雪碧图
在 HTTP/2 中, 针对特定资源的请求不再是阻塞式的, 很多请求可以并行处理; 于是就性能而言, 生成精灵图就失去意义了.
域名拆分(域名分片)
在 HTTP/1.1, 为了利用浏览器针对每个域名开启多个连接, 会将域名进行拆分(sharding). 但在由于 HTTP/2 没有限制并发请求(unlimited concurrent requests), 因此启用 HTTP/2 后, 就没必要再使用域名分片来解决并发限制了.
因此, 在 HTTP/2 上, 尽可能使用更少的域名, 反倒是更好的.
禁用 cookie 的域名
因为 cookie 是会被默认带上的, 在 HTTP/1.1 中, 由于没有头部压缩, 设置禁用 cookie 的域名是个合理的建议; 在在 HTTP/2 上, 由于首部是被压缩, 就无需禁用了.
用户对性能的感知
- 当用户请求一个网站时, 如果在 1 秒内看不到关键内容, 用户会产生任务被中断的感觉.
- 当用户点击某些按钮时, 如果 100ms 内无法响应, 用户会感受到延迟.
- 如果 Web 中的动画没有达到 60fps, 用户会感受到动画的卡顿.
页面生命周期的优化手段
通常一个页面有三个阶段: 加载阶段, 交互阶段和关闭阶段. 我们从这三个角度来分别讨论各自的优化点.
- 加载阶段, 是指从发出请求到渲染出完整页面的过程, 影响到这个阶段的主要因素有网络和 JavaScript 脚本.
- 交互阶段, 主要是从页面加载完成到用户交互的整合过程, 影响到这个阶段的主要因素是 JavaScript 脚本.
- 关闭阶段, 主要是用户发出关闭指令后页面所做的一些清理操作.
加载阶段
对于加载阶段, 抑或说从发起 URL 请求开始, 到首次显示页面的内容, 在视觉上又分为三个阶段:
第一个阶段, 等请求发出去之后, 到提交数据阶段, 这时页面展示出来的还是之前页面的内容. 详细内容仍然可以看[HTTP 系列] 第 6 篇 —— 从输入 URL 回车到页面呈现这篇文章的讲解.
第二个阶段, 提交数据之后渲染进程会创建一个空白页面, 我们通常把这段时间称为解析白屏, 并等待 CSS 文件和 JavaScript 文件的加载完成, 生成 CSSOM 和 DOM, 然后合成布局树, 最后还要经过一系列的步骤准备首次渲染.
第三个阶段, 等首次渲染完成之后, 就开始进入完整页面的生成阶段了, 然后页面会一点点被绘制出来.
影响第一个阶段的因素主要是网络或者是服务器处理这块儿, 这个不多说, 它主要跟服务端的质量有关. 提升服务器性能, 加一些像 redis 这样的缓存, 使用 CDN 等等都是好办法.
影响第二个阶段的主要问题是白屏时间, 如果白屏时间过久, 就会影响到用户体验. 为了缩短白屏时间, 我们来挨个分析这个阶段的主要任务, 包括了解析 HTML, 下载 CSS, 下载 JavaScript, 生成 CSSOM, 执行 JavaScript, 生成布局树, 绘制页面一系列操作. 通常情况下的瓶颈主要体现在下载 CSS 文件, 下载 JavaScript 文件和执行 JavaScript. 所以要想缩短白屏时长, 可以有以下策略:
- 通过内联 JavaScript, 内联 CSS 来移除这两种类型的文件下载, 这样获取到 HTML 文件之后就可以直接开始渲染流程了.
- 但并不是所有的场合都适合内联, 要避免大的内联脚本, 因为在解析 HTML 的过程中, 解析和编译也会占用主线程; 那么还可以尽量减少文件大小, 比如通过 webpack 等工具移除一些不必要的注释, 并压缩 JavaScript 文件.
- 还可以将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 async 或者 defer.
- 对于大的 CSS 文件, 可以通过媒体查询属性, 将其拆分为多个不同用途的 CSS 文件, 这样只有在特定的场景下才会加载特定的 CSS 文件.
我们知道并非所有的资源都会阻塞页面的首次绘制, 比如图片, 音频, 视频等文件就不会阻塞页面的首次渲染; 而 JavaScript, 首次请求的 HTML 资源文件, CSS 文件是会阻塞首次渲染的, 因为在构建 DOM 的过程中需要 HTML 和 JavaScript 文件, 在构造渲染树的过程中需要用到 CSS 文件. 我们把这些能阻塞网页首次渲染的资源称为关键资源.
第一个是关键资源个数. 关键资源个数越多, 首次页面的加载时间就会越长. 比如上图中的关键资源个数就是 3 个, 1 个 HTML 文件, 1 个 JavaScript 和 1 个 CSS 文件.
第二个是关键资源大小. 通常情况下, 所有关键资源的内容越小, 其整个资源的下载时间也就越短, 那么阻塞渲染的时间也就越短. 上图中关键资源的大小分别是 6KB, 8KB 和 9KB, 那么整个关键资源大小就是 23KB.
第三个是请求关键资源需要多少个 RTT(Round Trip Time). 当使用 TCP 协议传输一个文件时, 比如这个文件大小是 0.1M, 由于 TCP 的特性, 这个数据并不是一次传输到服务端的, 而是需要拆分成一个个数据包来回多次进行传输的. RTT 就是这里的往返时延. 它是网络中一个重要的性能指标, 表示从发送端发送数据开始, 到发送端收到来自接收端的确认, 总共经历的时延. 通常 1 个 HTTP 的数据包在 14KB 左右, 所以 1 个 0.1M 的页面就需要拆分成 8 个包来传输了, 也就是说需要 8 个 RTT.
我们可以结合上图来看看它的关键资源请求需要多少个 RTT. 首先是请求 HTML 资源, 大小是 6KB, 小于 14KB, 所以 1 个 RTT 就可以解决了. 至于 JavaScript 和 CSS 文件, 这里需要注意一点, 由于渲染引擎有一个预解析的线程, 在接收到 HTML 数据之后, 预解析线程会快速扫描 HTML 数据中的关键资源, 一旦扫描到了, 会立马发起请求, 你可以认为 JavaScript 和 CSS 是同时发起请求的, 所以它们的请求是重叠的, 那么计算它们的 RTT 时, 只需要计算体积最大的那个数据就可以了. 这里最大的是 CSS 文件(9KB), 所以我们就按照 9KB 来计算, 同样由于 9KB 小于 14KB, 所以 JavaScript 和 CSS 资源也就可以算成 1 个 RTT. 也就是说, 上图中关键资源请求共花费了 2 个 RTT.
因此来说, 在加载阶段, 总的优化原则就是减少关键资源个数, 降低关键资源大小, 降低关键资源的 RTT 次数.
- 如何减少关键资源的个数? 一种方式是可以将 JavaScript 和 CSS 改成内联的形式, 比如上图的 JavaScript 和 CSS, 若都改成内联模式, 那么关键资源的个数就由 3 个减少到了 1 个. 另一种方式, 如果 JavaScript 代码没有 DOM 或者 CSSOM 的操作, 则可以改成 async 或者 defer 属性; 同样对于 CSS, 如果不是在构建页面之前加载的, 则可以添加媒体取消阻止显现的标志. 当 JavaScript 标签加上了 async 或者 defer, CSSlink 属性之前加上了取消阻止显现的标志后, 它们就变成了非关键资源了.
- 如何减少关键资源的大小? 可以压缩 CSS 和 JavaScript 资源, 移除 HTML, CSS, JavaScript 文件中一些注释内容, 也可以通过前面讲的取消 CSS 或者 JavaScript 中关键资源的方式.
- 如何减少关键资源 RTT 的次数? 可以通过减少关键资源的个数和减少关键资源的大小搭配来实现. 除此之外, 还可以使用 CDN 来减少每次 RTT 时长.
交互阶段
谈交互阶段的优化, 其实就是在谈渲染进程渲染帧的速度, 因为在交互阶段, 帧的渲染速度决定了交互的流畅度. 因此交互阶段的页面优化实际上就是讨论渲染引擎是如何渲染帧的, 否则就无法优化帧率. 结合下面这张图片, 我们来一起回顾下交互阶段是如何生成一个帧的. 大部分情况下, 生成一个新的帧都是由 JavaScript 通过修改 DOM 或者 CSSOM 来触发的. 还有另外一部分帧是由 CSS 来触发的.
如果在计算样式阶段发现有布局信息的修改, 那么就会触发重排操作, 然后触发后续渲染流水线的一系列操作, 这个代价是非常大的. 同样如果在计算样式阶段没有发现有布局信息的修改, 只是修改了颜色一类的信息, 那么就不会涉及到布局相关的调整, 所以可以跳过布局阶段, 直接进入绘制阶段, 这个过程叫重绘. 不过重绘阶段的代价也是不小的. 还有另外一种情况, 通过 CSS 实现一些变形, 渐变, 动画等特效, 这是由 CSS 触发的, 并且是在合成线程上执行的, 这个过程称为合成. 因为它不会触发重排或者重绘, 而且合成操作本身的速度就非常快, 所以执行合成是效率最高的方式.
减少 JavaScript 脚本执行时间
首先第一个优化方案是减少 JavaScript 脚本执行时间. 有时 JavaScript 函数的一次执行时间可能有几百毫秒, 这就严重霸占了主线程执行其他渲染任务的时间. 针对这种情况我们可以采用以下两种策略:
- 一种是将一次执行的函数分解为多个任务, 使得每次的执行时间不要过久.
- 另一种是采用 Web Workers, 把一些和 DOM 操作无关且耗时的任务放到 Web Workers 中去执行.
避免强制同步布局
在介绍强制同步布局之前, 我们先来聊聊正常情况下的布局操作. 通过 DOM 接口执行添加元素或者删除元素等操作后, 是需要重新计算样式和布局的, 不过正常情况下这些操作都是在另外的任务中异步完成的, 这样做是为了避免当前的任务占用太长的主线程时间.
<html> <body> <div id="mian_div"> <li id="time_li">time</li> <li>geekbang</li> </div> <p id="demo">强制布局demo</p> <button onclick="foo()">添加新元素</button> <script> function foo() { let main_div = document.getElementById("mian_div"); let new_node = document.createElement("li"); let textnode = document.createTextNode("time.geekbang"); new_node.appendChild(textnode); document.getElementById("mian_div").appendChild(new_node); } </script> </body> </html>
对于上面这段代码, 我们可以使用 Performance 工具来记录添加元素的过程. 从图中可以看出来, 执行 JavaScript 添加元素是在一个任务中执行的, 重新计算样式布局是在另外一个任务中执行, 这就是正常情况下的布局操作.
而所谓强制同步布局, 是指 JavaScript 强制将计算样式和布局操作提前到当前的任务中. 我们对上面的代码做了一点修改. 将新的元素添加到 DOM 之后, 我们又调用了 `main_div.offsetHeight`
来获取新 `main_div`
的高度信息. 如果要获取到 `main_div`
的高度, 就需要重新布局, 所以这里在获取到 `main_div`
的高度之前, JavaScript 还需要强制让渲染引擎默认执行一次布局操作. 从下图可以看出, 计算样式和布局都是在当前脚本执行过程中触发的, 这就是强制同步布局.
function foo() { let main_div = document.getElementById("mian_div"); let new_node = document.createElement("li"); let textnode = document.createTextNode("time.geekbang"); new_node.appendChild(textnode); document.getElementById("mian_div").appendChild(new_node); // 由于要获取到 offsetHeight, // 但是此时的 offsetHeight 还是老的数据, // 所以需要立即执行布局操作 console.log(main_div.offsetHeight); }
我们可以优化下这个例子, 由于查询 `main_div.offsetHeight`
跟它添加了子节点没关系, 所以我们可以在上面就先获取 `main_div`
的 offsetHeight 信息就好了.
function foo() { let main_div = document.getElementById("mian_div"); // 挪到前面 console.log(main_div.offsetHeight); let new_node = document.createElement("li"); let textnode = document.createTextNode("time.geekbang"); new_node.appendChild(textnode); document.getElementById("mian_div").appendChild(new_node); }
避免布局抖动
还有一种比强制同步布局更坏的情况, 那就是布局抖动. 所谓布局抖动, 是指在一次 JavaScript 执行过程中, 多次执行强制布局和抖动操作. 比如下面这个例子, 我们在一个 for 循环语句里面不断读取属性值, 每次读取属性值之前都要进行计算样式和布局, 这会大大影响当前函数的执行效率. 这种情况的避免方式和强制同步布局一样, 都是尽量不要在修改 DOM 结构时再去查询一些相关值.
function foo() { let time_li = document.getElementById("time_li"); for (let i = 0; i < 100; i++) { let main_div = document.getElementById("mian_div"); let new_node = document.createElement("li"); let textnode = document.createTextNode("time.geekbang"); new_node.appendChild(textnode); new_node.offsetHeight = time_li.offsetHeight; document.getElementById("mian_div").appendChild(new_node); } }
合理利用 CSS 合成动画
我们在[HTTP 系列] 第 6 篇 —— 从输入 URL 回车到页面呈现这篇文章讲到了分层, 分块, 光栅化等等. 其实有一些技巧来利用分层技术优化代码.
在写 Web 应用的时候, 你可能经常需要对某个元素做几何形状变换, 透明度变换或者一些缩放操作, 如果使用 JavaScript 来写这些效果, 会牵涉到整个渲染流水线, 所以 JavaScript 的绘制效率会非常低下. 这时你可以使用 will-change 来告诉渲染引擎你会对该元素做一些特效变换, CSS 代码如下:
.box { will-change: transform, opacity; }
这段代码就是提前告诉渲染引擎 box 元素将要做几何变换和透明度变换操作, 这时候渲染引擎会将该元素单独实现一帧, 等这些变换发生时, 渲染引擎会通过合成线程直接去处理变换, 这些变换并没有涉及到主线程, 这样就大大提升了渲染的效率. 这也是 CSS 动画比 JavaScript 动画高效的原因.
所以, 如果涉及到一些可以使用合成线程来处理 CSS 特效或者动画的情况, 就尽量使用 will-change 来提前告诉渲染引擎, 让它为该元素准备独立的层. 但是凡事都有两面性, 每当渲染引擎为一个元素准备一个独立层的时候, 它占用的内存也会大大增加, 因为从层树开始, 后续每个阶段都会多一个层结构, 这些都需要额外的内存, 所以你需要恰当地使用 will-change.
避免频繁的垃圾回收
我们知道 JavaScript 使用了自动垃圾回收机制, 如果在一些函数中频繁创建临时对象, 那么垃圾回收器也会频繁地去执行垃圾回收策略. 这样当垃圾回收操作发生时, 就会占用主线程, 从而影响到其他任务的执行, 严重的话还会让用户产生掉帧, 不流畅的感觉. 所以要尽量避免产生那些临时垃圾数据, 可以尽可能优化储存结构, 尽可能避免小颗粒对象的产生.
Chrome 开发者工具
工欲善其事, 必先利其器. 要想对网页的性能状况有系统的认知, 首先要求我们能够熟练使用 Chrome 开发者工具, Chrome 开发者工具为我们提供了通过界面访问或者编辑 DOM 和 CSSOM 的能力, 还提供了强大的调试功能和查看性能指标的能力. 它一共包含了 10 个功能面板, 包括了 Elements, Console, Sources, NetWork, Performance, Memory, Application, Security, Audits 和 Layers. 下图是这十个 Tab 的具体功能. 我们在本篇文章中着重讲 NetWork 和 Performance.
网络面板
网络面板由控制器, 过滤器, 抓图信息, 时间线, 详细列表和下载信息概要这 6 个区域构成.
控制器
- 红色圆点的按钮, 表示开始 / 暂停抓包.
- 全局搜索按钮, 这个功能就非常重要了, 可以在所有下载资源中搜索相关内容, 还可以快速定位到某几个你想要的文件上.
- Disable cache, 即禁止从 Cache 中加载资源的功能, 它在调试 Web 应用的时候非常有用, 因为开启了 Cache 会影响到网络性能测试的结果.
- Online 按钮, 是模拟 2G/3G功能, 它可以限制带宽, 模拟弱网情况下页面的展现情况, 然后你就可以根据实际展示情况来动态调整策略, 以便让 Web 应用更加适用于这些弱网.
过滤器
网络面板中的过滤器, 主要就是起过滤功能. 因为有时候一个页面有太多内容在详细列表区域中展示了, 而你可能只想查看 JavaScript 文件或者 CSS 文件, 这时候就可以通过过滤器模块来筛选你想要的文件类型.
抓图信息
抓图信息区域可以用来分析用户等待页面加载时间内所看到的内容, 分析用户实际的体验情况. 比如, 如果页面加载 1 秒多之后屏幕截图还是白屏状态, 这时候就需要分析是网络还是代码的问题了. (勾选面板上的 Capture screenshots 即可启用屏幕截图)
时间线
时间线主要用来展示 HTTP, HTTPS, WebSocket 加载的状态和时间的一个关系, 用于直观感受页面的加载过程. 如果是多条竖线堆叠在一起, 那说明这些资源被同时被加载. 至于具体到每个文件的加载信息, 还需要用到下面要讲的详细列表.
详细列表
这个区域是最重要的, 它详细记录了每个资源从发起请求到完成请求这中间所有过程的状态, 以及最终请求完成的数据信息. 通过该列表, 你就能很容易地去诊断一些网络问题.
列表的属性
列表的属性包括 Name, Status, Type, Initiator 等等, 支持拖拽排序.
详细信息
详细信息可以看到请求列表中任意一项的请求行和请求头信息, 还可以查看响应行, 响应头和响应体等等.
单个资源的时间线
了解了每个资源的详细请求信息之后, 我们再来分析单个资源请求时间线, 这就涉及具体的 HTTP 请求流程了. 我们简单回顾一下这个流程: 我们介绍过发起一个 HTTP 请求之后, 浏览器首先查找缓存, 如果缓存没有命中, 那么继续发起 DNS 请求获取 IP 地址, 然后利用 IP 地址和服务器端建立 TCP 连接, 再发送 HTTP 请求, 等待服务器响应; 不过, 如果服务器响应头中包含了重定向的信息, 那么整个流程就需要重新再走一遍. 而这个流程的可视化就通过时间线面板可以很好的展示出来.
第一个是 Queuing, 也就是排队的意思, 当浏览器发起一个请求的时候, 会有很多原因导致该请求不能被立即执行, 而是需要排队等待. 导致请求处于排队状态的原因有很多:
- 首先, 页面中的资源是有优先级的, 比如 CSS, HTML, JavaScript 等都是页面中的核心文件, 所以优先级最高; 而图片, 视频, 音频这类资源就不是核心资源, 优先级就比较低. 通常当后者遇到前者时, 就需要让路, 进入待排队状态.
- 其次, 我们前面也提到过, 浏览器会为每个域名最多维护 6 个 TCP 连接, 如果发起一个 HTTP 请求时, 这 6 个 TCP 连接都处于忙碌状态, 那么这个请求就会处于排队状态.
- 最后, 网络进程在为数据分配磁盘空间时, 新的 HTTP 请求也需要短暂地等待磁盘分配结束.
等待排队完成之后, 就要进入发起连接的状态了. 不过在发起连接之前, 还有一些原因可能导致连接过程被推迟, 这个推迟就表现在面板中的 Stalled 上, 它表示停滞的意思.
如果你使用了代理服务器, 还会增加一个 Proxy Negotiation 阶段, 也就是代理协商阶段, 它表示代理服务器连接协商所用的时间, 不过在上图中没有体现出来, 因为这里我们没有使用代理服务器.
接下来, 就到了 Initial connection/SSL 阶段了, 也就是和服务器建立连接的阶段, 这包括了建立 TCP 连接所花费的时间; 不过如果你使用了 HTTPS 协议, 那么还需要一个额外的 SSL 握手时间, 这个过程主要是用来协商一些加密信息的.
和服务器建立好连接之后, 网络进程会准备请求数据, 并将其发送给网络, 这就是 Request sent 阶段. 通常这个阶段非常快, 因为只需要把浏览器缓冲区的数据发送出去就结束了, 并不需要判断服务器是否接收到了, 所以这个时间通常不到 1 毫秒.
数据发送出去了, 接下来就是等待接收服务器第一个字节的数据, 这个阶段称为 Waiting (TTFB), 通常也称为第一字节时间. TTFB 是反映服务端响应速度的重要指标, 对服务器来说, TTFB 时间越短, 就说明服务器响应越快.
接收到第一个字节之后, 进入陆续接收完整数据的阶段, 也就是 Content Download 阶段, 这意味着从第一字节时间到接收到全部响应数据所用的时间.
优化时间线上耗时项
下面我们针对 Timing 的每个项目针对性地进行优化.
排队时间过久
排队时间过久, 大概率是由浏览器为每个域名最多维护 6 个连接导致的, 在 HTTP/1 的时候可以使用域名分片技术, 即将网络资源分散到多个域名下, 就可以减少单域名的连接数量. 当然你升到 HTTP/2 之后就无需 care 这件事情了.
TTFB 时间过久
TTFB 时间过久大概率由以下几个原因构成:
- 服务器生成页面数据的时间过久. 对于动态网页来说, 服务器收到用户打开一个页面的请求时, 首先要从数据库中读取该页面需要的数据, 然后把这些数据传入到模板中, 模板渲染后, 再返回给用户. 服务器在处理这个数据的过程中, 可能某个环节会出问题.
- 网络的原因. 比如使用了低带宽的服务器, 或者本来用的是电信的服务器, 可联通的网络用户要来访问你的服务器, 这样也会拖慢网速.
- 发送请求头时带上了多余的用户信息. 比如一些不必要的 Cookie 信息, 服务器接收到这些 Cookie 信息之后可能需要对每一项都做处理, 这样就加大了服务器的处理时长.
面对第一种服务器的问题, 你可以想办法去提高服务器的处理速度, 比如通过增加各种缓存的技术; 针对第二种网络问题, 你可以使用 CDN 来缓存一些静态文件; 至于第三种, 你在发送请求时就去尽可能地减少一些不必要的 Cookie 数据信息.
Content Download 时间过久
如果单个请求的 Content Download 花费了大量时间, 有可能是字节数太多的原因导致的. 这时候你就需要减少文件大小, 比如压缩, 去掉源码中不必要的注释等方法.
下载信息概要
一般关注 DOMContentLoaded 和 Load 两个事件.
- DOMContentLoaded 这个事件发生后, 说明页面已经构建好 DOM 了, 这意味着构建 DOM 所需要的 HTML 文件, JavaScript 文件, CSS 文件都已经下载完成了.
- Load 事件说明浏览器已经加载了所有的资源(图像, 样式表等).
性能指标
- FP(First Paint) 首次绘制, 即渲染进程拿到下载好的 HTML 数据开始进行 DOM 解析
- FCP(First Contentful Paint) 首次绘制内容
- FMP(First Meaningful Paint) 首次有意义的绘制
- TBT(Total Blocking Time) 第一次有内容的绘制(FCP)和交互(TTI)之间的总时间
- TTI(Time To Interactive) 可交互时间: 应用在视觉上都已渲染出了, 完全可以响应用户的输入
- FCI(First CPU Idle) 首次 CPU 空闲时间: 代表着一个网页已经满足了最小程度的与用户发生交互行为的时刻
- FPS(Frames Per Second) 每秒帧率
- TTFB(Time to first byte) 发出页面请求到接收到应答数据第一个字节所花费的毫秒数
- LCP(Largest Contentful Paint) 最大内容绘制, 即首屏内容完全绘制完成时, 应小于 2.5s
- FID(First Input Delay) 首次输入延迟
- CLS(Cumulative Layout Shift) CLS 度量在页面的整个生命周期中发生的每个意外布局更改的所有单独布局更改得分的总和
performance.timing
let times = {}; let t = window.performance.timing; // 重定向时间 times.redirectTime = t.redirectEnd - t.redirectStart; // DNS 查询耗时 times.dnsTime = t.domainLookupEnd - t.domainLookupStart; // TTFB 读取页面第一个字节的时间 times.ttfbTime = t.responseStart - t.navigationStart; // DNS 缓存时间 times.appcacheTime = t.domainLookupStart - t.fetchStart; // 卸载页面的时间 times.unloadTime = t.unloadEventEnd - t.unloadEventStart; // TCP 连接耗时 times.tcpTime = t.connectEnd - t.connectStart; // request 请求耗时 times.reqTime = t.responseEnd - t.responseStart; // 解析 DOM 树耗时 times.analysisTime = t.domComplete - t.domInteractive; // 白屏时间 times.blankTime = t.domLoading - t.navigationStart; // domReadyTime times.domReadyTime = t.domContentLoadedEventEnd - t.fetchStart;
PWA
普通网页的缺陷:
- 首先, Web 应用缺少离线使用能力, 在离线或者在弱网环境下基本上是无法使用的. 而用户需要的是沉浸式的体验, 在离线或者弱网环境下能够流畅地使用是用户对一个应用的基本要求.
- 其次, Web 应用还缺少了消息推送的能力, 因为作为一个 App 厂商, 需要有将消息送达到应用的能力.
- 最后, Web 应用缺少一级入口, 也就是将 Web 应用安装到桌面, 在需要的时候直接从桌面打开 Web 应用, 而不是每次都需要通过浏览器来打开.
针对以上 Web 缺陷, PWA 提出了两种解决方案: 通过引入 Service Worker 来试着解决离线存储和消息推送的问题, 通过引入 manifest.json 来解决一级入口的问题.
Service Worker 的主要思想是在页面和网络之间增加一个拦截器, 用来缓存和拦截请求. 在没有安装 Service Worker 之前, WebApp 都是直接通过网络模块来请求资源的. 安装了 Service Worker 模块之后, WebApp 请求资源时, 会先通过 Service Worker, 让它判断是返回 Service Worker 缓存的资源还是重新去网络请求资源. 一切的控制权都交由 Service Worker 来处理.
我们知道 JavaScript 和页面渲染流水线的任务都是在页面主线程上执行的, 如果一段 JavaScript 执行时间过久, 那么就会阻塞主线程, 使得渲染一帧的时间变长, 从而让用户产生卡顿的感觉.
为了避免 JavaScript 过多占用页面主线程时长的情况, 浏览器实现了 Web Worker 的功能. Web Worker 的目的是让 JavaScript 能够运行在页面主线程之外, 不过由于 Web Worker 中是没有当前页面的 DOM 环境的, 所以在 Web Worker 中只能执行一些和 DOM 无关的 JavaScript 脚本, 并通过 postMessage 方法将执行的结果返回给主线程. 所以说在 Chrome 中, Web Worker 其实就是在渲染进程中开启的一个新线程, 它的生命周期是和页面关联的.
不过 Web Worker 是临时的, 每次 JavaScript 脚本执行完成之后都会退出, 执行结果也不能保存下来, 如果下次还有同样的操作, 就还得重新来一遍. 所以 Service Worker 需要在 Web Worker 的基础之上加上储存功能.
另外, 由于 Service Worker 还需要会为多个页面提供服务, 所以还不能把 Service Worker 和单个页面绑定起来. 在目前的 Chrome 架构中, Service Worker 是运行在浏览器进程中的, 因为浏览器进程生命周期是最长的, 所以在浏览器的生命周期内, 能够为所有的页面提供服务.
图片选型
JPEG(Joint Photographic Experts Group)
JPEG(联合图像专家小组), 是一种针对彩色照片而广泛使用的有损压缩图形格式, 它是一种栅格图形.
它适合颜色丰富的照片、彩色图, 大焦点图、通栏 banner 图; 结构不规则的图. 但它不适合线条图形和文字、图标图形, 并且不支持透明度.
PNG(Portable Network Graphics)
PNG(便携式网络图形)是一种无损压缩的栅格图形格式, 支持索引、灰度、RGB 三种颜色方案以及 Alpha 通道等特性. 最高支持 24 位彩色图像(PNG-24)和 8 位灰度图像(PNG-8).
它适合纯色、透明、线条绘图, 图标; 边缘清晰、有大块相同颜色区域; 颜色数较少, 但需要半透明. 由于是无损压缩, 对于颜色丰富的大图不友好.
GIF(Graphics Interchange Format)
GIF(图像互换格式)是一种栅格图形文件格式, 以 8 位色(即 256 种颜色)重现真彩色的图像, 支持完全透明和完全不透明, 采用 LZW 压缩算法进行编码.
它适合一些动画, 动态图标. 但由于是个像素只有 8 比特, 不适合存储彩色图片.
Webp
Webp 可为图像提供无损压缩和有损压缩, 优秀算法能同时保证一定程序上的图像质量和比较小的体积; 可以插入多帧, 实现动画效果; 可以设置透明度; 采用 8 位压缩算法. 无损的 Webp 比 PNG 小 26%, 有损的 Webp 比 JPEG 小 25-34%, 比 GIF 有更好的动画.
图片加载策略
图片懒加载
图片出现在视口时再加载.
响应式图片
<picture> <source media="(max-width: 799px)" srcset="elva-480w-close-portrait.jpg" /> <source media="(min-width: 800px)" srcset="elva-800w.jpg" /> <img src="elva-800w.jpg" alt="Chris standing up holding his daughter Elva" /> </picture>
逐步加载图像
- 使用统一占位符
- 使用 LQIP(Low Quality Image Placeholders), 低质量图像占位符
- 使用 SQIP(SVG Quality Image Placeholders), SVG 图像占位符
CSS 优化
CSS 样式文件链接尽量放在页面头部. CSS 加载不会阻塞 DOM tree 解析, 但是会阻塞 DOM Tree 渲染, 也会阻塞后面 JS 执行. 任何 body 元素之前, 可以确保在文档部分中解析了所有 CSS 样式(内联和外联), 从而减少了浏览器必须重排文档的次数. 如果放置页面底部, 就要等待最后一个 CSS 文件下载完成, 此时会出现"白屏", 影响用户体验.
总结
PREVIOUS POST
浏览器架构的前生今世
NEXT POST
[HTTP 系列] 第 5 篇 —— 网络安全