虽然我们可以通过开发者工具以及lighthouse等工具来查看网站的加载情况,并按之前我们说的那些方案做好了优化,但真正用户打开是否真的如预期一般快,我们不得而知。一直以来我们都以实验室数据为测试的依据,这些不能代表现场数据,即真实用户的体验。
RUM(Real User Monitoring)因此而诞生。RUM依赖于浏览器提供的API来搜集真实用户的性能数据,主要包含2个标准文档,Navigation Timing API 和 Resource Timing API,这两个API都是基于 High Resolution Time 的规范定制的。
本文档将引导你去认识这些API提供的数据,更好的掌握RUM。
浏览器中的网络请求
Navigation和Resource Timing之间有部分交集,但两者收集的数据指标还是不一样的。
- Navigation Timing 收集了HTML文档的性能指标
- Resource Timing 收集了文档依赖的资源的性能指标,如:css,js,图片等等
先在控制台尝试执行一下以下代码:
// Get Navigation Timing entries:
performance.getEntriesByType("navigation");
// Get Resource Timing entries:
performance.getEntriesByType("resource");
getEntriesByType
接收一个字符串参数,表示你要获取的条目类型。想要获取Navigation Timing的条目,则传 navigation
,另一个则是传 resource
。以上代码执行结果,可以看到类似下方的对象结构:
{
"connectEnd": 152.20000001136214,
"connectStart": 85.00000007916242,
"decodedBodySize": 1270,
"domComplete": 377.90000007953495,
"domContentLoadedEventEnd": 236.4000000525266,
"domContentLoadedEventStart": 236.4000000525266,
"domInteractive": 236.2999999895692,
"domainLookupEnd": 85.00000007916242,
"domainLookupStart": 64.4000000320375,
"duration": 377.90000007953495,
"encodedBodySize": 606,
"entryType": "navigation",
"fetchStart": 61.600000015459955,
"initiatorType": "navigation",
"loadEventEnd": 377.90000007953495,
"loadEventStart": 377.90000007953495,
"name": "https://example.com/",
"nextHopProtocol": "h2",
"redirectCount": 0,
"redirectEnd": 0,
"redirectStart": 0,
"requestStart": 152.50000008381903,
"responseEnd": 197.80000008177012,
"responseStart": 170.00000004190952,
"secureConnectionStart": 105.80000001937151,
"startTime": 0,
"transferSize": 789,
"type": "navigate",
"unloadEventEnd": 0,
"unloadEventStart": 0,
"workerStart": 0
}
上面的数据看起来很晕,但只要记住一点:你在开发者工具中 Network
看到的 waterflow
,就是用这些数据画出来的。你也可以用这些数据绘制类似的图,用一些工具就能做到,Waterfall 或者 Performance-Bookmarklet 。
用这些API可以分析用户打开一个网站的每一个步骤的耗时,你也可以在js中上去使用这些API来收集真实用户的性能数据。
网络请求的生命周期
在你收集完这些性能数据之后,为了更形象的去理解他们,你需要了解一个请求从发起到结束到底经历了什么,开发者工具可以提供这样的图表,如下:
如预期的一样,可以看到这些步骤:DNS查询,建立连接,TLS握手等等。接下来我们会对着这份数据依次去介绍它们。
以下纯属主观看法,想要客观地去学习,回到上方提供的对应API的标准文档阅读
DNS查询
DNS全称Domain Name System,简单理解就是根据域名查询对应的IP地址。取决于你中间的DNS代理层数,可能会花费一些时间。Navigation和Resource Timing都包含以下2个和DNS查询相关的属性:
-
domainLookupStart
代表DNS开始查询的时间 -
domainLookupEnd
代表DNS查询结束
很简单,做个减法,我们就能拿到DNS查询的耗时。
// Measuring DNS lookup time
var pageNav = performance.getEntriesByType("navigation")[0];
var dnsTime = pageNav.domainLookupEnd - pageNav.domainLookupStart;
要注意一点,这两个值可能都是0
,当我们的资源是非同源的时候,假设可能是用了第三方的CDN服务,且没有携带Timing-Allow-Origin
的响应头。
建立连接
在与服务器建立连接之后,相关的资源才会发送到客户端。如果这个时候用了HTTPS协议,这个建立连接的过程就会多一步TLS握手。与此相关的3个指标如下:
-
connectStart
表示连接开始建立 -
secureConnectionStart
表示TLS握手开始 -
connectEnd
表示连接建立完成(同时也是TLS握手结束)
至于为什么没有 secureConnectionEnd
这个属性,应该是TLS的握手是在建立连接的最后一步,与 connectEnd
是一个时间点。
如果用的不是HTTPS协议,则 secureConnectionStart
是 0
,所以我们可以做一些兼容性的处理,如下代码:
// Quantifying total connection time
var pageNav = performance.getEntriesByType("navigation")[0];
var connectionTime = pageNav.connectEnd - pageNav.connectStart;
var tlsTime = 0; // <-- Assume 0 by default
// Did any TLS stuff happen?
if (pageNav.secureConnectionStart > 0) {
// Awesome! Calculate it!
tlsTime = pageNav.connectEnd - pageNav.secureConnectionStart;
}
在DNS查询和建立连接完成后,真正的请求才开始了。
请求与响应
当我们去思考到底是什么影响了请求速度的时候,一般可以归类为以下两点:
- 外在因素: 网络延迟或者带宽,这些都是开发者无法掌控的。
- 内在因素:服务器和客户端的架构、资源大小等等。
和这部分相关性能指标是重中之重。Navigation和Resource Timing都有如下相关指标:
-
fetchStart
表示浏览器开始获取资源的时间,并非是说从服务器获取,而是从检查缓存开始。 -
workerStart
表示从 [service worker]() 开始获取资源的时间,如果没有安装service worker,则是0
。 -
requestStart
表示浏览器开始发起网络请求的时间 -
responseStart
表示服务器响应的第一个字节到达的时间 -
responseEnd
表示服务器响应的最后一个字节到达的时间,即下载完成
我们可以用以下代码来获取资源下载的时间,以及缓存读取的时间
// Cache seek plus response time
var pageNav = performance.getEntriesByType("navigation")[0];
var fetchTime = pageNav.responseEnd - pageNav.fetchStart;
// Service worker time plus response time
var workerTime = 0;
if (pageNav.workerStart > 0) {
workerTime = pageNav.responseEnd - pageNav.workerStart;
}
也可以去获取一些对我们有帮助的组合时间,代码如下:
// Request time only (excluding unload, redirects, DNS, and connection time)
var requestTime = pageNav.responseStart - pageNav.requestStart;
// Response time only (download)
var responseTime = pageNav.responseEnd - pageNav.responseStart;
// Request + response time
var requestResponseTime = pageNav.responseEnd - pageNav.requestStart;
其他
以上,我们已经获取了大部分重要的性能指标,但还有一些其他的指标也可以简单了解一下。
文档卸载
文档卸载发生在浏览器即将打开新的文档之前,一般而言,这不会出现什么大问题。但如果你绑定了 unload
事件,并在事件回调中执行了一些耗时的代码,你就需要去关注一下 unloadEventStart
和 unloadEventEnd
这两个指标了。
unload
相关的指标只属于 Navigation Timing
跳转
一般情况下,跳转不是什么大问题,但如果频繁跳转,也会或多或少的影响页面的加载速度,看自身情况决定是否需要关注着几个指标 redirectStart
和 redirectEnd
。
文档解析
文档加载之后,浏览器会解析文档。一般除非我们的文档特别大,解析的耗时才会影响页面加载。Navigation Timing提供了相关指标 domInteractive
、domContentLoadedEventStart
、domContentLoadedEventEnd
、domComplete
。
文档解析相关的指标也只属于 Navigation Timing。
加载
当文档和资源都加载完了之后,浏览器会触发一个 load
事件,这时相关的回调函数会依次执行,我们也可以去拿到加载时间的指标 loadEventStart
和 loadEventEnd
。
以上两个指标也只属于 Navigation Timing
文档和资源的大小
文档和资源的大小毫无疑问是影响页面加载性能的关键因素。用API也能够拿到这些指标:
-
transferSize
表示资源传输总大小,包含header -
encodedBodySize
表示压缩之后的body大小 -
decodedBodySize
表示解压之后的body大小
以下代码可以获取到一些其他信息:
// HTTP header size
var pageNav = performance.getEntriesByType("navigation")[0];
var headerSize = pageNav.transferSize - pageNav.encodedBodySize;
// Compression ratio
var compressionRatio = pageNav.decodedBodySize / pageNav.encodedBodySize;
其实资源和文档的大小都是开发者自己知道的,可以通过开发者工具看到,不一定要用API来获取这些信息。
在代码中实际应用
基本上上面对这些API都有了一个大致的了解,现在我们可以在代码中去收集这些指标数据了。
其他获取性能条目的函数
上面我们讲到一个 getEntriesByType
的函数可以获取指定类型的性能条目,还有另外两种:
getEntriesByName
getEntriesByName
可以通过名字来获取对应的条目。对 Navigation 和 Resource Timing 来说,名字就是文档或资源的URL地址:
// Get timing data for an important hero image
var heroImageTime = performance.getEntriesByName("https://somesite.com/images/hero-image.jpg");
getEntries
跟 getEntriesByType
和 getEntriesByName
不一样,getEntries
获取了所有的条目。
// Get timing data for all entries in the performance entry buffer
var allTheTimings = performance.getEntries();
这里我们有一个概念没提到
initiatorType
,有兴趣可以去
MDN 上查询相关资料
用 PerformanceObserver 来监听性能条目
上面我们提到的三种函数都是一次性获取性能条目的,但这些都有以下两个问题:
- 循环遍历性能条目的数组(可能很大),会阻塞主线程
- 无法统计到新的请求或者新的指标。如果我们用定时器来尝试解决这个问题,代价太大,甚至可能会引发渲染冲突,导致jank
PerformanceObserver
就是为此而诞生的。以下是相关代码:
// Instantiate the performance observer
var perfObserver = new PerformanceObserver(function(list, obj) {
// Get all the resource entries collected so far
// (You can also use getEntriesByType/getEntriesByName here)
var entries = list.getEntries();
// Iterate over entries
for (var i = 0; i < entries.length; i++) {
// Do the work!
}
});
// Run the observer
perfObserver.observe({
// Polls for Navigation and Resource Timing entries
entryTypes: ["navigation", "resource"]
});
需要注意的是 PerformanceObserver
目前还没不适用于所有浏览器,需要做一些兼容处理:
// Should we even be doing anything with perf APIs?
if ("performance" in window) {
// OK, yes. Check PerformanceObserver support
if ("PerformanceObserver" in window) {
// Observe ALL the performance entries!
} else {
// WOMP WOMP. Find another way. Or not.
}
}
一些陷阱
看上去统计上面这些性能指标都很简单,但还有一些比较棘手的情况。
Cross-origins 和 Timing-Allow-Origin 的响应头
并非所有的性能指标我们都能获取到,如果没有携带一些响应头,某些指标可能就一直是 0
,想要完全掌握这部分,需要去标准文档细读。
持久连接会影响时序
当HTTP/1.1的请求带了 Connection: Keep-Alive
的响应头的时候,此连接会被复用。或者当我们用的是HTTP/2的时候,一个连接会被所有同源资源复用。这些都会影响时间统计,不过我们不用太刻意去检查这些,稍微留个心就好了。
不是所有浏览器都支持这些API
对Web开发者而言,浏览器兼容性是无法避免的问题。而且 getEntriesByType
这个API函数,如果获取一个不支持的类型的性能条目,浏览器并不会报错,而是返回空数组,如以下代码:
// This returns stuff!
performance.getEntriesByType("resource");
// Not so much. :\
performance.getEntriesByType("navigation");
为此,我们可以稍作兼容:
if (performance.getEntriesByType("navigation").length > 0) {
// Yay, we have Navigation Timing stuff!
}
并非所有浏览器都支持这些API,用的时候尽量做一些检测,避免产生一些错误的统计。
收集数据
我们已经知道了如何使用这些API获取性能指标,但这些数据我们应该放在哪里?
使用navigator.sendBeacon
navigator.sendBeacon
是一种非阻塞的请求方式,不用等待服务器响应,只是单方面的数据发送,是收集RUM数据的一个最佳方案,即使页面关闭,浏览器依然会将这些请求发送完成。
// Caution: If you have a _lot_ of performance entries, don't send _everything_ via getEntries. This is just an example.
let rumData = JSON.stringify(performance.getEntries()));
// Check for sendBeacon support:
if ('sendBeacon' in navigator) {
// Beacon the requested
if (navigator.sendBeacon('/analytics', rumData)) {
// sendBeacon worked! We're good!
} else {
// sendBeacon failed! Use XHR or fetch instead
}
} else {
// sendBeacon not available! Use XHR or fetch instead
}
服务端要获取这些数据,可以从post表单中获取,或者从get的参数中获取。
navigator.sendBeacon
调用的时候,只是往队列里面插入了一个,等待浏览器资源空闲,会将请求发送出去。如果资源过大,浏览器也可能会拒绝发送。
总结
如果你对这些还不够自信,千万不要直接就应用在项目代码中,建议详细阅读相关标准文档之后,再尝试应用在项目中。有了这些性能指标数据,我们可以随时修复一些发现的问题。
另外,你也不用把所有指标都存到服务器,选一些自己觉得有用的就好。
本文档只是一个引导性质的,并不能完全代表这些API的所有使用方式,建议还是阅读以下相关标准文档(文中链接)。
有了这些API,你就能更加了解真是用户的使用场景。