如何衡量一个网页的性能
Published in:2024-04-24 | category: 前端 监控

背景

拥有一个良好用户体验的网页对于前端开发同学来说是一件必须做,持续做的一件事情,其中首屏的渲染尤为重要,因为用户与网页产生的交互就是从首屏开始的。接下来将介绍网页性能的几个指标以及如何计算和度量这些指标,通过监控和优化这些性能数据来持续优化网页,以提高用户体验。

Navigation Timing API

我们先来看一组 API,Navigation Timing API 提供了用来衡量一个网站性能的数据指标。相比于我们传统的基础手段使用这组 API 可以获取更有用、更精确的数据还可以做更多的数据统计。
Navigation Timing API 的兼容性也是相当的不错,兼容我们现在常用的浏览器,包括让人讨厌的 IE9+。完整的兼容性表见链接

资源加载过程

当我们的一个资源发起访问(navigationStart)后到资源完成加载(loadEventEnd)经历如下过程:

  1. 首先就是当资源需要重定向的时候会进入 redirect阶段,不需要重定向将会跳过这个阶段
  2. 接着就会检查资源是否有 HTTP 缓存的存在,当资源没有缓存的情况下回进入下一步;
  3. DNS 寻址的过程;
  4. TCP 协议握手建立通信;
  5. 发起请求;
  6. 响应资源;
  7. 对响应的内容进行处理,如:对 DOM 资源加载并解析;
  8. 最后一步完成资源加载。

下面这张图将是对这个过程的标准描述,也是一道前端经典面试题的标准答案,你懂得~

上图是W3C第一版的 `Navigation Timing` 的处理模型(Level 1),从当前浏览器窗口卸载旧页面到加载完成新页面,整个过程一共分为 9 个阶段:
  1. 提示卸载旧文档
  2. 重定向/卸载
  3. 应用缓存
  4. DNS 解析
  5. TCP 握手
  6. HTTP 请求处理
  7. HTTP 响应处理
  8. DOM 处理
  9. 文档装载完成

Level 1 的规范从 2012 年底进入候选建议阶段使用已将近 10 年之久,也算完成了其历史使命,今年的 3 月 20(2022 年 3 月 10 日)正式进入 Level2 的规范( WorkingDraft ),Level2 相比较 Level1 精度更高。

Level2 处理模型如下:


图片来源:https://www.w3.org/TR/navigation-timing-2/

Level2 将整个过程划分为了 11 个阶段,各阶段指标明细:

序号 指标 解释
1 navigationStart 表示从上一个文档卸载结束时的 unix 时间戳,如果没有上一个文档,这个值将和 fetchStart 相等。
2 unloadEventStart 表示前一个网页(与当前页面同域)unload 的时间戳,如果无前一个网页 unload 或者前一个网页与当前页面不同域,则值为 0。
3 unloadEventEnd 返回前一个页面 unload 时间绑定的回掉函数执行完毕的时间戳。
4 redirectStart 第一个 HTTP 重定向发生时的时间。有跳转且是同域名内的重定向才算,否则值为 0。
5 redirectEnd 最后一个 HTTP 重定向完成时的时间。有跳转且是同域名内部的重定向才算,否则值为 0。
6 fetchStart 浏览器准备好使用 HTTP 请求抓取文档的时间,这发生在检查本地缓存之前。
7 domainLookupStartdomainLookupEnd DNS 域名查询开始/结束的时间,如果使用了本地缓存(即无 DNS 查询)或持久连接,则与 fetchStart 值相等。
8 connectStart HTTP(TCP)开始/重新建立连接的时间,如果是持久连接,则与 fetchStart 值相等
9 connectEnd HTTP(TCP)完成建立连接的时间(完成握手),如果是持久接,则与 fetchStart 值相等。
10 secureConnectionStart HTTPS 连接开始的时间,如果不是安全连接,则值为 0。
11 requestStart HTTP 请求读取真实文档开始的时间(完成建立连接),包括从本地读取缓存。
12 responseStart HTTP 开始接收响应的时间(获取到第一个字节),包括从本地读取缓存。
13 responseEnd HTTP 响应全部接收完成的时间(获取到最后一个字节),包括本地读取缓存。
14 domLoading 开始解析渲染 DOM 树的时间,此时 Document..readyState 变为 loading,并将抛出 readystatechange 相关事件。
15 domlnteractive 完成解析 DOM 树的时间,Document..readyState 变为 interactive,并将抛出 readystatechange 相关事件,注意只是 DOM 树解析完成,这时候并没有开始加载网页内的资源。
16 domContentLoadedEventStart DOM 解析完成后,网页内资源加载开始的时间,在 DOMContentLoaded 事件抛出前发生。
17 domContentLoadedEventEnd DOM 解析完成后,网页内资源加载完成的时间(如 JS 脚本加载执行完毕)
18 domComplete DOM 树解析完成,且资源也准备就绪的时间,Document..readyState 变为 complete,并将抛出 readystatechange 相关事件。
19 loadEventStart load 事件发送给文档,也即 load 回调函数开始执行的时间。
20 loadEventEnd load 事件的回调函数执行完毕的时间。

计算页面加载的总时长

Navigation Timing API 为 window 下挂的 Performance 对象增加了 timing 和 navigation 属性,通过window.performance.timing可以得到 **PerformanceTiming**** **接口,我们可以使用接口里面提供的**loadEventEnd**减去**navigationStart\*\*得到页面加载总的时长。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>页面性能</title>
</head>
<body>
<script>
// 书签支持的 js 脚本格式:javascript:<code>
javascript: (() => {
const perfData = window.performance.timing;
const pageLoadTime = perfData.loadEventEnd - perfData.navigationStart;
console.log("页面加载耗时:", pageLoadTime, "ms");
})();
</script>
</body>
</html>


当你在自己的电脑中运行这段代码的时候会发现,输出的耗时居然是负数,那是因为本地服务的访问速度是很快了,所以整个页面的加载时间几乎为 0,我们可以将里面的 JavaScript 脚本保存到你的浏览器书签,在随便找一个网站等待加载完成后点击这个书签就可以在控制台输出正常的页面加载的完整时间了。

下图就是我们通过上述脚本获取到的页面耗时在 6798 毫秒,那么这个数据是否正确呢?其实浏览器的开发者工具也为我们统计了这个数据,可以接着看下面的第二张图的右下角。

下图右下角的时间是浏览器开发者工具为我们统计到的数据:

根据前面的资源加载的流程图我们就可以获取到任意一段有意义的时间,比如:资源请求的耗时我们就可以使用 **responseEnd**** 减去 **requestStart** 得到,DOM 加载的整个时间可以使用 **domComplete** 减去 **domLoading** **得到等。

如何得到更精确的数据

前面我们使用的 API 所提供的数据通过查看均是毫秒级别的,也看得到 API 均被标注的弃用的标识。那么更高精度的数据我们就需要使用一个新的 API 来获取,最终得到一个 **PerformanceNavigationTiming**** **对象,这个对象提供了纳秒级别的数据统计,我们一起来执行下面的代码。

1
2
3
4
5
6
7
<script>
window.onload = function () {
// PerformanceEntryList
const entries = window.performance.getEntries();
console.log(entries);
};
</script>

我们运行上述代码将在控制台得到下面的数组,其中数组的第一项的 typenavigate,这表示我们是通过在浏览器的地址栏输入 URL 打开的页面,也可以是通过点击链接、表单提交、脚本初始化,但不包括刷新页面,回退等,type 的更多描述见 mdn

上图中的 name 属性在这里也需要说明一下,可以看到数据第一项的 name 表示这次资源请求的地址,其他的两个是特殊的标识,其内容取决于**PerformanceEntry**对象的**subtype****PerformanceEntry.entryType**,所以特地摘录了 mdn 上的属性表格,

Value Subtype EntryType 描述
URL PerformanceNavigationTiming frame, navigation 文档的地址
URL PerformanceNavigationTiming resource 请求资源所解析的 URL
DOMString PerformanceMark mark 执行 performance.mark()创建标记时使用的名称
DOMString PerformanceMeasure measure 执行 performance.measure()时使用的名称
DOMString PerformancePaintTiming paint first-paint**first-contentful-paint

我们也可以通过指定 **EntryType**** 来获取对应的信息,下面的代码我们将只会获取到 **first-paint****first-contentful-paint** **两个元素:

1
2
3
4
5
6
<script>
window.onload = function () {
const entries = window.performance.getEntriesByType('paint');
console.log(entries);
};
</script>

常见的性能指标

我们在前面输出**PerformanceNavigationTiming**对象时就已经看到了 namefirst-paintfirst-contentful-paint的对象,这两个就是典型的前端性能指标,其次还有几个,我们用一个表格来看一下:

指标 含义
FP First Paint,首次绘制,指浏览器从开始请求网站到屏幕渲染第一个像素点的时间。
FCP First Contentful Paint,首次内容绘制,指浏览器渲染出第一个内容的时间,这个内容可以是文本、图片、背景图、非空白的画布、SVG 等,并不包括 iframe 元素。
LCP Largest Contentful Paint,最大内容绘制,指网页被展示在视口中的最大内容的显示时间。
FMP First Meaningful Paint,首次有效绘制,指网页渲染出第一个关键内容的时间。区别与 FCP,FMP 指第一块有意义的内容绘制。如:博客网站的关键内容指的是正文,视频网站的关键内容指的是视频播放器,电商网站的关键内容指的是商品列表或商品详情等。因其计算过于复杂,得到的结果并不是非常准确,在 Lighthouse6.0 中用 LCP 替换了 FMP。
DCL DOMContentLoaded,指网页中的 DOM 加载并完成解析后触发此事件, 但不包括样式、图像等。
L load,指网页完成了所有的加载包括 DOM、样式、图像等内容后触发此事件。


下图是在控制台的性能页签抓取的数据:

实时获取性能指标

上述的性能指标都存在一个问题,就是我们只能获取在 **onload**执行前的性能数据,之后变化的数据将无法获取。所以在 HTML5 中为我们新增的 **PerformanceObserver**** **对象,可以使用观察者的模式来拿到每次发生变化后的数据,兼容性见下表:

获取 FP 和 FCP

  1. 实例化 PerformanceObserver 对象;
  2. 在构造函数位置指定处理函数;
  3. 调用 observe 并指定 entryTypes 使其包含 paint
1
2
3
4
5
6
7
8
<script>
const ob = new PerformanceObserver((list) => {
list.getEntries().forEach((entrie) => {
console.log("entrie:", entrie);
});
});
ob.observe({ entryTypes: ["paint"] });
</script>

下图输出的信息就是对应的 fp 和 fcp 的信息了:

获取其他资源的性能数据

  1. 调整 **observe **函数中 **entryTypes **使其包含 **resource **;
  2. 将 ob 的实例化提到其他脚本执行之前;
1
2
3
4
5
6
7
8
9
10
11
12
13
<head>
<meta charset="UTF-8" />
<title>页面性能</title>
<script>
const ob = new PerformanceObserver((list) => {
list.getEntries().forEach((entrie) => {
console.log("entrie:", entrie);
});
});
ob.observe({ entryTypes: ["resource"] });
</script>
<script src="./index.js"></script>
</head>

下图输出的信息就包含了此脚本加载的性能信息:

获取脚本运行到特定位置的性能数据

  1. 再次调整**observe **函数中 **entryTypes **使其包含 **resource **;
  2. 调用 window.performance.mark(“own”);进行打点,own 为自定义标记;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<head>
<meta charset="UTF-8" />
<title>页面性能</title>
<script>
const ob = new PerformanceObserver((list) => {
list.getEntries().forEach((entrie) => {
console.log("entrie:", entrie);
});
});
ob.observe({ entryTypes: ["mark"] });
</script>
<script src="./index.js"></script>
<script>
console.log("↓↓↓↓↓↓↓↓↓↓↓");
window.performance.mark("own");
console.log("↑↑↑↑↑↑↑↑↑↑");
</script>
</head>

下图输出的信息就包含了执行到此标记时的耗时:


计算 DOM 插入的时间:

  1. 编写一个 addDom 函数,内部实现一个 div 插入到 body 的完整过程;
  2. 在 addDom 函数的开始和结束位置分别使用 mark 进行打标记;
  3. 当我们触发页面中的 add dom 按钮的时候就会接收到包含我们自定义标记两个 **PerformanceMark **对象,其中包含的时间相减就可以得到 DOM 插入的时间了。

内容扩展

W3C 标准只是推荐标准,并没有强制执行的能力。但在 Web 标准领域各浏览器厂商对推荐的标准都很重视,积极的响应,但并非所有的标准都能得到适配。下图是一份 W3C 标准制定的流程示意图:

一份标准的制定从**工作草案**提出由公众和会员进行讨论形成**最终工作草案**后并再次进行讨论及修订,修订完毕的最终工作草案将列入**候选推荐标准**,此时的规范处于稳定状态,可以开展实验性的实施,在持续一段进行的验证和修复后就会进入到**建议推荐标准**的阶段,W3C会员将对这份标准进行审阅,如果审阅没有通过将打回到**候选标准推荐**,更严重的将直接打回到最初的**工作草案**阶段或**彻底移除**,审阅通过后W3C总监将宣布该规范为**W3C推荐标准**。

写在最后

在这一篇中我们介绍了几个在网页性能紧相关的几项指标,也介绍了从毫秒到纳秒的性能数据获取的 API 和使用的方式,期间我们也演示了通过浏览器提供的Performance功能,Chrome 浏览器还内置了 Lighthouse 功能来为我们的网页性能打分。我们通常也可以完全应用这两个工具,那么在需要定制一些私有的性能监控平台的时候 API 就可以发挥更大的功能了。

参考资料

https://www.w3.org/TR/navigation-timing/
https://www.w3.org/TR/navigation-timing-2/
https://zhuanlan.zhihu.com/p/82981365

Prev:
如何使用 Docker 提升开发及部署体验
Next:
规则引擎在前端的应用