我们通常通过页面加载速度来评判一个网站的性能,但是,页面加载速度是一个相对模糊且主观的概念,我们该如何拆解并通过技术手段量化这个概念,本文将详细介绍前端性能中的那些关键指标。

Navigation Timing API

为了帮助开发者更好地衡量和改进前端页面性能,W3C性能小组引入了 Navigation Timing API ,提供了更有用和更准确的页面性能数据;开发者可以通过 window.performance 属性获取。

performance.timing定义了从 navigationStartloadEventEnd 的 21 个只读属性。

下图是W3C第一版的 Navigation Timing 的处理模型。从当前浏览器窗口卸载旧页面开始,到新页面加载完成,整个过程一共被切分为 9 个小块:

  1. 提示卸载旧文档
  2. 重定向/卸载
  3. 应用缓存
  4. DNS 解析
  5. TCP 握手
  6. HTTP 请求处理
  7. HTTP 响应处理
  8. DOM 处理
  9. 文档装载完成

每个小块的首尾、中间做事件分界,取 Unix 时间戳,两两事件之间计算时间差,从而获取中间过程的耗时(精确到毫秒级别)。

timing-overview
W3C Navigation Timing Level 1
W3C Navigation Timing Level 2
W3C Navigation Timing Level 2

指标解读

 指标说明
navigationStart表示从上个文档卸载结束时的 unix 时间戳,如果没有上一个文档,该值和fetchStart相等。
unloadEventStart表示上一个文档(与当前文档同域)unload前的时间戳,如果没有上一个文档或者上一个文档与当前文档不同域,则值为0。
unloadEventEnd表示上一个文档(与当前文档同域)unload后的时间戳,如果没有上一个文档或者上一个文档与当前文档不同域,则值为0。
redirectStart如果存在来自同域的HTTP重定向,返回启动重定向时的时间戳,否则该值为0。
redirectEnd如果存在来自同域的HTTP重定向,返回上一个重定向相应的最后一个字节时的时间戳,否则该值为0。
fetchStart是指在浏览器发起任何请求之前的时间戳。在fetchStart和domainLookupStart之间,浏览器会检查当前文档的缓存。
domainLookupStart代表DNS查询的开始时间戳。如果浏览器没有进行DNS查询(比如使用了cache),则值等于fetchStart。
domainLookupEnd代表DNS查询的结束时间戳。如果浏览器没有进行DNS查询(比如使用了cache),则值等于fetchStart。
connectStart开始/重新建立到服务器连接的时间戳,如果是持久链接,则值等于domainLookupEnd。
connectEnd完成建立到服务器连接的时间戳,如果是持久链接,则值等于domainLookupEnd。
secureConnectionStart HTTPS连接开始的时间戳,如果不是安全链接,则值等于0。
requestStartHTTP请求读取真实文档开始的时间戳(完成建立连接),包括从本地读取缓存。
responseStartHTTP开始接受响应的时间戳(获取到最后一个字节),包括从本地读取缓存。
responseEndHTTP响应全部接受完成的时间戳(获取到第一个字节),包括从本地读取缓存。
domLoading代表浏览器开始解析DOM树的时间戳。我们知道IE浏览器下的document有readyState属性,domLoading的值就等于readyState改变为loading的时间节点
domInteractive完成解析DOM树的时间戳。
代表浏览器解析DOM树的状态为interactive时的时间节点。domInteractive并非DOMReady,它早于DOMReady触发,代表html文档解析完毕(即dom tree创建完成)但是内嵌资源(比如外链css、js等)还未加载的时间点;
domContentLoadedEventStartDOM树解析完成后,网页内资源加载开始的时间,在DOMContentLoaded事件抛出前发生,此刻用户可以对页面进行操作,也就是jQuery中的domready时间;
domContentLoadedEventEnd代表DOMContentLoaded事件触发的时间节点:页面文档完全加载并解析完毕之后,会触发DOMContentLoaded事件,HTML文档不会等待样式文件,图片文件,子框架页面的加载(load事件可以用来检测HTML页面是否完全加载完毕(fully-loaded))。
domCompleteDOM树解析完成,且资源也准备就绪的时间。Document.readyState变为complete,并将抛出readystatechange事件。
loadEventStartload函数开始执行的时间
loadEventEndload事件的回调函数执行结束的时间
指标解读

首字节时间

首字节时间指浏览器接收到 HTML 文档第一个字节的时间。此时间点之前,浏览器需要经过 DNS解析、重定向(若有)、建立 TCP/SSL 连接、服务器响应等过程。

性能指标为 responseStart – fetchStart 的时间。

白屏时间

白屏时间(First Paint Time)是用户打开页面到首次看到内容的时间,也叫做首次渲染时间。白屏时间出现在头部外链资源加载完附近,因为浏览器只有加载并解析完头部资源才会真正渲染页面。基于此我们可以通过获取头部资源加载完的时刻来近似统计白屏时间。尽管并不精确,但却考虑了影响白屏的主要因素:首字节时间和头部资源加载时间。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <script>
      var start_time = +new Date(); //测试时间起点,实际统计起点为 DNS 查询
    </script>
    <!-- 3s 后这个 js 才会返回 -->
    <script src="script.php"></script>
    <script>
      var end_time = +new Date(); //时间终点 var headtime = end_time - start_time; //头部资源加载时间 console.log(headtime);
    </script>
  </head>
  <body>
    <p>在头部资源加载完之前页面将是白屏</p>
    <p>
      script.php 被模拟设置 3s 后返回,head 底部内嵌 JS 等待前面 js 返回后才执行
    </p>
    <p>script.php 替换成一个执行长时间循环的 js 效果也一样</p>
  </body>
</html>

上述代码中end_time和start_time的差值即可作为页面的白屏时间。对于支持W3C Navigation Timing的浏览器,一般通过如下指标来计算白屏时间。

(domInteractive || domLoading) – fetchStart

首屏时间

首屏时间的统计比较复杂,目前应用比较广的方案是将首屏的图片、iframe等资源添加onload事件,获取最慢的一个。

首屏位置调用 API 开始统计 -> 绑定首屏内所有图片的 load 事件 -> 页面加载完后判断图片是否在首屏内,找出加载最慢的一张 -> 首屏时间

这是同步加载情况下的简单统计逻辑,另外需要注意的几点:
– 页面存在 iframe 的情况下也需要判断加载时间
– gif 图片在 IE 上可能重复触发 load 事件需排除
– 异步渲染的情况下应在异步获取数据插入之后再计算首屏
– css 重要背景图片可以通过 JS 请求图片 url 来统计(浏览器不会重复加载)
– 没有图片则以统计 JS 执行时间为首屏,即认为文字出现时间

 // 首屏时间计算脚本示例
function first_screen_time() {
    var imgs = document.getElementsByTagName("img");
    var iframes = document.getElementsByTagName("IFRAME");
    var timestamp = +new Date;
    var imgtime = [];
    var that = this;

    function offsetTop(i) {
        var top = 0;
        top = window.pageYOffset ? window.pageYOffset : document.documentElement.scrollTop;
        try {
            top += i.getBoundingClientRect().top;
        } catch (k) {} finally {
            return top;
        }
    }

    var handler = function() {
        if (this.removeEventListener) {
            this.removeEventListener("load", handler, false)
        }
        imgtime.push({
            img: this,
            time: +new Date
        });
    };

    for (var i = 0; i < imgs.length; i++) {
        (function() {
            var img = imgs[i];
            if (img.addEventListener) {
                !img.complete && img.addEventListener("load", handler, false);
            } else {
                if (img.attachEvent) {
                    img.attachEvent("onreadystatechange", function() {
                        if (img.readyState == "complete") {
                            handler.call(img, handler);
                        }
                    })
                }
            }
        })()
    }
    for (var i = 0, d = iframes.length; i < d; i++) {
        (function() {
            var iframe = iframes[i];
            if (iframe.attachEvent) {
                iframe.attachEvent("onload", function() {
                    handler.call(iframe, handler)
                });
            } else {
                iframe.addEventListener("load", handler, false)
            }
        })()
    }
    return function(fsHeight) {
        var fsHeight = fsHeight || document.documentElement.clientHeight;
        for (var i = 0; i < imgtime.length; i++) {
            var item = imgtime[i];
            var dom = item.img;
            var time = item.time;
            var offsetTop = offsetTop(dom);
            if (offsetTop > 0 && offsetTop < fsHeight) {
                timestamp = time > timestamp ? time : timestamp
            }
        }
        return timestamp;
    };
}

用户可操作时间

用户可操作默认可以统计domready时间,因为通常会在这时候绑定事件操作。对于使用了模块化异步加载的 JS 可以在代码中去主动标记重要 JS 的加载时间,这也是产品指标的统计方式。

但在实际项目中,通过domready来作为用户可操作时间显示是不准确的,比如项目中使用了第三方的类库框架开发,用户可操作时间点可能就是在,框架加载完成并且将试图渲染并绑定时间完成时作为用户可操作时间,是一个更加准确的时间。

var ready = (function (callback) {
    var isReady = false;
    var readyList = [];
    // The ready event handler
    var DOMContentLoaded = function( event ) {
        // readyState === "complete" is good enough for us to call the dom ready in oldIE
        if ( document.addEventListener || event.type === 'load' || document.readyState === 'complete' ) {
            detach();
            ready();
        }
    };
    // Clean-up method for dom ready events
    var detach = function() {
        if ( document.addEventListener ) {
            document.removeEventListener('DOMContentLoaded', DOMContentLoaded, false);
            window.removeEventListener('load', DOMContentLoaded, false);
        } else {
            document.detachEvent('onreadystatechange', DOMContentLoaded);
            window.detachEvent('onload', DOMContentLoaded);
        }
    };
    // Handle when the DOM is ready
    function ready() {
        if(!isReady) {
            isReady = true;
            for (var i = 0, j = readyList.length; i < j; i++) {
                readyList[i]();
            }
        }
    }
    // Catch cases where $(document).ready() is called after the browser event has already occurred.
    // we once tried to use readyState "interactive" here, but it caused issues like the one
    // discovered by ChrisS here: http://bugs.jquery.com/ticket/12282#comment:15
    if ( document.readyState === "complete" ) {
        // Handle it asynchronously to allow scripts the opportunity to delay ready
        setTimeout( ready );
    // Standards-based browsers support DOMContentLoaded
    }else if (document.addEventListener) {
        // Use the handy event callback
        document.addEventListener('DOMContentLoaded', DOMContentLoaded, false);
        // A fallback to window.onload, that will always work
        window.addEventListener('load', ready, false);
    // If IE event model is used
    } else {
        // Ensure firing before onload, maybe late but safe also for iframes
        document.attachEvent('onreadystatechange', DOMContentLoaded);
        // A fallback to window.onload, that will always work
        window.attachEvent('onload', ready);
        // If IE and not a frame
        // continually check to see if the document is ready
        var top = false;
        try {
            top = window.frameElement == null && document.documentElement;
        } catch(e) {}
        if (top && top.doScroll) {
            (function() {
                if (!isReady) {
                    try {
                        // Use the trick by Diego Perini
                        // http://javascript.nwbox.com/IEContentLoaded/
                        top.doScroll("left");
                    } catch (e) {
                        return setTimeout(arguments.callee, 1);
                    }
                    // and execute any waiting functions
                    ready();
                }
            })();
        }
    }
    return function(callback) {
        isReady ? callback() : readyList.push(callback)
    }
    // end
})();

总下载时间

总下载时间默认可以统计onload时间,这样可以统计同步加载的资源全部加载完的耗时。如果页面中存在很多异步渲染,可以将异步渲染全部完成的时间作为总下载时间。

var onloader = function(callback) {
    var loaded = function(callback) {
        if ( document.addEventListener ) {
            window.removeEventListener('load', loaded, false );
        } else {
            window.detachEvent('onload', loaded );
        }
        callback();
    };
    if (document.addEventListener) {
        window.addEventListener('load', loaded, false);
    } else {
        window.attachEvent('onload', loaded);
    }
};

网络指标

DNS查询时间

= domainLookupEnd – domainLookupStart

TCP连接时间

=connectEnd – connectStart

HTTP请求时间

= responseEnd – responseStart

解析dom树耗时

= domComplete – domInteractive

白屏时间

= domloadng – fetchStart

domready时间

= domContentLoadedEventEnd – fetchStart

onload时间

= loadEventEnd – fetchStart

实现

PMS_A

/**
 * PMS header scripts
 *
 * 性能指标
 * wst: white screen timing
 * fst: first screen timing
 * drt: dom ready timing
 * prt: page ready timing
 * olt: on loaded timing
 * lpt: leave page timing
 *
 * dns: domain lookup timing
 * tct: tcp connect timing
 * rst: response start timing
 * ret: response end timing
 * dct: dom complete timing
 * let: load event timing
 * 
 * @author  Yang,junlong at 2017-05-04 17:18:50 build.
 * @version $Id$
 */

(function() {
    window.PMS = {
        _firsts: +new Date,
        _timing: {},
        _option: {},
        extend: function (destination) {
            if(!destination){
                return;
            }
            var args = Array.prototype.slice.call(arguments, 1);
            for (i = 0, l = args.length; i < l; i++) {
                var source = args[i];
                for(var property in source){
                    var value = source[property];
                    if (value !== undefined && destination[property] === undefined){
                        destination[property] = value;
                    }
                }
            }
            return destination;
        },
        // mark timing
        mark: function (markname, timestamp) {
            this._timing[markname] = timestamp || +new Date;
        },
        init: function (option) {
            this.extend(this._option, option);
        },
        fst: function () {
            this.mark('fst');
        },
        prt: function () {
            this.mark('prt');
        }
    }
})();

PMS_B

/**
 * Description
 * 
 * @author  Yang,junlong at 2017-05-04 17:45:39 build.
 * @version $Id$
 */

(function() {
    PMS.extend(PMS, {
        sended: false,
        metrics: {},
        each: function (object, callback, scope) {
            // array
            if (object.length === +object.length) {
                for (var i = 0, l = object.length; i < l; i++) {
                    if (callback.call(scope, object[i], i, object) === false) {
                        return;
                    }
                }
            } else {
                for (var key in object) {
                    if (object.hasOwnProperty(key)) {
                        if (callback.call(scope, object[key], key, object) === false) {
                            return;
                        }
                    }
                }
            }
        },
        setup: function () {
            // setup something
            var timing = {
                wst: 0, // white screen timing
                fst: 0, // first screen timing
                drt: 0, // dom ready timing
                prt: 0, // page ready timing
                olt: 0, // on load timing
                lpt: 0, // leave page timing
                dns: 0, // domain lookup timing
                tct: 0, // tcp connect timing
                rst: 0, // response start timing
                ret: 0, // response end timing
                dct: 0, // dom complete timing
                let: 0  // load event timing
            };

            this.extend(this._option, {
                sample: 0.1,
                logurl: 'https://stat.pay.xiaomi.com/event.gif',
                env: {}
            });

            this.each(this._option['env'], function(value, key) {
                this.metrics[key] = value;
            }, this);

            PMS._timing['prt'] = Math.max(PMS._timing['drt'], PMS._timing['prt'] || 0);

            this.measure(PMS._firsts, this._timing);

            // 设置屏幕信息
            // this.screen();

            this.measureNetworkTime();
        },

        measureNetworkTime: function () {
            if (window.performance && performance.timing) {
                var timing = performance.timing;
                var startTime = timing.navigationStart || timing.fetchStart;

                var _timing = {
                    dns: timing.domainLookupEnd - timing.domainLookupStart,
                    cnt: timing.connectEnd,
                    rst: timing.responseStart,
                    ret: timing.responseEnd,
                    dct: timing.domComplete,
                    let: timing.loadEventEnd,
                    wst: timing.domLoading
                };

                var wtt = PMS._firsts - startTime;
                wtt > 0 && (this.metrics['wtt'] =  wtt);

                this.measure(startTime, _timing);
            }
        },

        // measure timing
        measure: function (start, timing) {
            var _timing = 0;
            this.each(timing, function(value, key) {
                value = Math.max(value, this._timing[key] || 0);
                _timing = ('' + value).length < 8 ? value : value - start;
                this.metrics[key] = _timing;
            }, this);
        },


        ready: (function (callback) {
            var isReady = false;
            var readyList = [];
            // The ready event handler
            var DOMContentLoaded = function( event ) {
                // readyState === "complete" is good enough for us to call the dom ready in oldIE
                if ( document.addEventListener || event.type === 'load' || document.readyState === 'complete' ) {
                    detach();
                    ready();
                }
            };
            // Clean-up method for dom ready events
            var detach = function() {
                if ( document.addEventListener ) {
                    document.removeEventListener('DOMContentLoaded', DOMContentLoaded, false);
                    window.removeEventListener('load', DOMContentLoaded, false);
                } else {
                    document.detachEvent('onreadystatechange', DOMContentLoaded);
                    window.detachEvent('onload', DOMContentLoaded);
                }
            };
            // Handle when the DOM is ready
            function ready() {
                if(!isReady) {
                    isReady = true;
                    for (var i = 0, j = readyList.length; i < j; i++) {
                        readyList[i]();
                    }
                }
            }
            // Catch cases where $(document).ready() is called after the browser event has already occurred.
            // we once tried to use readyState "interactive" here, but it caused issues like the one
            // discovered by ChrisS here: http://bugs.jquery.com/ticket/12282#comment:15
            if ( document.readyState === "complete" ) {
                // Handle it asynchronously to allow scripts the opportunity to delay ready
                setTimeout( ready );
            // Standards-based browsers support DOMContentLoaded
            }else if (document.addEventListener) {
                // Use the handy event callback
                document.addEventListener('DOMContentLoaded', DOMContentLoaded, false);
                // A fallback to window.onload, that will always work
                window.addEventListener('load', ready, false);
            // If IE event model is used
            } else {
                // Ensure firing before onload, maybe late but safe also for iframes
                document.attachEvent('onreadystatechange', DOMContentLoaded);
                // A fallback to window.onload, that will always work
                window.attachEvent('onload', ready);
                // If IE and not a frame
                // continually check to see if the document is ready
                var top = false;
                try {
                    top = window.frameElement == null && document.documentElement;
                } catch(e) {}
                if (top && top.doScroll) {
                    (function() {
                        if (!isReady) {
                            try {
                                // Use the trick by Diego Perini
                                // http://javascript.nwbox.com/IEContentLoaded/
                                top.doScroll("left");
                            } catch (e) {
                                return setTimeout(arguments.callee, 1);
                            }
                            // and execute any waiting functions
                            ready();
                        }
                    })();
                }
            }
            return function(callback) {
                isReady ? callback() : readyList.push(callback);
                return isReady;
            }
            // end
        })(),

        // get first paint timing
        getFPT: function() {
            var fpt;
            if (window.performance && performance.timing && performance.timing.msFirstPaint) {
                fpt = performance.timing.msFirstPaint;
            } else {
                if (window.chrome && chrome.loadTimes) {
                    fpt = parseInt(chrome.loadTimes().firstPaintTime * 1000);
                }
            }
            return fpt;
        },

        // 获取屏幕尺寸
        screen: function () {
            if (window.screen) {
                this.metrics['screen'] =   window.screen.width + "*" + window.screen.height + "|" + window.screen.availWidth + "*" + window.screen.availHeight;
            }
        },

        send: function (delay) {
            var that = this;
            delay = delay || 0;
            setTimeout(function(){
                that._send.call(that);
            }, delay);
        },

        // send data
        _send: function() {
            if (this.sended) {
                return;
            }
            this.sended = true;
            // setup pms
            PMS.setup();

            var query = [];
            query.push('metric=' +  this._option.metric);
            PMS.each(this.metrics, function(item, key) {
                query.push('tags:'+key + '=' + item);
            });

            var logurl = this._option.logurl + '?' + query.join('&');
            this.stat(logurl);
        },
        stat: function (src) {
            var n = '__log_img_' + new Date * 1;

            // 将image对象赋给全局变量,防止被当做垃圾回收,造成请求失败。
            var img = window[n] = new Image();
            img.onload = img.onerror = function(){
                //垃圾回收
                window[n] = null; 
                delete window[n];
            };
            img.src = src;
            //垃圾回收
            img = null;
        }
    });


    // dom ready timing
    PMS.ready(function() {
        PMS.mark('drt');
    });
    if (document.attachEvent) {
        window.attachEvent("onload", function() {
            PMS.mark("olt")

            PMS.send();
        }, false);

        window.attachEvent("onbeforeunload", function() {
            // leave page timing
            PMS.mark("lpt");

            PMS.send();
        });
    } else {
        window.addEventListener("load", function() {
            PMS.mark("olt");

            PMS.send();
        });

        window.addEventListener("beforeunload", function() {
            // leave page timing
            PMS.mark("lpt");

            PMS.send();
        }, false);
    }
})();

参考