性能优化的重要性不言而喻,Google 的研究表明,当网站达到核心 Web 指标(Core Web Vitals)阈值时,用户放弃加载网页的可能性会降低 24%。
如何科学地定位到网页的性能瓶颈,就需要找到一个合理的方式来测量和监控页面的性能,确定优化的方向。
前端的性能监控分为 2 种:
- 第一种是合成监控(Synthetic Monitoring,SYN),模拟网页加载或脚本运行等方式来测量网页性能,输出性能报告以供参考,常用的工具有 Chrome DevTools 的 Performance 面板、Lighthouse、WebPageTest 等。
- 第二种是真实用户监控(Real User Monitoring,RUM),采集真实用户所访问到的页面数据,通过 Performance、PerformanceObserver 等 API 计算得到想要的性能参数,各种第三方的性能监控的 SDK 就属于此类。
本文的示例代码摘取自 shin-monitor,一款开源的前端监控脚本。
为了便于记忆,特将此系列的所有重点内容浓缩成一张思维导图。
一、Performance
W3C 在 2012 年制订了第一版测量网页性能的规范:Navigation Timing。下图提供了页面各阶段可用的性能计时参数。
注意,若重定向是非同源,那么带下划线的 redirectStart、redirectEnd、unloadStart、unloadEnd 四个值将一直都是 0。
W3C 在几年后又制订了第二版的规范:Navigation Timing Level 2,如下图所示。
注意,在浏览器中,读取 unloadEventStart 的值后,会发现这个时间点并不会像图中那样在 fetchStart 之前,因为 unload 不会阻塞页面加载。
接下来,会用代码来演示性能参数的计算,后文中的 navigationStart 参数其实就是 startTime。
1)性能对象
第一版获取性能参数的方法是调用 performance.timing,第二版的方法是调用 performance.getEntriesByType('navigation')[0]。
前者得到一个 PerformanceTiming 对象,后者得到一个 PerformanceNavigationTiming 对象。
在下面的代码中,若当前浏览器不支持第二版,则回退到第一版。不过,目前主流的浏览器对第一版的支持也比较好。
const timing = performance.getEntriesByType('navigation')[0] || performance.timing;
以我公司为例,投放到线上的页面,其中只有大概 5.5% 的用户读取的第一版。
2023-02-27 注意,PerformanceNavigationTiming 继承了 PerformanceResourceTiming。
在 iOS 设备中,若 SDK 涉及跨域,并且其响应没有声明 timing-allow-origin 首部,那么 PerformanceResourceTiming 中的大部分属性都是 0。
包括 responseStart、connectStart、domainLookupStart 等都为 0,若 responseStart 为 0,那就会影响 TTFB 的计算,其值也会一直为 0。
可以将 timing-allow-origin 设为星号,或指定域名,如下所示。
Timing-Allow-Origin: * Timing-Allow-Origin: https://www.pwstrick.com
2023-03-14 虽然添加了 timing-allow-origin,但是统计结果中 TTFB 仍然包含大量的 0。
经过抓包发现,是因为没有请求服务器中的 SDK,而是直接读取了客户端中的缓存。
为了让客户端每次都去校验资源是否需要更新(即破缓存),就在 SDK 的响应头中增加 Cache-Control: no-cache。
2)fetchStart
从上面的计时图中可知,在 fetchStart 之前,浏览器会先处理重定向。
重定向的本质是在服务器第一次响应时,返回 301 或 302 状态码,让客户端进行下一步请求。
会多走一遍响应流程,若不是像鉴权、短链等有意义的重定向,都应该要避免。
比较常见的有浏览器强制从 HTTP 页面重定向到对应的 HTTPS 页面,以及主域名的重定向,例如从 https://pwstrick.com 重定向至 https://www.pwstrick.com。
由于浏览器安全策略的原因,不同域名之间的重定向时间,是无法精确计算的,只能统计 fetchStart 之前的总耗时。
fetchStart 还会包含新标签页初始化的时间,但并不包括上一个页面的 unload 时间。
由此可知,startTime 其实是在卸载上个页面之后开始统计的。 fetchStart 最主要的优化手段就是减少重定向次数。
例如若页面需要登录,则做成一个弹框,不要做页面跳转,还例如在编写页面时,不要显式地为 URL 添加协议。
3)TCP
TCP 在建立连接之前,要经过三次握手,若是 HTTPS 协议,还要包括 SSL 握手,计算规则如下所示。
/** * SSL连接耗时 */ const sslTime = timing.secureConnectionStart; connectSslTime = sslTime > 0 ? timing.connectEnd - sslTime : 0; /** * TCP连接耗时 */ connectTime = timing.connectEnd - timing.connectStart;
在建立连接后,TCP 就可复用,所以有时候计算得到的值是 0。
若要减少 TCP 的耗时,可通过减少物理距离、使用 HTTP/3 协议等方法实现。
还有一种方法是通过 preconnect 提前建立连接,如下所示,浏览器会抢先启动与该来源的连接。
<link rel="preconnect" href="https://pwstrick.com"/>
4)TTFB
TTFB(Time To First Byte)是指读取页面第一个字节的时间,即从发起请求到服务器响应后收到的第一个字节的时间差,用于衡量服务器处理能力和网络的延迟。
TTFB 包括重定向、DNS 解析、TCP 连接、网络传输、服务器响应等时间消耗的总和,计算规则就是 responseStart 减去 redirectStart。
TTFB = timing.responseStart - timing.redirectStart;
其实,TTFB 计算的是整个通信的往返时间(Round-Trip Time,RTT),以及服务器的处理时间。
所以,设备之间的距离、网络传输路径、数据库慢查询等因素都会影响 TTFB。
一般来说,TTFB 保持在 75ms 以内会比较完美,而在 200ms 以内会比较理想,若超过 500ms,用户就会感觉到明显地白屏。
TTFB 常用的优化手段包括增加 CDN 动态加速、减少请求的数据量、服务器硬件升级、优化后端代码(引入缓存、慢查询优化等服务端的工作)。
5)FP 和 FCP
白屏(First Paint,FP)也叫首次绘制,是指屏幕从空白到显示第一个画面的时间,即渲染树转换成屏幕像素的时刻。
这是用户可感知到的一个性能参数,1 秒内是比较理想的白屏时间。
白屏时间的计算规则有 2 种:
- 第一种是读取 PerformancePaintTiming 对象,再减去 fetchStart。
- 第二种是通过 responseEnd 和 fetchStart 相减。
const paint = performance.getEntriesByType("paint"); if (paint && timing.entryType && paint[0]) { firstPaint = paint[0].startTime - timing.fetchStart; } else { firstPaint = timing.responseEnd - timing.fetchStart; }
在实践中发现,每天有大概 2 千条记录中的白屏时间为 0,而且清一色的都是苹果手机。
一番搜索后,了解到,当 iOS 设备通过浏览器的前进或后退按钮进入页面时,fetchStart、responseEnd 等性能参数很可能为 0。
还发现当初始页面的结构中,若包含渐变的效果时,1 秒内的白屏占比会从最高 94% 降低到 85%。
注意,PerformancePaintTiming 包含两个性能数据,FP 和 FCP,理想情况下,两者的值可相同。
FCP(First Contentful Paint)是指首次有实际内容渲染的时间,测量页面从开始加载到页面内容的任何部分在屏幕上完成渲染的时间。
内容是指文本、图像(包括背景图像)、svg 元素或非白色的 canvas 元素,不包括 iframe 中的内容。
网站性能测试工具 GTmetrix 认为 FCP 比较理想的时间是控制在 943ms 以内,字节的标准是控制在 1.8s 内。
if (paint && timing.entryType && paint[1]) { firstContentfulPaint = paint[1].startTime - timing.fetchStart; } else { firstContentfulPaint = 0; }
影响上述两个指标的主要因素包括网络传输和页面渲染,优化的核心就是降低网络延迟以及加速渲染。
优化手段包括剔除阻塞渲染的 JavaScript 和 CSS、优化图像、压缩合并文件、延迟加载非关键资源、使用 HTTP/2 协议、SSR 等。
6)DOM
在性能计时图中,有 4 个与 DOM 相关的参数,包括 domInteractive、domComplete、domContentLoadedEventStart 和 domContentLoadedEventEnd。
domInteractive 记录的是在加载 DOM 并执行网页的阻塞脚本的时间。
在这个阶段,具有 defer 属性的脚本还没有执行,某些样式表加载可能仍在处理并阻止页面呈现。
domComplete 记录的是完成解析 DOM 树结构的时间。
在这个阶段,DOM 中的所有脚本,包括具有 async 属性的脚本,都已执行。并且开始加载 DOM 中定义的所有页面静态资源,包括图像、iframe 等。
loadEventStart 会紧跟在 domComplete 之后。在大多数情况下,这 2 个指标是相等的。在 loadEventStart 之前可能的延迟将由 onReadyStateChange 引起。
由 domInteractive 和 domComplete 两个参数可计算出两个 DOM 阶段的耗时,如下所示。
initDomTreeTime = timing.domInteractive - timing.responseEnd; // 请求完毕至 DOM 加载的耗时 parseDomTime = timing.domComplete - timing.domInteractive; // 解析 DOM 树结构的耗时
若 initDomTreeTime 过长的话,就需要给脚本瘦身了。若 parseDomTime过长的话,就需要减少资源的请求了。
DOMContentLoaded(DCL)紧跟在 domInteractive 之后,该事件包含开始和结束两个参数,jQuery.ready() 就是封装了此事件。
该事件会在 HTML 加载完毕,并且 HTML 所引用的内联和外链的非 async/defer 的同步 JavaScript 脚本和 CSS 样式都执行完毕后触发,无需等待图像和 iframe 完成加载。
由 domContentLoadedEventEnd 可计算出用户可操作时间,即 DOM Ready 时间。
domReadyTime = timing.domContentLoadedEventEnd - navigationStart; // 用户可操作时间(DOM Ready时间)
注意,若 domContentLoadedEventEnd 高于 domContentLoadedEventStart,则说明该页面中也注册了此事件。
与 DCL 相比,load 事件触发的时机要晚的多。
它会在页面的 HTML、CSS、JavaScript(包括 async/defer)、图像等静态资源都已经加载完之后才触发。