写作背景:
拥有一个良好用户体验的网页对于前端开发同学来说是一件必须做,持续做的一件事情,其中首屏的渲染尤为重要,因为用户与网页产生的交互就是从首屏开始的。接下来将介绍网页性能的几个指标以及如何计算和度量这些指标,通过监控和优化这些性能数据来持续优化网页,以提高用户体验。
Navigation Timing API:
我们先来看一组 API,Navigation Timing API 提供了用来衡量一个网站性能的数据指标。相比于我们传统的基础手段使用这组 API 可以获取更有用、更精确的数据还可以做更多的数据统计。
Navigation Timing API 的兼容性也是相当的不错,兼容我们现在常用的浏览器,包括让人讨厌的 IE9+。完整的兼容性表见链接:
资源加载过程:
当我们的一个资源发起访问(navigationStart)后到资源完成加载(loadEventEnd)经历如下过程:
- ① 首先就是当资源需要重定向的时候会进入 redirect阶段,不需要重定向将会跳过这个阶段;
- ② 接着就会检查资源是否有HTTP缓存的存在,当资源没有缓存的情况下回进入下一步;
- ③ DNS 寻址的过程;
- ④ TCP 协议握手建立通信;
- ⑤ 发起请求;
- ⑥ 响应资源;
- ⑦ 对响应的内容进行处理,如:对 DOM 资源加载并解析;
- ⑧ 最后一步完成资源加载。
下面这张图将是对这个过程的标准描述,也是一道前端经典面试题的标准答案,你懂得~
[
](https://www.w3.org/TR/navigat...)
上图是W3C第一版的 Navigation Timing 的处理模型(Level 1),从当前浏览器窗口卸载旧页面到加载完成新页面,整个过程一共分为 9 个阶段:
- 提示卸载旧文档
- 重定向/卸载
- 应用缓存
- DNS 解析
- TCP 握手
- HTTP 请求处理
- HTTP 响应处理
- DOM 处理
- 文档装载完成
Level 1 的规范从2012 年底进入候选建议阶段使用已将近10年之久,也算完成了其历史使命,今年的3月20(2022年3月10日)正式进入Level2的规范( WorkingDraft ),Level2相比较Level1精度更高。
Level2处理模型如下:
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得到页面加载总的时长。
<!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对象,这个对象提供了纳秒级别的数据统计,我们一起来执行下面的代码。
<script>
window.onload = function () {
// PerformanceEntryList
const entries = window.performance.getEntries();
console.log(entries);
};
</script>
我们运行上述代码将在控制台得到下面的数组,其中数组的第一项的 type为 navigate,这表示我们是通过在浏览器的地址栏输入 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 两个元素:
<script>
window.onload = function () {
const entries = window.performance.getEntriesByType('paint');
console.log(entries);
};
</script>
常见的性能指标:
我们在前面输出PerformanceNavigationTiming对象时就已经看到了 name 非 first-paint 和 first-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 和 fpc:
- 实例化 PerformanceObserver 对象;
- 在构造函数位置指定处理函数;
- 调用 observe 并指定 entryTypes 使其包含 paint;
<script>
const ob = new PerformanceObserver((list) => {
list.getEntries().forEach((entrie) => {
console.log("entrie:", entrie);
});
});
ob.observe({ entryTypes: ["paint"] });
</script>
下图输出的信息就是对应的 fp 和 fcp 的信息了:
获取其他资源的性能数据:
- 调整 observe 函数中 entryTypes 使其包含 resource;
- 将 ob 的实例化提到其他脚本执行之前;
<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>
下图输出的信息就包含了此脚本加载的性能信息:
获取脚本运行到特定位置的性能数据:
- 再次调整observe 函数中 entryTypes 使其包含 resource;
- 调用
window.performance.mark("own");
进行打点,own 为自定义标记;
<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 插入的时间:
- 编写一个 addDom 函数,内部实现一个 div 插入到 body 的完整过程;
- 在 addDom 函数的开始和结束位置分别使用 mark 进行打标记;
- 当我们触发页面中的 add dom 按钮的时候就会接收到包含我们自定义标记两个 PerformanceMark 对象,其中包含的时间相减就可以得到DOM 插入的时间了。
内容扩展:
W3C标准只是推荐标准,并没有强制执行的能力。但在 Web 标准领域各浏览器厂商对推荐的标准都很重视,积极的响应,但并非所有的标准都能得到适配。那么作为一名Web前端的开发人员,我们在使用一些新的API的时候也要关注这些API目前处于什么样的阶段,兼容性如何,能否可靠的运行到生产环境。下图是一份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
团队介绍
高灯科技交易合规前端团队(GFE), 隶属于高灯科技(北京)交易合规业务事业线研发部,是一个富有激情、充满创造力、坚持技术驱动全面成长的团队, 团队平均年龄27岁,有在各自领域深耕多年的大牛, 也有刚刚毕业的小牛, 我们在工程化、编码质量、性能监控、微服务、交互体验等方向积极进行探索, 追求技术驱动产品落地的宗旨,打造完善的前端技术体系。
- 愿景: 成为最值得信任、最有影响力的前端团队
- 使命: 坚持客户体验第一, 为业务创造更多可能性
文化: 勇于承担、深入业务、群策群力、简单开放
- Github:github.com/gfe-team
- 团队邮箱:gfe@goldentec.com
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。