前端页面监控-性能

Performance 接口为我们提供了获取页面中性能相关信息的能力,通过这些信息可以计算出页面性能相关的各项数据,用于监控和优化页面性能。

导航计时

导航是加载 Web 页面的第一步。它发生在以下情形:用户通过在地址栏输入一个 URL、点击一个链接、提交表单或者是其他的行为。

我们先来看一下一个页面的导航过程

从打开一个页面(输入/跳转)到页面完全渲染完成,主要有以下几个过程

  • DNS 查找

    对于一个 Web 页面来说导航的第一步是要去寻找页面资源的位置。浏览器向域名服务器发请求,进行DNS查询,最终会返回一个IP地址(这个IP地址可能会被缓存一段时间),后续使用这个IP地址进行交互。

  • TCP 握手

    一旦获取到服务器IP地址,浏览器就会通过TCP"三次握手"(SYN-SYN-ACK)与服务器建立连接。

    为了在HTTPS上建立安全连接,会进行SSL/TLS握手协商 ,从而在进行真实的数据传输之前建立安全连接。可以参考 HTTPS SSL/TLS握手

  • 请求&响应

    一旦我们建立了到web服务器的连接,浏览器就发送一个初始的HTTP GET请求,对于网站来说,这个请求通常是一个HTML文件。 一旦服务器收到请求,它将使用相关的响应头和HTML的内容进行回复。

  • 解析

    一旦浏览器收到数据的第一块,它就可以开始解析收到的信息,包括构建Dom树、构建CSSOM树、编译JavaScript等。

  • 渲染

    渲染步骤包括样式、布局、合成、绘制等,最终将内容绘制到屏幕上。

我们已经了解了导航的整个过程,接下来我们看一下如何获取导航相关计时数据。可以通过以下方法获取导航计时对象

                
                    var timing = window.performance.getEntriesByType('navigation')[0];
                
            

返回结果是一个PerformanceNavigationTiming对象,数据如下所示

                
                    {
                      "name": "https://www.youtube.com/",
                      "type": "navigate",
                      "startTime": 0,
                      "unloadEventStart": 0,
                      "unloadEventEnd": 0,
                      "redirectStart": 0,
                      "redirectEnd": 0,
                      "fetchStart": 8.835000000544824,
                      "domainLookupStart": 12.484999999287538,
                      "domainLookupEnd": 66.08499999856576,
                      "connectStart": 66.08499999856576,
                      "secureConnectionStart": 103.01500000059605,
                      "connectEnd": 163.5699999969802,
                      "requestStart": 163.7300000002142,
                      "responseStart": 268.33999999507796,
                      "responseEnd": 627.1249999990687,
                      "domInteractive": 6270.304999998189,
                      "domContentLoadedEventStart": 6274.839999998221,
                      "domContentLoadedEventEnd": 6276.599999997416,
                      "domComplete": 7754.919999999402,
                      "loadEventStart": 7755.279999997583,
                      "loadEventEnd": 7761.189999997441,
                      "duration": 7761.189999997441,
                      "redirectCount": 0,

                      "entryType": "navigation",
                      "initiatorType": "navigation",
                      "nextHopProtocol": "h2",
                      "workerStart": 0,
                      "transferSize": 59303,
                      "encodedBodySize": 58569,
                      "decodedBodySize": 479256,
                      "serverTiming": [],
                      "workerTiming": []
                    }
                
                    

各项参数说明如下

参数 描述
name 当前导航地址。
type 标识当前导航的类型,有"navigate", "reload", "back_forward" or "prerender"四个可选值。
redirectCount 表示自当前浏览上下文中上次非重定向导航以来的重定向次数的数字。如果是跨域重定向,则改值为0
startTime 浏览器开始unload前一个页面文档的开始时间节点。
unloadEventStart 抛出unload卸载事件后,指示窗口中上一个文档开始卸载的时间。 如果没有先前的文档,或者先前的文档或所需的重定向之一不是同一来源,则返回的值为0。
unloadEventEnd unload卸载事件处理程序完成时。 如果没有先前的文档,或者先前的文档或所需的重定向之一不是同一来源,则返回的值为0。
redirectStart 当第一个HTTP重定向开始时。如果没有重定向,或者其中一个重定向源不相同,则返回的值为0。
redirectEnd 当最后一个HTTP重定向完成时,即已收到HTTP响应的最后一个字节。 如果没有重定向,或者其中一个重定向源不相同,则返回值为0。
fetchStart 当浏览器准备好使用HTTP请求获取文档时。 此刻在检查任何应用程序缓存之前。
domainLookupStart 当域查找开始时。 如果使用持久连接,或者信息存储在缓存或本地资源中,则该值将与PerformanceTiming.fetchStart相同。
domainLookupEnd 域查找完成后。 如果使用持久连接,或者信息存储在缓存或本地资源中,则该值将与PerformanceTiming.fetchStart相同。
connectStart 当打开连接的请求发送到网络时。 如果传输层报告错误,并且连接建立再次开始,则给出最后的连接建立开始时间。 如果使用持久连接,则该值将与PerformanceTiming.fetchStart相同。
secureConnectionStart 安全连接握手开始时。如果不请求此类连接,则返回0。
connectEnd 打开连接网络时。 如果传输层报告错误,并且连接建立再次开始,则给出最后的连接建立结束时间。 如果使用持久连接,则该值将与PerformanceTiming.fetchStart相同。 当所有安全连接握手或SOCKS身份验证终止时,连接被视为已打开。
requestStart 当浏览器发送请求以从服务器或缓存中获取实际文档时。 如果在请求开始后传输层失败,并且连接重新打开,则此属性将设置为与新请求对应的时间。
responseStart 当浏览器从服务器从缓存或本地资源接收到响应的第一个字节时。
responseEnd 当浏览器收到响应的最后一个字节时,或者如果第一次发生则关闭连接时,则来自服务器,缓存或本地资源。
domInteractive 解析器完成对主文档的工作时,即其Document.readyState更改为“ interactive”,并且引发了相应的readystatechange事件。
domContentLoadedEventStart 在解析器发送DOMContentLoaded事件之前,即在解析后立即需要执行的所有脚本之后。
domContentLoadedEventEnd 在所有需要尽快执行的脚本(无论是否按顺序执行)之后。
domComplete 解析器完成对主文档的工作时,即其Document.readyState更改为“ complete”,并且引发了相应的readystatechange事件。
loadEventStart 为当前文档发送加载事件的时间。 如果尚未发送此事件,则返回0。
loadEventEnd 当加载事件处理程序终止时,即加载事件完成时。 如果此事件尚未发送或尚未完成,则返回0。
duration loadEventEnd和startTime 属性之间的差值。

以上计时相关数据,单位都是毫秒ms(1秒=1000 毫秒)

根据导航过程和计时对象,我们可以计算出导航各个过程的时间数据

                
                    /*
                     导航类型
                     navigate/reload/back_forward/prerender
                     */
                    "type": timing.type,
                    /*
                     地址
                     */
                    "name": timing.name,
                    /*
                     导航/资源的类型,navigation/link/script/css/img/iframe
                     */
                    "initiatorType": timing.initiatorType,
                    /*
                    重定向计数
                    */
                    "redirectCount": timing.redirectCount | 0,
                    /*
                    重定向时间
                    -- 直接使用重定向之后的地址,可以减少重定向耗时。
                    */
                    "redirect": timing.redirectEnd - timing.redirectStart,
                    /*
                    DNS查询时间
                    -- 减少DNS的请求次数
                    -- DNS预获取 
                    */
                    "domainLookup": timing.domainLookupEnd - timing.domainLookupStart,
                    /*
                    TCP连接时间
                    -- 减少HTTP的请求数量,
                        1#资源合并
                        2#长链接
                    */
                    "connect": timing.connectEnd - timing.connectStart,
                    /*
                    SSL/TLS协商时间
                    */
                    "secureConnection": timing.connectEnd - timing.secureConnectionStart,
                    /*
                    第一字节的到达时间,浏览器请求页面与从服务器接收到信息的第一字节之间的时间
                    */
                    "ttfb": timing.responseStart - timing.requestStart,
                    /*
                     内容下载时间
                     -- 内容压缩,gzip
                     */
                    "contentDownload": timing.responseEnd - timing.responseStart,
                    /*
                     整个导航完成/资源可用时间
                     */
                    "duration": timing.duration,
                    /*
                    执行 onload 回调函数的时间
                    -- 减少onload回调函数操作,
                    */
                    "loadEvent": timing.loadEventEnd - timing.loadEventStart,
                    /*
                    解析dom树时间
                    -- 减少dom树嵌套
                    */
                    "domReady": timing.domComplete - timing.domInteractive
                
            

对照下Chrome中的Network Timing

另外一种获取导航相关计时参数的方法是通过 window.performance.timing , 返回结果是一个 PerformanceTiming 对象,这是一个不推荐使用,但是仍然可用的接口,虽然功能没有PerformanceNavigationTiming完善,但是浏览器支持性更好,具体使用,请参考实现代码。

资源计时

导航计时用于计算整个主页的性能,包括HTML文件和HTML文件中所需的各项资源的计时集合。

而资源计时用于测量单个资源,比如css/js/img等。跟导航计时很相似: 有一个DNS查找,TCP握手,建立HTTPS安全连接和请求&响应。

可以通过以下方法获取各个资源的计时数据

                
                    performance.getEntriesByType('resource').forEach(function (timing) {
                      console.dir(timing);
                    });
                
            

返回结果为资源相关的PerformanceNavigationTiming对象,具体参数请参考导航计时部分。

实现代码

以下我的代码,实现了异常和性能监控,当页面加载完成以后,会将超出阈值的性能参数传送到服务器,异常处理部分请参考 前端页面监控-异常

                
                    /**
                     * taozh1982@gmail.com
                     * www.zhangyiheng.com
                     */
                    (function (window) {
                        var $monitor_server_url = "";//服务器地址
                        var $monitor_performance_navigation = {//导航计时阈值
                            duration: 10000
                        };
                        var $monitor_performance_resource = {//资源计时阈值
                            duration: 10000
                        };
                        var $Monitor = {
                            init: function () {
                                $Monitor.initErrMonitor();
                                $Monitor.initPerformanceMonitor();
                            },
                            initErrMonitor: function () {
                                window.addEventListener('error', function (event) {
                                    if (event instanceof ErrorEvent) {
                                        $Monitor.handleJSErr(event);
                                    } else if (event instanceof Event) {
                                        $Monitor.handleResourceErr(event);
                                    }
                                }, true);
                                window.addEventListener('unhandledrejection', $Monitor.handleUnhandledRejection, true);
                            },
                            handleJSErr: function (event) {
                                // console.log("js error");
                                $Monitor.sendErrToServer({
                                    error_type: "javascript",
                                    error: {
                                        lineno: event.lineno,
                                        colno: event.colno,
                                        message: event.message,
                                        stack: event.error.stack
                                    }
                                }, event);
                            },
                            handleResourceErr: function (event) {
                                // console.log("resource error");
                                $Monitor.sendErrToServer({
                                    error_type: "resource",
                                    error: {
                                        target: event.target.outerHTML
                                    }
                                }, event);
                            },
                            handleUnhandledRejection: function (event) {
                                // console.log("unhandledrejection");
                                $Monitor.sendErrToServer({
                                    error_type: "unhandledrejection",
                                    error: {
                                        reason: event.reason,
                                        promise: event.promise
                                    }
                                }, event);
                            },
                            sendErrToServer: function (errData, event) {
                                errData.monitor_type = "error";
                                $Monitor._sendToServer(errData);
                            },

                            initPerformanceMonitor: function () {
                                window.addEventListener('load', function (event) {
                                    setTimeout($Monitor.handlePerformance, 1000);
                                });
                            },
                            handlePerformance: function () {
                                var navigationTiming = $Monitor.getNavigationTiming();
                                var timing = {resource: []};
                                if ($Monitor.checkPerformance(navigationTiming, $monitor_performance_navigation) === false) {
                                    timing.navigation = navigationTiming;
                                }

                                var resourceTiming = $Monitor.getResourceTiming();
                                resourceTiming.forEach(function (resourceTimingItem) {
                                    if ($Monitor.checkPerformance(resourceTimingItem, $monitor_performance_resource) === false) {
                                        timing.resource.push(resourceTimingItem);
                                    }
                                });
                                if (timing.navigation || timing.resource.length > 0) {
                                    $Monitor.sendPerformanceToServer({
                                        timing: timing
                                    });
                                }
                            },
                            sendPerformanceToServer: function (performanceData) {
                                performanceData.monitor_type = "performance";
                                $Monitor._sendToServer(performanceData);
                            },
                            checkPerformance: function (timing, monitor_performance) {
                                if (!timing || !monitor_performance) {
                                    return true;
                                }
                                var keys = Object.keys(timing);
                                var len = keys.length;
                                for (var i = 0; i < len; i++) {
                                    var key = keys[i];
                                    if (monitor_performance.hasOwnProperty(key)) {
                                        if (timing[key] > monitor_performance[key]) {
                                            return false;
                                        }
                                    }
                                }
                                return true
                            },
                            getNavigationTiming: function () {
                                var performance = window.performance;
                                if (!performance) {
                                    return null;
                                }
                                var navigationTiming = performance.getEntriesByType('navigation')[0];
                                if (!navigationTiming) {
                                    navigationTiming = performance;
                                    var navigation = navigationTiming.navigation;
                                    var type = navigation.type;
                                    switch (type) {
                                        case 0:
                                            type = "navigate";
                                            break;
                                        case 1:
                                            type = "reload";
                                            break;
                                        case 2:
                                            type = "back_forward ";
                                            break;
                                    }
                                    navigationTiming = navigationTiming.timing.toJSON();
                                    navigationTiming.startTime = navigationTiming.startTime || navigationTiming.navigationStart;
                                    navigationTiming.name = window.location.href;
                                    navigationTiming.type = type;
                                    navigationTiming.initiatorType = "navigation";
                                    navigationTiming.redirectCount = navigation.redirectCount;
                                    navigationTiming.duration = navigationTiming.loadEventEnd - navigationTiming.startTime;
                                } else {
                                    navigationTiming = navigationTiming.toJSON();
                                }
                                return Object.assign($Monitor._getPerformanceTiming(navigationTiming), {
                                    /*
                                     导航类型
                                     navigate/reload/back_forward/prerender
                                     */
                                    type: navigationTiming.type,
                                    /*
                                    执行 onload 回调函数的时间
                                    -- 减少onload回调函数操作,
                                    */
                                    loadEvent: navigationTiming.loadEventEnd - navigationTiming.loadEventStart,
                                    /*
                                    解析dom树时间
                                    -- 减少dom树嵌套
                                    */
                                    domReady: navigationTiming.domComplete - navigationTiming.domInteractive
                                });
                            },
                            getResourceTiming: function () {
                                var timingArr = [];
                                var performance = window.performance;
                                if (!performance) {
                                    return timingArr;
                                }
                                performance.getEntriesByType('resource').forEach(function (timing) {
                                    timingArr.push($Monitor._getPerformanceTiming(timing.toJSON()))
                                });
                                return timingArr;
                            },
                            _getPerformanceTiming: function (timingObj) {
                                return {
                                    /*
                                     地址
                                     */
                                    name: timingObj.name,
                                    /*
                                     导航/资源的类型,navigation/link/script/css/img/iframe
                                     */
                                    initiatorType: timingObj.initiatorType,
                                    /*
                                    重定向计数
                                    */
                                    redirectCount: timingObj.redirectCount | 0,
                                    /*
                                    重定向时间
                                    -- 直接使用重定向之后的地址,可以减少重定向耗时。
                                    */
                                    redirect: timingObj.redirectEnd - timingObj.redirectStart,
                                    /*
                                    DNS查询时间
                                    -- 减少DNS的请求次数
                                    -- DNS预获取 
                                    */
                                    domainLookup: timingObj.domainLookupEnd - timingObj.domainLookupStart,
                                    /*
                                    TCP连接时间
                                    -- 减少HTTP的请求数量,
                                        1#资源合并
                                        2#长链接
                                    */
                                    connect: timingObj.connectEnd - timingObj.connectStart,
                                    /*
                                    SSL/TLS协商时间
                                    */
                                    secureConnection: timingObj.connectEnd - timingObj.secureConnectionStart,
                                    /*
                                    第一字节的到达时间,浏览器请求页面与从服务器接收到信息的第一字节之间的时间
                                    */
                                    ttfb: timingObj.responseStart - timingObj.requestStart,
                                    /*
                                     内容下载时间
                                     -- 内容压缩,gzip
                                     */
                                    contentDownload: timingObj.responseEnd - timingObj.responseStart,
                                    /*
                                     整个导航完成/资源可用时间
                                     */
                                    duration: timingObj.duration
                                }
                            },
                            _sendToServer: function (monitorData) {
                                if (!$monitor_server_url) {
                                    return;
                                }
                                window.setTimeout(function () {
                                    monitorData.url = window.location.href;
                                    var httpRequest = new XMLHttpRequest();
                                    httpRequest.open("POST", $monitor_server_url);
                                    httpRequest.setRequestHeader("Content-Type", "application/json; charset=utf-8");
                                    httpRequest.send(JSON.stringify(monitorData));
                                }, 100);
                            }
                        };
                        $Monitor.init();
                    })(window);
                    //test code
                    //js error test
                    /* window.abc.error = '123'; */

                    //unhandledrejection test
                    /*
                    new Promise(function (resolve, reject) {
                        reject('promise reject');
                    });
                    */

                    //resource error
                    /* <img src="../resource/notfound.png"> */