🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
&emsp;&emsp;前端性能监控是个老话题了,各个团队都会对其有所关注,因为关注性能是工程师的本分。 &emsp;&emsp;页面性能对用户体验而言十分关键,每次重构或优化,仅靠手中的几个设备或模拟的测试,缺少说服力,需要有大量的真实数据来做验证。 &emsp;&emsp;在2016年,我就写过一篇《[前端页面性能参数搜集](https://www.cnblogs.com/strick/p/5750022.html)》的文章,当时采用的还是W3C性能参数的[第一版](https://www.w3.org/blog/2012/09/performance-timing-information/),现在已有[第二版](https://www.w3.org/TR/navigation-timing-2/)了。 &emsp;&emsp;在2020年,根据自己所学整理了一套监控系统,代号[菠萝](https://github.com/pwstrick/pineapple),不过并没有正式上线,所以只能算是个玩具。 &emsp;&emsp;这次不同,公司急切的需要一套性能监控系统,用于分析线上的活动,要扎扎实实的提升用户体验。 &emsp;&emsp;整个系统大致的运行流程如下: :-: ![](https://img.kancloud.cn/3f/d9/3fd9afa204a73c24cdc4e33b6d93706b_627x225.png =500x) &emsp;&emsp;2023-01-16 经过 TypeScript 整理重写后,正式将监控系统的脚本开源,命名为 [shin-monitor](https://github.com/pwstrick/shin-monitor)。 ## 一、SDK &emsp;&emsp;性能参数搜集的代码仍然写在前面的监控 [shin.js](https://github.com/pwstrick/shin-admin/blob/main/public/shin.js)(SDK) 中,为了兼容两个版本的性能标准,专门编写了一个函数。 ~~~ function _getTiming() { var timing = performance.getEntriesByType("navigation")[0] || performance.timing; var now = 0; if (!timing) { return { now: now }; } var navigationStart; if (timing.startTime === undefined) { navigationStart = timing.navigationStart; /** * 之所以老版本的用 Date,是为了防止出现负数 * 当 performance.now 是最新版本时,数值的位数要比 timing 中的少很多 */ now = new Date().getTime() - navigationStart; } else { navigationStart = timing.startTime; now = shin.now() - navigationStart; } return { timing: timing, navigationStart: navigationStart, now: _rounded(now) }; } ~~~ &emsp;&emsp;其实两种方式得当的参数类似,第二版中的参数比第一版来的多,下面两张图是官方给的参数示意图,粗看的话下面两种差不多。 :-: ![](https://box.kancloud.cn/2016-05-18_573be5286b049.png =800x) W3C第一版的性能参数 :-: ![](https://img.kancloud.cn/81/53/81536b373d68112de61e4e7de6472804_1582x502.png =800x) W3C第二版的性能参数 &emsp;&emsp;但其实在将 performance.getEntriesByType("navigation")\[0\] 打印出来后,就会发现它还会包含页面地址、传输的数据量、协议等字段。 **1)统计的参数** &emsp;&emsp;网上有很多种统计性能参数的计算方式,大部分都差不多,我选取了其中较为常规的参数。 ~~~ shin.getTimes = function () { // 出于对浏览器兼容性的考虑,仍然引入即将淘汰的 performance.timing var currentTiming = _getTiming(); var timing = currentTiming.timing; // var timing = performance.timing; var api = {}; // 时间单位 ms if (!timing) { return api; } var navigationStart = currentTiming.navigationStart; /** * http://javascript.ruanyifeng.com/bom/performance.html * 页面加载总时间,有可能为0,未触发load事件 * 这几乎代表了用户等待页面可用的时间 * loadEventEnd(加载结束)-navigationStart(导航开始) */ api.loadTime = timing.loadEventEnd - navigationStart; /** * Unload事件耗时 */ api.unloadEventTime = timing.unloadEventEnd - timing.unloadEventStart; /** * 执行 onload 回调函数的时间 * 是否太多不必要的操作都放到 onload 回调函数里执行了,考虑过延迟加载、按需加载的策略么? */ api.loadEventTime = timing.loadEventEnd - timing.loadEventStart; /** * 首次可交互时间 */ api.interactiveTime = timing.domInteractive - timing.fetchStart; /** * 用户可操作时间(DOM Ready时间) * 在初始HTML文档已完全加载和解析时触发(无需等待图像和iframe完成加载) * 紧跟在DOMInteractive之后。 * https://www.dareboost.com/en/doc/website-speed-test/metrics/dom-content-loaded-dcl */ api.domReadyTime = timing.domContentLoadedEventEnd - timing.fetchStart; /** * 白屏时间 * FP(First Paint)首次渲染的时间 */ var paint = performance.getEntriesByType("paint"); if (paint && timing.entryType && paint[0]) { api.firstPaint = paint[0].startTime - timing.fetchStart; api.firstPaintStart = paint[0].startTime; // 记录白屏时间点 } else { api.firstPaint = timing.responseEnd - timing.fetchStart; } /** * FCP(First Contentful Paint)首次有实际内容渲染的时间 */ if (paint && timing.entryType && paint[1]) { api.firstContentfulPaint = paint[1].startTime - timing.fetchStart; api.firstContentfulPaintStart = paint[1].startTime; // 记录白屏时间点 } else { api.firstContentfulPaint = 0; } /** * 解析DOM树结构的时间 * DOM中的所有脚本,包括具有async属性的脚本,都已执行。加载DOM中定义的所有页面静态资源(图像、iframe等) * loadEventStart紧跟在domComplete之后。在大多数情况下,这2个指标是相等的。 * 在加载事件开始之前可能引入的唯一额外延迟将由onReadyStateChange的处理引起。 * https://www.dareboost.com/en/doc/website-speed-test/metrics/dom-complete */ api.parseDomTime = timing.domComplete - timing.domInteractive; /** * 请求完毕至DOM加载耗时 * 在加载DOM并执行网页的阻塞脚本时触发 * 在这个阶段,具有defer属性的脚本还没有执行,某些样式表加载可能仍在处理并阻止页面呈现 * https://www.dareboost.com/en/doc/website-speed-test/metrics/dom-interactive */ api.initDomTreeTime = timing.domInteractive - timing.responseEnd; /** * 准备新页面耗时 */ api.readyStart = timing.fetchStart - navigationStart; /** * 重定向次数(新) */ api.redirectCount = timing.redirectCount || 0; /** * 传输内容压缩百分比(新) */ api.compression = (1 - timing.encodedBodySize / timing.decodedBodySize) * 100 || 0; /** * 重定向的时间 * 拒绝重定向!比如,http://example.com/ 就不该写成 http://example.com */ api.redirectTime = timing.redirectEnd - timing.redirectStart; /** * DNS缓存耗时 */ api.appcacheTime = timing.domainLookupStart - timing.fetchStart; /** * DNS查询耗时 * DNS 预加载做了么?页面内是不是使用了太多不同的域名导致域名查询的时间太长? * 可使用 HTML5 Prefetch 预查询 DNS,参考:http://segmentfault.com/a/1190000000633364 */ api.lookupDomainTime = timing.domainLookupEnd - timing.domainLookupStart; /** * SSL连接耗时 */ var sslTime = timing.secureConnectionStart; api.connectSslTime = sslTime > 0 ? timing.connectEnd - sslTime : 0; /** * TCP连接耗时 */ api.connectTime = timing.connectEnd - timing.connectStart; /** * 内容加载完成的时间 * 页面内容经过 gzip 压缩了么,静态资源 css/js 等压缩了么? */ api.requestTime = timing.responseEnd - timing.requestStart; /** * 请求文档 * 开始请求文档到开始接收文档 */ api.requestDocumentTime = timing.responseStart - timing.requestStart; /** * 接收文档(内容传输耗时) * 开始接收文档到文档接收完成 */ api.responseDocumentTime = timing.responseEnd - timing.responseStart; /** * 读取页面第一个字节的时间,包含重定向时间 * TTFB 即 Time To First Byte 的意思 * 维基百科:https://en.wikipedia.org/wiki/Time_To_First_Byte */ api.TTFB = timing.responseStart - timing.redirectStart; /** * 仅用来记录当前 performance.now() 获取到的时间格式 * 用于追溯计算 */ api.now = shin.now(); // 全部取整 for (var key in api) { api[key] = _rounded(api[key]); } /** * 浏览器读取到的性能参数,用于排查,并保留两位小数 */ api.timing = {}; for (var key in timing) { const timingValue = timing[key]; const type = typeof timingValue; if (type === "function") { continue; } api.timing[key] = timingValue; if (type === "number") { api.timing[key] = _rounded(timingValue, 2); } } return api; }; ~~~ &emsp;&emsp;所有的性能参数最终都要被取整,以毫秒作单位。兼容的 timing 对象也会被整个传递到后台,便于分析性能参数是怎么计算出来的。 &emsp;&emsp;compression(传输内容压缩百分比)是一个[新的参数](https://developer.mozilla.org/en-US/docs/Web/Performance/Navigation_and_resource_timings#compression)。 &emsp;&emsp;白屏时间的计算有两种: 1. 第一种是调用 [performance.getEntriesByType("paint")](https://developer.mozilla.org/zh-CN/docs/Web/API/PerformancePaintTiming)方法,再减去 fetchStart; 2. 第二种是用 responseEnd 来与 fetchStart 相减。 &emsp;&emsp;在实践中发现,每天有大概 2 千条记录中的白屏时间为 0,而且清一色的都是苹果手机,一番搜索后,了解到。 &emsp;&emsp;当 iOS 设备通过浏览器的前进或后退按钮进入页面时,fetchStart、responseEnd 等性能参数很可能为 0。 &emsp;&emsp;2023-01-19 发现当初始页面的结构中,若包含渐变的效果时,1 秒内的白屏占比会从最高 94% 降低到 85%。 &emsp;&emsp;loadTime(页面加载总时间)有可能为0,就是当页面资源还没加载完,触发 load 事件前将页面关闭。 &emsp;&emsp;如果这种很多,那就很有可能页面被阻塞在某个位置,可能是接收时间过长、可能是DOM解析过长等。 &emsp;&emsp;当这个页面加载时间超过了用户的心理承受范围时,就需要抽出时间来做各个方面的页面优化了。 &emsp;&emsp;注意,在调用 performance.getEntriesByType("paint") 方法后,可以得到一个数组,第一个元素是白屏对象,第二个元素是[FCP](https://developer.mozilla.org/en-US/docs/Glossary/First_contentful_paint)对象。 &emsp;&emsp;FCP(First Contentful Paint)是首次有实际内容渲染的时间,于 2022-08-16 新增该指标。 &emsp;&emsp;在上线一段时间后,发现有大概 50% 的记录,load 和 ready 是小于等于 0。查看原始的性能参数,如下所示。 ~~~ { "unloadEventStart": 0, "unloadEventEnd": 0, "domInteractive": 0, "domContentLoadedEventStart": 0, "domContentLoadedEventEnd": 0, "domComplete": 0, "loadEventStart": 0, "loadEventEnd": 0, "type": "navigate", "redirectCount": 0, "initiatorType": "navigation", "nextHopProtocol": "h2", "workerStart": 0, "redirectStart": 0, "redirectEnd": 0, "fetchStart": 0.5, "domainLookupStart": 3.7, "domainLookupEnd": 12.6, "connectStart": 12.6, "connectEnd": 33.3, "secureConnectionStart": 20.4, "requestStart": 33.8, "responseStart": 44.1, "responseEnd": 46, "transferSize": 1207, "encodedBodySize": 879, "decodedBodySize": 2183, "serverTiming": [], "name": "https://www.xxx.me/xx.html", "entryType": "navigation", "startTime": 0, "duration": 0 } ~~~ &emsp;&emsp;发现 domContentLoadedEventEnd 和 loadEventEnd 都是 0,一开始怀疑参数的问题。 &emsp;&emsp;旧版的[performance.timing](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceTiming)已经被废弃,新版通过 performance.getEntriesByType('navigation')\[0\] 得到一个[PerformanceNavigationTiming](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming)对象,它继承自[PerformanceResourceTiming](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming)类,但没查到有什么问题。 &emsp;&emsp;还有一种情况,就是 DOMContentLoaded 与 load 两个事件都没有触发。关于两者的触发时机,网上的[一篇文章](https://juejin.cn/post/6844903623583891469)总结道: * load 事件会在页面的 HTML、CSS、JavaScript、图片等静态资源都已经加载完之后才触发。 * DOMContentLoaded 事件会在 HTML 加载完毕,并且 HTML 所引用的内联 JavaScript、以及外链 JavaScript 的同步代码都执行完毕后触发。 &emsp;&emsp;在网上搜索一圈后,没有发现阻塞的原因,那很有可能是自己代码的问题。 &emsp;&emsp;经查,是调用 JSBridge 的一种同步方式阻塞了两个事件的触发。代码中的 t 就是一条链接。 ~~~ window.location.href = t ~~~ &emsp;&emsp;在加载脚本时,就会触发某些 JSBridge,而有些手机就会被阻塞,有些并不会。解决方案就是将同步的跳转改成异步的,如下所示。[链接]() ~~~ var iframe = document.createElement("iframe"); iframe.src = t; document.body.append(iframe); ~~~ &emsp;&emsp;值得一提的是,在将此问题修复后,首屏 1 秒内的占比从 66.7% 降到了 48.4%,2 秒内的占比从 20.7% 升到了 25.5%,3、4、4+ 秒的占比也都提升了。 **2)首屏时间** &emsp;&emsp;首屏时间很难计算,一般有几种计算方式。 &emsp;&emsp;第一种是算出首屏页面中所有图片都加载完后的时间,这种方法难以覆盖所有场景(例如 CSS 中的背景图、Image 元素等),并且计算结果并不准。 ~~~ /** * 计算首屏时间 * 记录首屏图片的载入时间 * 用户在没有滚动时候看到的内容渲染完成并且可以交互的时间 */ doc.addEventListener( "DOMContentLoaded", function () { var isFindLastImg = false, allFirsrImgsLoaded = false, firstScreenImgs = []; //用一个定时器差值页面中的图像元素 var interval = setInterval(function () { //如果自定义了 firstScreen 的值,就销毁定时器 if (shin.firstScreen) { clearInterval(interval); return; } if (isFindLastImg) { allFirsrImgsLoaded = firstScreenImgs.every(function (img) { return img.complete; }); //当所有的首屏图像都载入后,关闭定时器并记录首屏时间 if (allFirsrImgsLoaded) { shin.firstScreen = _calcCurrentTime(); clearInterval(interval); } return; } var imgs = doc.querySelectorAll("img"); imgs = [].slice.call(imgs); //转换成数组 //遍历页面中的图像 imgs.forEach(function (img) { if (isFindLastImg) return; //当图像离顶部的距离超过屏幕宽度时,被认为找到了首屏的最后一张图 var rect = img.getBoundingClientRect(); if (rect.top + rect.height > firstScreenHeight) { isFindLastImg = true; return; } //若未超过,则认为图像在首屏中 firstScreenImgs.push(img); }); }, 0); }, false ); ~~~ &emsp;&emsp;第二种是自定义首屏时间,也就是自己来控制何时算首屏全部加载好了,这种方法相对来说要精确很多。 ~~~ shin.setFirstScreen = function() { this.firstScreen = _calcCurrentTime(); } /** * 计算当前时间与 fetchStart 之间的差值 */ function _calcCurrentTime() { return _getTiming().now; } /** * 标记时间,单位毫秒 */ shin.now = function () { return performance.now(); } ~~~ &emsp;&emsp;之所以未用 Date.now() 是因为它会受系统程序执行阻塞的影响, 而performance.now() 的时间是以恒定速率递增的,不受系统时间的影响(系统时间可被人为或软件调整)。 &emsp;&emsp;在页面关闭时还未获取到首屏时间,那么它就默认是 domReadyTime(用户可操作时间)。 &emsp;&emsp;首屏时间(screen)有可能是负数,例如返回上一页、刷新当前页后,马上将页面关闭,此时 screen 的值取自 domReadyTime。 &emsp;&emsp;domReadyTime 是由 domContentLoadedEventEnd 和 fetchStart 相减而得到,domContentLoadedEventEnd 可能是 0,fetchStart 是个非 0 值。 &emsp;&emsp;这样就会得到一个负值,不过总体占比并不高,每天在 300 条上下,0.3% 左右。 &emsp;&emsp;2023-01-06 去掉了这两种首屏算法,因为默认会采用 LCP 或 FMP 的计算结果。 **3)上报** &emsp;&emsp;本次上报与之前不同,需要在页面关闭时上报。而在此时普通的请求可能都无法发送成功,那么就需要[navigator.sendBeacon()](https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator/sendBeacon)的帮忙了。 &emsp;&emsp;它能将少量数据异步 POST 到后台,并且支持跨域,而少量是指多少并没有特别指明,由浏览器控制,网上查到的资料说一般在 64KB 左右。 &emsp;&emsp;在接收数据时遇到个问题,由于后台使用的是 KOA 框架,解析请求数据使用了 koa-bodyparser 库,而它默认不会接收 Content-Type: text 的数据,因此要额外配置一下,具体可[参考此处](https://stackoverflow.com/questions/53591683/how-to-access-request-payload-in-koa-web-framework)。 ~~~ /** * 在页面卸载之前,推送性能信息 */ window.addEventListener("beforeunload", function () { var data = shin.getTimes(); if (shin.param.rate > Math.random(0, 1) && shin.param.pkey) { navigator.sendBeacon(shin.param.psrc, _paramifyPerformance(data)); } }, false ); ~~~ &emsp;&emsp;在上报时,还限定了一个采样率,默认只会把 50% 的性能数据上报到后台,并且必须定义 pkey 参数,这其实就是一个用于区分项目的 token。 &emsp;&emsp;本来一切都是这么的顺利,但是在实际使用中发现,在 iOS 设备上调试发现不会触发 beforeunload 事件,安卓会将其触发,一番查找后,根据[iOS支持的事件](https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html#//apple_ref/doc/uid/TP40006511-SW5)和[社区的解答](https://stackoverflow.com/questions/3239834/window-onbeforeunload-not-working-on-the-ipad),发现得用[pagehide](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/pagehide_event)事件替代。 &emsp;&emsp;以为万事大吉,但还是太年轻,在微信浏览器中的确能触发 pagehide 事件,但是在自己公司APP中,表现不尽如意,无法触发,若要监控关闭按钮,得发一次版本。 &emsp;&emsp;无奈,只能自己想了个比较迂回的方法,那就是在后台跑个定时器,每 200ms 缓存一次要搜集的性能数据,在第二次进入时,再上报到后台。 ~~~ /** * 组装性能变量 */ function _paramifyPerformance(obj) { obj.token = shin.param.token; obj.pkey = shin.param.pkey; obj.identity = getIdentity(); obj.referer = location.href; // 来源地址 // 取 FCM、LCP 和用户可操作时间中的最大值 obj.firstScreen = Math.max.call( undefined, shin.fmp.time, shin.lcp.time, obj.domReadyTime ); obj.timing.lcp = shin.lcp; //记录LCP对象 obj.timing.fmp = shin.fmp; //记录FMP对象 obj.timing.fid = shin.fid; //记录FID对象 // 静态资源列表 var resources = performance.getEntriesByType("resource"); var newResources = []; resources && resources.forEach(function (value) { // 过滤 fetch 请求 if (value.initiatorType === "fetch") return; // 只存储 1 分钟内的资源 if (value.startTime > 60000) return; newResources.push({ name: value.name, duration: _rounded(value.duration), startTime: _rounded(value.startTime) }); }); obj.resource = newResources; return JSON.stringify(obj); } /** * 均匀获得两个数字之间的随机数 */ function _randomNum(max, min) { return Math.floor(Math.random() * (max - min + 1) + min); } /** * iOS 设备不支持 beforeunload 事件,需要使用 pagehide 事件 * 在页面卸载之前,推送性能信息 */ var isIOS = !!navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/); var eventName = isIOS ? "pagehide" : "beforeunload"; var isNeedHideEvent = true; // 是否需求触发隐藏事件 window.addEventListener(eventName, function () { isNeedHideEvent && sendBeacon(); }, false ); /** * 在 load 事件中,上报性能参数 * 该事件不可取消,也不会冒泡 */ window.addEventListener("load", function () { /** * 监控页面奔溃情况 * 原先是在 DOMContentLoaded 事件内触发,经测试发现,当因为脚本错误出现白屏时,两个事件的触发时机会很接近 * 在线上监控时发现会有一些误报,HTML是有内容的,那很可能是 DOMContentLoaded 触发时,页面内容还没渲染好 */ setTimeout(function () { monitorCrash(shin.param); }, 1000); // 加定时器是避免在上报性能参数时,loadEventEnd 为 0,因为事件还没执行完毕 setTimeout(function () { sendBeacon(); }, 0); }); // var SHIN_PERFORMANCE_DATA = 'shin_performance_data'; // var heartbeat; // 心跳定时器 /** * 发送 64KB 以内的数据 */ function sendBeacon(existData) { // 如果传了数据就使用该数据,否则读取性能参数,并格式化为字符串 var data = existData || _paramifyPerformance(shin.getTimes()); var rate = _randomNum(10, 1); // 选取1~10之间的整数 if (shin.param.rate >= rate && shin.param.pkey) { navigator.sendBeacon(shin.param.psrc, data); } // clearTimeout(heartbeat); // localStorage.removeItem(SHIN_PERFORMANCE_DATA); // 移除性能缓存 isNeedHideEvent = false; } /** * 发送已存在的性能数据 */ // function sendExistData() { // var exist = localStorage.getItem(SHIN_PERFORMANCE_DATA); // if (!exist) { return; } // setTimeout(function() { // sendBeacon(exist); // }, 0); // } // sendExistData(); /** * 一个心跳回调函数,缓存性能参数 * 适用于不能触发 pagehide 和 beforeunload 事件的浏览器 */ // function intervalHeartbeat() { // localStorage.setItem(SHIN_PERFORMANCE_DATA, _paramifyPerformance(shin.getTimes())); // } // heartbeat = setInterval(intervalHeartbeat, 200); ~~~ &emsp;&emsp;2023-01-06 去掉了对性能参数的缓存,因为在 load 事件中也会上报性能参数。 &emsp;&emsp;并且也是为了减少对 FMP 的计算次数,消除不必要的性能损耗,避免错误的计算结果,故而做出了此决定。 &emsp;&emsp;注意,首屏最终会取 FMP、LCP 和用户可操作时间中的最大值。 **4)LCP** &emsp;&emsp;2022-07-12 新增该指标,LCP(Largest Contentful Paint)是指最大的内容在可视区域内变得可见的时间点。 &emsp;&emsp;在 MDN 网站中,有一段[LCP](https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint)的计算示例,在此基础之上,做了些兼容性判断。 &emsp;&emsp;通过[PerformanceObserver.observe()](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver/observe)监控 LCP。entries 是一组[LargestContentfulPaint](https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint)类型的对象,它有一个 url 属性,如果记录的元素是个图像,那么会存储其地址。 ~~~ var lcp; function getLCP() { var types = PerformanceObserver.supportedEntryTypes; var lcpType = 'largest-contentful-paint'; // 浏览器兼容判断 if(types.indexOf(lcpType) === -1) { return; } new PerformanceObserver((entryList) => { var entries = entryList.getEntries(); var lastEntry = entries[entries.length - 1]; lcp = lastEntry.renderTime || lastEntry.loadTime; // 断开此观察者的连接,因为回调仅触发一次 obs.disconnect(); // buffered 为 true 表示调用 observe() 之前的也算进来 }).observe({type: lcpType, buffered: true}); } getLCP(); ~~~ &emsp;&emsp;在 iOS 的 WebView 中,只支持三种类型的 entryType,不包括 largest-contentful-paint,所以加了段浏览器兼容判断。并且 entries 是一组[PerformanceResourceTiming](https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceResourceTiming)类型的对象。 &emsp;&emsp;在《[Largest Contentful Paint 最大内容绘制](https://web.dev/i18n/zh/lcp/)》中提到,选项卡和页面转移到后台后,得停止 LCP 的计算,因此需要找到隐藏到后台的时间。 ~~~ let firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity; // 记录页面隐藏时间 iOS 不会触发 visibilitychange 事件 const onVisibilityChange = (event) => { // 页面不可见状态 if (lcp && document.visibilityState === 'hidden') { firstHiddenTime = event.timeStamp; // 移除事件 document.removeEventListener('visibilitychange', onVisibilityChange, true); } } document.addEventListener('visibilitychange', onVisibilityChange, true); ~~~ &emsp;&emsp;利用[visibilitychange](https://developer.mozilla.org/zh-CN/docs/Web/API/Document/visibilitychange_event)事件,就能准备得到隐藏时间,然后在读取 LCP 时,大于这个时间的就直接忽略掉。不过在实践中发现,在 iOS 的 WebView 中并不支持此事件。 &emsp;&emsp;公司要监测的 H5 页面主要在移动平台,选项卡的情况比较少,并且页面结构比较简单,一般都不会很久就能加载完成。 &emsp;&emsp;largest-contentful-paint 不会计算 iframe 中的元素,返回上一页也不会重新计算。不过,我们的页面中基本不会加载 iframe,并且页面都是以单页的活动为主,跳转也比较少。 &emsp;&emsp;有个成熟的库:[web-vitals](https://github.com/GoogleChrome/web-vitals),提供了 LCP、FID、CLS、FCP 和 TTFB 指标,对上述所说的特殊场景做了处理,若要了解原理,可以参考其中的计算过程。 &emsp;&emsp;注意,LCP 会被一直监控(其监控的元素如下所列),这样会影响结果的准确性。例如有个页面首次进入是个弹框,确定后会出现动画,增加些图片,DOM结构也都会跟着改变。 * img 元素 * 内嵌在 svg 中的 image 元素 * video 元素(使用到封面图片) * 拥有背景图片的元素(调用 CSS 的 url() 函数) * 包含文本节点或或行内文本节点的块级元素 &emsp;&emsp;如果在关闭页面时上报,那么 LCP 将会很长,所以需要选择合适的上报时机,例如 load 事件中。 ~~~ window.addEventListener("load", function () { sendBeacon(); }, false ); ~~~ &emsp;&emsp;优化后还有 5、6 千条记录中的 load 是 0。查看参数记录发现 loadEventEnd 是 0,而 loadEventStart 有时候是 0,有时候有值。 &emsp;&emsp;可以在 load 事件中加个定时器,避免在上报性能参数时,loadEventEnd 为 0,因为此时事件还没执行完毕。 ~~~ window.addEventListener("load", function () { setTimeout(function () { sendBeacon(); }, 0); }, false ); ~~~ &emsp;&emsp;优化后,白屏 1 秒内的占比从 74.2% 提升到了 92.4%,首屏 1 秒内的占比从 48.6% 提升到了 78.8%。 &emsp;&emsp;2022-11-30 发现让 PerformanceObserver 的回调仅触发一次而得到的结果会不准,例如有一个页面,默认展示的是一张表示空内容的图片,然后再去请求列表信息。 &emsp;&emsp;那么第一次回调中选取的最大内容将是这张图片,而用户真正关心的其实是那个后请求的列表。 &emsp;&emsp;所以暂停 LCP 的读取时机要修改一下,参考[web-vitals](https://github.com/GoogleChrome/web-vitals/blob/main/src/onLCP.ts#L92)的代码,当有按键或点击(包括滚动)时,就停止 LCP 的采样。 ~~~ function getLCP() { var lcpType = "largest-contentful-paint"; var isSupport = checkSupportPerformanceObserver(lcpType); // 浏览器兼容判断 if (!isSupport) { return; } var po = new PerformanceObserver(function (entryList) { var entries = entryList.getEntries(); var lastEntry = entries[entries.length - 1]; shin.lcp = { time: _rounded(lastEntry.renderTime || lastEntry.loadTime), // 取整 url: lastEntry.url, element: lastEntry.element ? lastEntry.element.outerHTML : "" }; }); // buffered 为 true 表示调用 observe() 之前的也算进来 po.observe({ type: lcpType, buffered: true }); /** * 当有按键或点击(包括滚动)时,就停止 LCP 的采样 * once 参数是指事件被调用一次后就会被移除 */ ["keydown", "click"].forEach((type) => { window.addEventListener( type, function () { // 断开此观察者的连接 po.disconnect(); }, { once: true, capture: true } ); }); } ~~~ **5)FID** &emsp;&emsp;这个[FID](https://developer.mozilla.org/en-US/docs/Glossary/First_input_delay)(First Input Delay)是用户第一次与页面交互(例如点击链接、按钮等操作)到浏览器对交互作出响应的时间,于 2022-08-16 新增该指标。 &emsp;&emsp;延迟时间越长,用户体验越差。减少站点初始化时间和消除冗长的任务有助于消除首次输入延迟。 &emsp;&emsp;计算方式和 LCP 类似,也是借助 PerformanceObserver 实现,提炼了一个通用的判断方法 checkSupportPerformanceObserver()。 ~~~ /** * 判断当前宿主环境是否支持 PerformanceObserver * 并且支持某个特定的类型 */ function checkSupportPerformanceObserver(type) { if (!PerformanceObserver) return false; var types = PerformanceObserver.supportedEntryTypes; // 浏览器兼容判断 if (types.indexOf(type) === -1) { return false; } return true; } /** * 浏览器 FID 计算 * FID(First Input Delay)用户第一次与页面交互到浏览器对交互作出响应的时间 * https://developer.mozilla.org/en-US/docs/Glossary/First_input_delay */ function getFID() { var fidType = "first-input"; var isSupport = checkSupportPerformanceObserver(fidType); // 浏览器兼容判断 if (!isSupport) { return; } new PerformanceObserver(function (entryList, obs) { const firstInput = entryList.getEntries()[0]; // 测量第一个输入事件的延迟 shin.fid = _rounded(firstInput.processingStart - firstInput.startTime); // 断开此观察者的连接,因为回调仅触发一次 obs.disconnect(); }).observe({ type: fidType, buffered: true }); } getFID(); ~~~ &emsp;&emsp;还有一个与交互有关的指标:[TTI](https://developer.mozilla.org/en-US/docs/Glossary/Time_to_interactive),TTI(Time to Interactive)可测量页面从开始加载到主要子资源完成渲染,并能够快速、可靠地响应用户输入所需的时间。 &emsp;&emsp;它的计算规则比较繁琐: 1. 先找到 FCP 的时间点。 2. 沿时间轴正向搜索时长至少为 5 秒的安静窗口,其中安静窗口的定义为:没有长任务([Long Task](https://developer.mozilla.org/en-US/docs/Web/API/Long_Tasks_API))且不超过两个正在处理的网络 GET 请求。 3. 沿时间轴反向搜索安静窗口之前的最后一个长任务,如果没有找到长任务,则在 FCP 处终止。 4. TTI 是安静窗口之前最后一个长任务的结束时间,如果没有找到长任务,则与 FCP 值相同。 &emsp;&emsp;下图有助于更直观的了解上述步骤,其中数字与步骤对应,竖的橙色虚线就是 TTI 的时间点。 :-: ![](https://img.kancloud.cn/33/d2/33d2c362f6556e444962965eacffc07b_1654x993.png =600x) &emsp;&emsp;TBT(Total Blocking Time)是指页面从 FCP 到 TTI 之间的阻塞时间,一般用来量化主线程在空闲之前的繁忙程度。 &emsp;&emsp;它的计算方式就是取 FCP 和 TTI 之间的所有长任务消耗的时间总和。 &emsp;&emsp;不过网上[有些资料](https://web.dev/tti/)认为 TTI 可能会受当前环境的影响而导致测量结果不准确,因此更适合在实验工具中测量,例如[LightHouse](https://github.com/GoogleChrome/lighthouse)、[WebPageTest](https://www.webpagetest.org/)等。 &emsp;&emsp;Google 的[TTI Polyfill](https://github.com/GoogleChromeLabs/tti-polyfill)库的第一句话就是不建议在线上搜集 TTI,建议使用 FID。 &emsp;&emsp;下表是关键指标的基准线,参考字节的标准。 | Metric Name | Good(ms) | Needs Improvement(ms) | Poor(ms) | | --- | --- | --- | --- | | FP | 0-1000 | 1000-2500 | Over 2500 | | FCP | 0-1800 | 1800-3000 | Over 3000 | | LCP | 0-2500 | 2500-4000 | Over 4000 | | TTI | 0-3800 | 3800-7300 | Over 7300 | | FID | 0-100 | 100-300 | Over 300 | **6)FMP** &emsp;&emsp;2023-01-06 研究了网上开源的各类算法中,初步总结了一套计算 FMP 的步骤。 &emsp;&emsp;首先,通过[MutationObserver](https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver)监听每一次页面整体的 DOM 变化,触发 MutationObserver 的回调。 &emsp;&emsp;然后在回调中,为每个 HTML 元素(不包括忽略的元素)打上标记,记录元素是在哪一次回调中增加的,并且用数组记录每一次的回调时间。 ~~~ var IGNORE_TAG_SET = ["SCRIPT", "STYLE", "META", "HEAD", "LINK"]; var WW = window.innerWidth; var WH = window.innerHeight; var cacheTrees = []; // 缓存每次更新的DOM元素 var FMP_ATTRIBUTE = "_ts"; var fmpObserver; var callbackCount = 0; /** * 开始监控DOM的变化 */ function initFMP() { fmpObserver = new MutationObserver(() => { var mutationsList = []; // 为 HTML 元素打标记,记录是哪一次的 DOM 更新 var doTag = function (target, callbackCount) { var childrenLen = target.children ? target.children.length : 0; // 结束递归 if (childrenLen === 0) return; for (var children = target.children, i = childrenLen - 1; i >= 0; i--) { var child = children[i]; var tagName = child.tagName; if ( child.getAttribute(FMP_ATTRIBUTE) === null && IGNORE_TAG_SET.indexOf(tagName) === -1 // 过滤掉忽略的元素 ) { child.setAttribute(FMP_ATTRIBUTE, callbackCount); mutationsList.push(child); // 记录更新的元素 } // 继续递归 doTag(child, callbackCount); } }; // 从 body 元素开始遍历 document.body && doTag(document.body, callbackCount++); cacheTrees.push({ ts: performance.now(), children: mutationsList }); }); fmpObserver.observe(document, { childList: true, // 监控子元素 subtree: true // 监控后代元素 }); } initFMP(); ~~~ &emsp;&emsp;接着在触发 load 事件时,先过滤掉首屏外和没有高度的元素,以及元素列表之间有包括关系的祖先元素,再计算各次变化时剩余元素的总分。 &emsp;&emsp;之前是只记录没有后代的元素,但是后面发现有时候 DOM 变化时,没有这类元素。 ~~~ /** * 是否超出屏幕外 */ function isOutScreen(node) { var { left, top } = node.getBoundingClientRect(); return WH < top || WW < left; } /** * 读取 FMP 信息 */ function getFMP() { fmpObserver.disconnect(); // 停止监听 var maxObj = { score: -1, //最高分 elements: [], // 首屏元素 ts: 0 // DOM变化时的时间戳 }; // 遍历DOM数组,并计算它们的得分 cacheTrees.forEach((tree) => { var score = 0; // 首屏内的元素 var firstScreenElements = []; tree.children.forEach((node) => { // 只记录元素 if (node.nodeType !== 1 || IGNORE_TAG_SET.indexOf(node.tagName) >= 0) { return; } var { height } = node.getBoundingClientRect(); // 过滤高度为 0,在首屏外的元素 if (height > 0 && !isOutScreen(node)) { firstScreenElements.push(node); } }); // 若首屏中的一个元素是另一个元素的后代,则过滤掉该祖先元素 firstScreenElements = firstScreenElements.filter((node) => { // 只要找到一次包含关系,就过滤掉 var notFind = !firstScreenElements.some( (item) => node !== item && node.contains(item) ); // 计算总得分 if (notFind) { score += caculateScore(node); } return notFind; }); // 得到最高值 if (maxObj.score < score) { maxObj.score = score; maxObj.elements = firstScreenElements; maxObj.ts = tree.ts; } }); // 在得分最高的首屏元素中,找出最长的耗时 return getElementMaxTimeConsuming(maxObj.elements, maxObj.ts); } ~~~ &emsp;&emsp;不同类型的元素,权重也是不同的,权重越高,对页面呈现的影响也越大。 &emsp;&emsp;在 caculateScore() 函数中,通过[getComputedStyle](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/getComputedStyle)得到 CSS 类中的背景图属性,注意,node.style 只能得到内联样式中的属性。 ~~~ var TAG_WEIGHT_MAP = { SVG: 2, IMG: 2, CANVAS: 4, OBJECT: 4, EMBED: 4, VIDEO: 4 }; /** * 计算元素分值 */ function caculateScore(node) { var { width, height } = node.getBoundingClientRect(); var weight = TAG_WEIGHT_MAP[node.tagName] || 1; if ( weight === 1 && window.getComputedStyle(node)["background-image"] && // 读取CSS样式中的背景图属性 window.getComputedStyle(node)["background-image"] !== "initial" ) { weight = TAG_WEIGHT_MAP["IMG"]; //将有图片背景的普通元素 权重设置为img } return width * height * weight; } ~~~ &emsp;&emsp;最后在得到分数最大值后,从这些元素中挑选出最长的耗时,作为 FMP。 ~~~ /** * 读取首屏内元素的最长耗时 */ function getElementMaxTimeConsuming(elements, observerTime) { // 记录静态资源的响应结束时间 var resources = {}; // 遍历静态资源的时间信息 performance.getEntries().forEach((item) => { resources[item.name] = item.responseEnd; }); var maxObj = { ts: observerTime, element: "" }; elements.forEach((node) => { var stage = node.getAttribute(FMP_ATTRIBUTE); ts = stage ? cacheTrees[stage].ts : 0; // 从缓存中读取时间 switch (node.tagName) { case "IMG": ts = resources[node.src]; break; case "VIDEO": ts = resources[node.src]; !ts && (ts = resources[node.poster]); // 读取封面 break; default: // 读取背景图地址 var match = window.getComputedStyle(node)["background-image"].match(/url\(\"(.*?)\"\)/); if (!match) break; var src; // 判断是否包含协议 if (match && match[1]) { src = match[1]; } if (src.indexOf("http") == -1) { src = location.protocol + match[1]; } ts = resources[src]; break; } if (ts > maxObj.ts) { maxObj.ts = ts; maxObj.element = node; } }); return maxObj; } ~~~ ## 二、存储 **1)性能数据日志** &emsp;&emsp;性能数据会被存储到 web\_performance 表中,同样在接收时会通过队列来异步新增。 ~~~ CREATE TABLE `web_performance` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `load` int(11) NOT NULL DEFAULT '0' COMMENT '页面加载总时间', `ready` int(11) NOT NULL DEFAULT '0' COMMENT '用户可操作时间', `paint` int(11) NOT NULL DEFAULT '0' COMMENT '白屏时间', `screen` int(11) NOT NULL DEFAULT '0' COMMENT '首屏时间', `measure` varchar(1000) COLLATE utf8mb4_bin NOT NULL COMMENT '其它测量参数,用JSON格式保存', `ctime` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `day` int(11) NOT NULL COMMENT '格式化的天(冗余字段),用于排序,20210322', `hour` tinyint(2) NOT NULL COMMENT '格式化的小时(冗余字段),用于分组,11', `minute` tinyint(2) DEFAULT NULL COMMENT '格式化的分钟(冗余字段),用于分组,20', `identity` varchar(30) COLLATE utf8mb4_bin NOT NULL COMMENT '身份', `project` varchar(20) COLLATE utf8mb4_bin NOT NULL COMMENT '项目关键字,关联 web_performance_project 表中的key', `ua` varchar(600) COLLATE utf8mb4_bin NOT NULL COMMENT '代理信息', `referer` varchar(200) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源地址', `referer_path` varchar(45) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源地址中的路径', `timing` text COLLATE utf8mb4_bin COMMENT '浏览器读取到的性能参数,用于排查', `resource` text COLLATE utf8mb4_bin COMMENT '静态资源信息', PRIMARY KEY (`id`), KEY `idx_project_day` (`project`,`day`), KEY `idx_project_day_hour` (`project`,`day`,`hour`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='性能监控' ~~~ &emsp;&emsp;表中的 project 字段会关联 web\_performance\_project 表中的key。 &emsp;&emsp;2023-01-09 增加 referer_path 字段,用于分析指定页面的性能。 **2)性能项目** &emsp;&emsp;性能项目就是要监控的页面,与之前不同,性能的监控粒度会更细,因此需要有个后台专门管理这类数据。 ~~~ CREATE TABLE `web_performance_project` ( `id` int(11) NOT NULL AUTO_INCREMENT, `key` varchar(20) COLLATE utf8mb4_bin NOT NULL COMMENT '唯一值', `name` varchar(45) COLLATE utf8mb4_bin NOT NULL COMMENT '项目名称', `ctime` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1:正常 0:删除', PRIMARY KEY (`id`), UNIQUE KEY `name_UNIQUE` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='性能监控项目'; ~~~ &emsp;&emsp;目前做的也比较简单,通过名称得到 16位MD5 字符串,引入 Node.js 的 cryto 库。 ~~~ const crypto = require('crypto'); const key = crypto.createHash('md5').update(name).digest('hex').substring(0, 16); ~~~ &emsp;&emsp;2022-11-25 新增查询、分页和编辑,允许更改项目名称,但是 key 要保持不变。 &emsp;&emsp;可以对长期维护的网页创建单独的性能项目,对于那些临时活动可以共用一个项目。 :-: ![](https://img.kancloud.cn/f5/63/f5636f7c5be6f22f2402305caa9ea352_1914x1244.png =800x) ## 三、分析 **1)性能看板** &emsp;&emsp;在性能看板中,会有四张折线图,当要统计一天的数据时,横坐标为小时(0~23),纵坐标为在这个小时内正序后处于 95% 位置的日志,也就是 95% 的用户打开页面的时间。 &emsp;&emsp;这种写法也叫 TP95,TP 是 Top Percentile 的缩写,不用性能平均数是因为那么做不科学。 :-: ![](https://img.kancloud.cn/44/42/444255b86a98cef43fed79d353c850c4_1872x1270.png =800x) &emsp;&emsp;过滤条件还可以选择具体的小时,此时横坐标为分钟,纵坐标为在这个分钟内正序后处于 95% 位置的日志。 &emsp;&emsp;点击图表的 label 部分,可以在后面列表中显示日志细节,其中原始参数就是从浏览器中得到的计算前的性能数据。 :-: ![](https://img.kancloud.cn/06/cb/06cbfae371ce714df1b4703260bf9727_2962x888.png =800x) &emsp;&emsp;后面又增加了对比功能,就是将几天的数据放在一起对比,可更加直观的展示趋势。 :-: ![](https://img.kancloud.cn/ba/58/ba584023a63e72c7623bff690ef4d4c7_2946x998.png =800x) **2)定时任务** &emsp;&emsp;在每天的凌晨 3点30 分,统计昨天的日志信息。 &emsp;&emsp;本来是计划 web\_performance\_statis 表中每天只有一条记录,所有性能项目的统计信息都塞到 statis 字段中,并且会包含各个对应的日志。 &emsp;&emsp;但奈何数据量实在太大,超出了 MySQL 中 TEXT 类型的范围,没办法塞进去,后面就只存储 id 并且一个项目每天各一条记录。 &emsp;&emsp;数据结构如下,其中 loadZero 是指未执行load事件的数量。 ~~~ { hour: { x: [11, 14], load: ["158", "162"], ready: ["157", "162"], paint: ["158", "162"], screen: ["157", "162"], loadZero: 1 }, minute: { 11: { x: [11, 18, 30], load: ["157", "159", "160"], ready: ["156", "159", "160"], paint: ["157", "159", "160"], screen: ["156", "159", "160"], loadZero: 1 }, 14: { x: [9, 16, 17, 18], load: ["161", "163", "164", "165"], ready: ["161", "163", "164", "165"], paint: ["161", "163", "164", "165"], screen: ["161", "163", "164", "165"], loadZero: 0 } } } ~~~ &emsp;&emsp;还有个定时任务会在每天的凌晨 4点30 分执行,将四周前的 web\_performance\_statis 和 web\_performance 两张表中的数据清除。 **3)资源瀑布图** &emsp;&emsp;2022-07-08 新增了资源瀑布图的功能,就是查看当时的资源加载情况。 &emsp;&emsp;在上报性能参数时,将静态资源的耗时,也一起打包。[getEntriesByType()](https://developer.mozilla.org/zh-CN/docs/Web/API/Performance/getEntriesByType) 方法可返回给定类型的 PerformanceEntry 列表。 ~~~ const resources = performance.getEntriesByType('resource'); const newResources = []; resources && resources.forEach(value => { const { name, duration, startTime, initiatorType} = value; // 过滤 fetch 请求 if(initiatorType === 'fetch') return; // 只存储 1 分钟内的资源 if(startTime > 60000) return; newResources.push({ name, duration: Math.round(duration), startTime: Math.round(startTime), }) }); obj.resource = newResources; ~~~ &emsp;&emsp;代码中会过滤掉 fetch 请求,因为我本地业务请求使用的是[XMLHTTPRequest](https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest),只在上报监控数据时采用了 fetch() 函数。 &emsp;&emsp;并且我只会搜集 1 分钟内的资源,1 分钟以外的资源我都会舍弃,只记录名称、耗时和开始时间。 &emsp;&emsp;最终的效果如下图所示,包含一个横向的柱状图和查询区域,目前只开放了根据 ID 查询。 :-: ![](https://img.kancloud.cn/c7/5c/c75c43f3d4040d49c8424bc87db4b0de_1714x1074.png =800x) &emsp;&emsp;2022-08-17 在资源瀑布图中标注白屏和首屏的时间点,可对资源的加载做更直观的比较,便于定位性能问题。 &emsp;&emsp;2022-12-28 在资源瀑布图中,增加 load 和 DOMContentLoaded 两个事件触发的时间点。 :-: ![](https://img.kancloud.cn/ca/ce/caceb72013f65604ca7c5239866c0e81_2030x1006.png =800x) **4)堆叠柱状图** &emsp;&emsp;先将所有的性能记录统计出来,然后分别统计白屏和首屏 1 秒内的数量、1-2 秒内、2-3 秒内、3-4 秒内、4+秒的数量,白屏的 SQL 如下所示。 ~~~ SELECT COUNT(*) FROM `web_performance` WHERE `ctime` >= '2022-06-12 00:00' and `ctime` < '2022-06-13 00:00'; SELECT COUNT(*) FROM `web_performance` WHERE `paint` <= 1000 and `ctime` >= '2022-06-12 00:00' and `ctime` < '2022-06-13 00:00'; SELECT COUNT(*) FROM `web_performance` WHERE `paint` > 1000 and `paint` <= 2000 and `ctime` >= '2022-06-12 00:00' and `ctime` < '2022-06-13 00:00'; SELECT COUNT(*) FROM `web_performance` WHERE `paint` > 2000 and `paint` <= 3000 and `ctime` >= '2022-06-12 00:00' and `ctime` < '2022-06-13 00:00'; SELECT COUNT(*) FROM `web_performance` WHERE `paint` > 3000 and `paint` <= 4000 and `ctime` >= '2022-06-12 00:00' and `ctime` < '2022-06-13 00:00'; SELECT COUNT(*) FROM `web_performance` WHERE `paint` > 4000 `ctime` >= '2022-06-12 00:00' and `ctime` < '2022-06-13 00:00'; ~~~ &emsp;&emsp;算出后,分母为总数,分子为上述五个值,组成一张堆叠柱状图,类似于下面这样,每种颜色代码一个占比。 :-: ![](https://img.kancloud.cn/c1/a8/c1a8c221a50165ea7c4d050c37a3d67f_800x531.png =600x) &emsp;&emsp;这样就能直观的看到优化后的性能变化了,更快的反馈优化结果。 &emsp;&emsp;2024-03-19 将白屏和首屏的堆叠柱状图修改成堆叠面积图,为了能更好的查看变化趋势。 :-: ![](https://img.kancloud.cn/f0/90/f0902ad8a4a7613dae5f9e4cc68deae8_1682x796.png =600x) **5)阶段时序图** &emsp;&emsp;在将统计的参数全部计算出来后,为了能更直观的发现性能瓶颈,设计了一张阶段时序图。 &emsp;&emsp;描绘出 TTFB、responseDocumentTime、initDomTreeTime、parseDomTime 和 loadEventTime 所占用的时间,如下所示。 &emsp;&emsp;橙色竖线表示白屏时间,黑色竖线表示首屏时间。移动到 id 或来源地址,就会提示各类参数。 :-: ![](https://img.kancloud.cn/d9/47/d947b7f1216baf7e50101fff03017710_2978x1082.png =800x) &emsp;&emsp;2023-01-09 增加身份和来源路径两个条件,当查到某条错误日志后,可以通过身份将两类日志关联,查看当时的性能数据。 &emsp;&emsp;来源路径便于查看某一张页面的性能日志,便于做针对性的优化。 :-: ![](https://img.kancloud.cn/75/8d/758d46f9d79ae88b42a6736856e26802_1456x282.png =800x) &emsp;&emsp;TTFB 的计算包括 redirectTime、appcacheTime、lookupDomainTime、connectTime 以及 requestDocumentTime 的和。 &emsp;&emsp;并且因为 requestStart 和 connectEnd 之间的时间(即 TCP 连接建立后到发送请求这段时间)没有算,所以会比这个和大。 &emsp;&emsp;responseDocumentTime 就是接收响应内容的时间。initDomTreeTime 是构建 DOM 树并执行网页阻塞的脚本的时间,在这个阶段,具有 defer 属性的脚本还没有执行。 &emsp;&emsp;parseDomTime 是解析 DOM 树结构的时间,DOM 中的所有脚本,包括具有 async 属性的脚本也会执行,还会加载页面中的静态资源,例如图像、iframe 等。 &emsp;&emsp;parseDomTime 是 domComplete 和 domInteractive 相减得到的差。loadEventStart 会紧跟在 domComplete 之后,而在大多数情况下,这 2 个指标是相等的。 &emsp;&emsp;loadEventTime 就是执行 onload 事件的时间,一般都比较短。 &emsp;&emsp;观察下来,如果是 TTFB 比较长,那么就是 NDS 查询、TCP 连接后的请求等问题。 &emsp;&emsp;initDomTreeTime 过长的话,就需要给脚本瘦身了;parseDomTime过长的话,就需要减少资源的请求。 **参考:** [前端性能监控及推荐几个开源的监控系统](https://cloud.tencent.com/developer/news/682347) [如何进行 web 性能监控?](http://www.alloyteam.com/2020/01/14184/) [蚂蚁金服如何把前端性能监控做到极致?](https://www.infoq.cn/article/dxa8am44oz*lukk5ufhy) [5 分钟撸一个前端性能监控工具](https://juejin.cn/post/6844903662020460552) [10分钟彻底搞懂前端页面性能监控](https://zhuanlan.zhihu.com/p/82981365) [Navigation\_and\_resource\_timings](https://developer.mozilla.org/en-US/docs/Web/Performance/Navigation_and_resource_timings) [PerformanceNavigationTiming](https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceNavigationTiming) [Time to Interactive: Focusing on the Human-Centric Metrics](https://calibreapp.com/blog/time-to-interactive) [Time to Interactive Explainer](https://github.com/WICG/time-to-interactive) [前端监控 SDK 的技术要点原理分析](https://www.freecodecamp.org/chinese/news/tech-analysis-of-front-end-monitoring-sdk) ***** > 原文出处: [博客园-从零开始搞系列](https://www.cnblogs.com/strick/category/1928903.html) 已建立一个微信前端交流群,如要进群,请先加微信号freedom20180706或扫描下面的二维码,请求中需注明“看云加群”,在通过请求后就会把你拉进来。还搜集整理了一套[面试资料](https://github.com/pwstrick/daily),欢迎阅读。 ![](https://box.kancloud.cn/2e1f8ecf9512ecdd2fcaae8250e7d48a_430x430.jpg =200x200) 推荐一款前端监控脚本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不仅能监控前端的错误、通信、打印等行为,还能计算各类性能参数,包括 FMP、LCP、FP 等。