性能优化 - Web性能数据/监控/指标

988 阅读11分钟

注:本文部分参考

  1. 【字节前端】 Web Performance Metrics 与 Core Web Vitals 简介 —— 现代前端性能各个指标的具体含义和设计理念
  2. 【MDN】Performance

本文结合自己的学习和理解,总结了小部分。很多东西都没有说全。权当学习文章。不可以此去做实践。

监控部分的代码参考某大前端团队代码。侵删。

代码逻辑请自行消化,这里不过多赘述。

开始~

数据

window.performance 可以获取到与页面性能相关的信息。其属性和方法如下:

// 属性
// 以下是真实数据
// 导航相关。PerformanceNavigation类型
navigation: {
    // 0: 当前页面是通过点击链接,书签和表单提交,或者脚本操作,或者在url中直接输入地址
    // 1: 点击刷新页面按钮或者通过Location.reload()方法显示的页面
    // 2: 页面通过历史记录和前进后退访问时
    // 255: 其他的任何方式
    type: 1,
    // 表示在到达这个页面之前重定向了多少次。
    redirectCount: 0,
},
// 内存相关。MemoryInfo类型。此属性是非标准属性。只在Chrome下有效。不应该在生产环境使用。
memory: {
    // 上下文内可用堆的最大体积,以字节计算。
    jsHeapSizeLimit: 4294705152
    // 已分配的堆体积,以字节计算。
    totalJSHeapSize: 651152055
    // 当前 JS 堆活跃段(segment)的体积,以字节计算。
    usedJSHeapSize: 613122587
},
// 性能测量开始时间点(高精度时间戳)
timeOrigin: 1618392583547.822,
// 延迟相关的性能信息。PerformanceTiming类型
timing: {
    // 从同一个浏览器上下文的上一个文档卸载(unload)结束时的UNIX时间戳。如果没有上一个文档,这个值会和PerformanceTiming.fetchStart相同。
    navigationStart: 1618392583547,
    // unload事件抛出时的UNIX时间戳。如果没有上一个文档,或者前一个文档或其中一个需要的重定向不属于同一来源 这个值会返回0.
    unloadEventStart: 1618392583584,
    // unload事件处理完成时的UNIX时间戳,如果没有上一个文档,或者前一个文档或其中一个需要的重定向不属于同一来源 这个值会返回0.
    unloadEventEnd: 1618392583584,
    // 第一个HTTP重定向开始时的UNIX时间戳。如果没有重定向,或者重定向中的一个不同源,这个值会返回0.
    redirectStart: 0,
    // 最后一个HTTP重定向完成时(也就是说是HTTP响应的最后一个比特直接被收到的时间)的UNIX时间戳。如果没有重定向,或者重定向中的一个不同源,这个值会返回0.
    redirectEnd: 0,
    // 浏览器准备好使用HTTP请求来获取(fetch)文档的UNIX时间戳。这个时间点会在检查任何应用缓存之前。
    fetchStart: 1618392583551,
    // 域名查询开始的UNIX时间戳。如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和 PerformanceTiming.fetchStart一致。
    domainLookupStart: 1618392583551,
    // 域名查询结束的UNIX时间戳。如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和 PerformanceTiming.fetchStart一致。
    domainLookupEnd: 1618392583551,
    // HTTP请求开始向服务器发送时的Unix毫秒时间戳。如果使用持久连接(persistent connection),则返回值等同于fetchStart属性的值。
    connectStart: 1618392583551,
    // 浏览器与服务器之间的连接建立时的Unix毫秒时间戳。如果建立的是持久连接,则返回值等同于fetchStart属性的值。连接建立指的是所有握手和认证过程全部结束。
    connectEnd: 1618392583551,
    // 浏览器与服务器开始安全链接的握手时的Unix毫秒时间戳。如果当前网页不要求安全连接,则返回0。
    secureConnectionStart: 0,
    // 浏览器向服务器发出HTTP请求时(或开始读取本地缓存时)的Unix毫秒时间戳。
    requestStart: 1618392583574,
    // 浏览器从服务器收到(或从本地缓存读取)第一个字节时的Unix毫秒时间戳。如果传输层在开始请求之后失败并且连接被重开,该属性将会被数制成新的请求的相对应的发起时间。
    responseStart: 1618392583578,
    // 浏览器从服务器收到(或从本地缓存读取,或从本地资源读取)最后一个字节时(如果在此之前HTTP连接已经关闭,则返回关闭时)的Unix毫秒时间戳。
    responseEnd: 1618392583580,
    // 当前网页DOM结构开始解析时(即Document.readyState属性变为“loading”、相应的 readystatechange (en-US)事件触发时)的Unix毫秒时间戳。
    domLoading: 1618392583618,
    // 当前网页DOM结构结束解析、开始加载内嵌资源时(即Document.readyState属性变为“interactive”、相应的readystatechange (en-US)事件触发时)的Unix毫秒时间戳。
    domInteractive: 1618392588369,
    // 当解析器发送DOMContentLoaded (en-US) 事件,即所有需要被执行的脚本已经被解析时的Unix毫秒时间戳。
    domContentLoadedEventStart: 1618392588369,
    // 当所有需要立即执行的脚本已经被执行(不论执行顺序)时的Unix毫秒时间戳。
    domContentLoadedEventEnd: 1618392588374,
    // 当前文档解析完成,即Document.readyState 变为 'complete'且相对应的readystatechange (en-US) 被触发时的Unix毫秒时间戳。
    domComplete: 1618392606809,
    // 该文档下,load (en-US)事件被发送时的Unix毫秒时间戳。如果这个事件还未被发送,它的值将会是0。
    loadEventStart: 1618392606809,
    // 当load (en-US)事件结束,即加载事件完成时的Unix毫秒时间戳。如果这个事件还未被发送,或者尚未完成,它的值将会是0.
    loadEventEnd: 1618392606809

},

可以看到,timing 属性包含了从 上个文档卸载当前文档加载完成 的所有节点的时间。通过简单的运算,我们可以拿到我们想要的性能数据。比如:

// 以下是本页面本身的加载(不包含外部资源)
let positiveDiff = (para1, para2) => {
    let diff = para1 - para2;
    return diff % 1 !== 0 ? diff.toFixed(3) : diff;
};
// dns查询时间:
positiveDiff(timing.domainLookupEnd, timing.domainLookupStart);

// tcp三次握手耗时
positiveDiff(timing.connectEnd, timing.connectStart);

// 请求文档耗时
positiveDiff(timing.responseStart, timing.requestStart);

// 接收文档耗时
// iOS平台responseEnd值可能会异常大
let responseEnd = timing.responseEnd > timing.domLoading ? timing.domLoading : timing.responseEnd;
positiveDiff(responseEnd, timing.responseStart);

// 开始解析dom至dom树解析完成
positiveDiff(timing.domInteractive, timing.domLoading);

// defer的js下载执行完成
positiveDiff(timing.domContentLoadedEventStart, timing.domInteractive);

// HTML和所有资源(比如图片)都加载完成
positiveDiff(timing.domComplete, timing.domContentLoadedEventEnd);

// Load事件耗时
positiveDiff(timing.loadEventEnd, timing.loadEventStart);

再来看下原型上的方法。

performance.getEntries(): 此方法返回所有资源的性能属性(包括页面本身和外部资源)。

image

image

performance.getEntriesByName(name): 名称过滤(name)。我们上面的timing对象,其实就是此函数参数传入当前页面URI获取到的对象的部分属性。

performance.getEntriesByType(type): 类型过滤(entry type)

performance.mark(name): 在该函数执行时,往上述对象中添加一个属性和值。属性名为传入的参数,值为该函数执行的时间。

performance.clearMarks(name): 从浏览器的性能输入缓冲区中移除该属性。

performance.measure(name, startMark, endMark)

用一个例子来说明:

// 以一个标志开始。
performance.mark('mySetTimeout-start');

// 等待一些时间。
setTimeout(function () {
    // 标志时间的结束。
    performance.mark('mySetTimeout-end');

    // 测量两个不同的标志。
    performance.measure('mySetTimeout', 'mySetTimeout-start', 'mySetTimeout-end');

    // 获取所有的测量输出。
    // 在这个例子中只有一个。
    var measures = performance.getEntriesByName('mySetTimeout');
    var measure = measures[0];
    console.log('setTimeout milliseconds:', measure.duration);

    // 清除存储的标志位
    performance.clearMarks();
    performance.clearMeasures();
}, 1000);

performance.clearResourceTimings(): 从浏览器的性能数据缓冲区中移除所有 entryType"resource"performance entries

performance.now(): 返回一个表示从性能测量时刻开始经过的毫秒数。

performance.toJSON(): 返回 Performance 对象的 JSON 对象。

监控

通过上面的各种 API 我们可以拿到页面上所有资源的性能数据了。下面看下如何使用这些数据为我们的页面做性能监控。(首屏打点上报)。

首先我们需要在每个页面映入性能监控库。

import performance from 'performance';
performance();

我们需要在页面 load 的时候,将从 navigationStartloadEventEnd 的时间上报。但是我们不能直接用这两个值的差值。因为有可能任何一个值为 0 导致整个数据出现巨大偏差。我们需要将每一个小段的时间逐步相加。这样才能保证时间基本准确。

我们先需要做一些准备:

// 计算两个时间点的时间差
let positiveDiff = (para1, para2) => {
    let diff = para1 - para2;
    return diff % 1 !== 0 ? diff.toFixed(3) : diff;
};

// 为times赋值
let reduceData = (value, obj, key) => {
    if (value) {
        obj[key] = value;
    }
};
/**
 * @gap_ns_dns  navigationStart与dns间隔
 * @dns         dns查询耗时
 * @gap_dns_tcp dns与tcp间隔
 * @tcp         tcp三次握手耗时
 * @gap_tcp_req tcp与req间隔
 * @req         请求文档耗时
 * @res         接收文档耗时
 * @gap_res_p   res与process间隔
 * @p1          开始解析dom至dom树解析完成(尚未加载内嵌资源)
 * @p2          defer的js下载执行完成
 * @p3          DOMContentLoaded事件耗时
 * @p4          HTML和所有资源(比如图片)都加载完成
 * @gap_p_load  domComplete后立刻触发load事件
 * @load        Load事件耗时
 */
let getPerformanceData = () => {
    if (!performance.timing) {
        return null;
    }

    let timing = performance.timing;

    // iOS平台responseEnd值可能会异常大
    let responseEnd = timing.responseEnd > timing.domLoading ? timing.domLoading : timing.responseEnd;

    let times = {};

    reduceData(positiveDiff(timing.domainLookupStart, timing.navigationStart), times, 'gap_ns_dns');
    reduceData(positiveDiff(timing.domainLookupEnd, timing.domainLookupStart), times, 'dns');
    reduceData(positiveDiff(timing.connectStart, timing.domainLookupEnd), times, 'gap_dns_tcp');
    reduceData(positiveDiff(timing.connectEnd, timing.connectStart), times, 'tcp');
    reduceData(positiveDiff(timing.requestStart, timing.connectEnd), times, 'gap_tcp_req');
    reduceData(positiveDiff(timing.responseStart, timing.requestStart), times, 'req');
    reduceData(positiveDiff(responseEnd, timing.responseStart), times, 'res');
    reduceData(positiveDiff(timing.domLoading, responseEnd), times, 'gap_res_p');
    reduceData(positiveDiff(timing.domInteractive, timing.domLoading), times, 'p1');
    reduceData(positiveDiff(timing.domContentLoadedEventStart, timing.domInteractive), times, 'p2');
    reduceData(positiveDiff(timing.domContentLoadedEventEnd, timing.domContentLoadedEventStart), times, 'p3');
    reduceData(positiveDiff(timing.domComplete, timing.domContentLoadedEventEnd), times, 'p4');
    reduceData(positiveDiff(timing.loadEventStart, timing.domComplete), times, 'gap_p_load');
    reduceData(positiveDiff(timing.loadEventEnd, timing.loadEventStart), times, 'load');

    return times;
};

// 逐步相加
let getThresholdData = (performanceData) => {
    if (!performanceData) {
        return Infinity;
    }

    // 最小过程性能数据key
    let perfKeys = [
        'gap_ns_dns',
        'dns',
        'gap_dns_tcp',
        'tcp',
        'gap_tcp_req',
        'req',
        'res',
        'gap_res_p',
        'p1',
        'p2',
        'p3',
        'p4',
        'gap_p_load',
        'load',
    ];
    // 页面加载完成时间
    let load2 = 0;

    for (let key in perfKeys) {
        let _value = performanceData[perfKeys[key]];
        if (typeof _value !== 'undefined') {
            load2 += _value;
        }
    }

    return load2;
};

有了上面的准备后,我们已经可以计算出整个时间了。

页面本身之外的引用资源也需要进行性能监控。

const SLOW_SOURCE_MAX = 10 * 1000; // 性能值大于10秒作为慢资源处理
const NO_VALUE = -1; // 数据取不到的时候的默认值

const ERROR_MINUS = -21; // 异常--负数的默认值

/**
 * 处理异常size值(浏览器差异)
 * @param a Number
 * @return Number
 */
let abnormalSize = (arg) => {
    if (isNaN(arg)) {
        return NO_VALUE;
    }
    if (arg < 0) {
        return ERROR_MINUS;
    }
    return arg;
};

/**
 * 计算外部资源加载时间
 * @return value
 * @rstime           开始时间
 * @rredir           重定向耗时
 * @rredircache      缓存检查与重定向间隔
 * @rcache           缓存检查耗时
 * @rdns             DNS解析耗时
 * @rdnstcp          dns与tcp间隔
 * @rtcp             tcp开始建连至https开始连接耗时
 * @rssl             https连接开始至tcp完成建连耗时
 * @rtcpreq          tcp与req间隔
 * @rreq             请求文档耗时
 * @rres             接收文档耗时
 */
let getEntryTiming = (entry) => {
    let t = entry;
    let name = t.name;

    let redirectStart = t.redirectStart || t.startTime;
    let redirectEnd = t.redirectEnd || t.startTime;
    let responseStart = t.responseStart || t.fetchStart;
    let secureConnectionStart = t.secureConnectionStart || t.connectStart;
    // dnslookup阶段没有的情况下 domainLookupStart为0
    let domainLookupStart = t.domainLookupStart || t.fetchStart;

    try {
        // gap_cache_dns 无法计算 直接算进cache中
        let times = {};
        // 加载时间
        times.rduration = t.duration % 1 !== 0 ? t.duration.toFixed(3) : t.duration;

        reduceData(positiveDiff(t.startTime, 0), times, 'rstime');
        reduceData(positiveDiff(redirectEnd, redirectStart), times, 'rredir');
        reduceData(positiveDiff(t.fetchStart, redirectEnd), times, 'rredircache');
        reduceData(positiveDiff(domainLookupStart, t.fetchStart), times, 'rcache');
        reduceData(positiveDiff(t.domainLookupEnd, t.domainLookupStart), times, 'rdns');
        reduceData(positiveDiff(t.connectStart, t.domainLookupEnd), times, 'rdnstcp');
        reduceData(positiveDiff(secureConnectionStart, t.connectStart), times, 'rtcp');
        reduceData(positiveDiff(t.connectEnd, secureConnectionStart), times, 'rssl');
        reduceData(positiveDiff(t.requestStart, t.connectEnd), times, 'rtcpreq');
        reduceData(positiveDiff(t.responseStart, t.requestStart), times, 'rreq');
        reduceData(positiveDiff(t.responseEnd, responseStart), times, 'rres');

        if (times.rduration < SLOW_SOURCE_MAX) {
            let search = name.split('?');
            name = search[0];
        }

        // 资源名称(绝对路径)
        times.rname = name;
        // 自定义类型
        times.rtype = t.type;

        let size = abnormalSize(t.transferSize);
        if (size) {
            times.rtransize = size;
        }
        return times;
    } catch (e) {
        console.warn(`msg:${e.msg}`);
        console.warn(`stack:${e.stack}`);
        return {};
    }
};
let entries = [];
let infoList = [];
let getResourceData = () => {
    try {
        let resourceData = [];
        entries.forEach((entry) => {
            let name = entry.name && entry.name.match(/([/][^?#;]*)?(?:[?]([^?#]*))?(#[^#]*)?$/);
            let pathName = name[1];
            let fullName = name[0];

            if (entry.entryType === 'navigation') {
                // 过滤页面本身
                return;
            }

            if (/\.js$/.test(pathName)) {
                entry.type = 'script';
            } else if (/\.css$/.test(pathName)) {
                entry.type = 'stylesheet';
            } else if (/\.(eot|woff|ttf|svg)$/.test(pathName)) {
                entry.type = 'font';
            } else if (/\.(bmp|gif|jpg|png|jpeg|webp)(!share)*?$/.test(pathName)) {
                entry.type = 'image';
                return;
            } else if (/\.(mp3|wma|wav|ape|flac|ogg|aac)$/.test(pathName)) {
                // 音频
                entry.type = 'media';
            } else if (/\.(mp4|avi|rmvb|mkv)$/.test(pathName)) {
                // 视频
                entry.type = 'media';
            } else if (entry.initiatorType === 'iframe') {
                entry.type = 'iframe';
            } else {
                entry.type = 'others';
                return;
            }

            resourceData.push(getEntryTiming(entry));
        });

        return resourceData;
    } catch (e) {
        console.warn(`msg:${e.msg}`);
        console.warn(`stack:${e.stack}`);
        return [];
    }
};
// performance.js

const FEATURE_MAX = 60 * 1000; // 性能值大于60s的均作为异常处理
const hasLoaded = false; //页面是否已经load

const init = () => {
    //
    let loadedFunc = () => {
        // 页面本身的加载性能上报
        setTimeout(() => {
            hasLoaded = true;
            let performanceData = getPerformanceData();
            let data = getThresholdData(performanceData);

            // 如果打点数据计算出页面加载完成时间大于60s,该次打点不上报
            if (data < FEATURE_MAX) {
                sendLog(data);
            }
        });
        // 页面上外部资源的性能上报
        // 允许丢失 延迟执行
        setTimeout(() => {
            let resourceData = getResourceData();
            let length = resourceData.length;
            let arr = [];
            let num = 10;

            while (length > 0) {
                if (length > num) {
                    arr = resourceData.splice(0, num);
                    length = resourceData.length;
                } else {
                    arr = resourceData;
                    length = 0;
                }

                sendLog(arr);
            }
        }, 500);
    };

    window.addEventListener('load', loadedFunc);
};

export default init;

以上便上报了页面 DOM 加载完毕的时间。

那如何上报首屏时间呢?又如何上报首次 paint 的时间呢?

先来看 first paint 的时间

/* 获取首次渲染耗时 */
let getFirstPaint = function (decimalNum) {
    if (!window.performance || !window.performance.getEntriesByType) {
        return undefined;
    }

    decimalNum || (decimalNum = 0);

    let paintEntries = window.performance.getEntriesByType('paint');

    if (paintEntries && paintEntries.length) {
        for (let i = 0, length = paintEntries.length; i < length; i++) {
            let entry = paintEntries[i];

            if (entry && entry.name === 'first-paint') {
                return +(entry.startTime + entry.duration).toFixed(decimalNum);
            }
        }
    }

    return undefined;
};

其中,window.performance.getEntriesByType('paint')是入下图所示的对象:

image

所以,也可以对 first-contentful-paint 做监控。

再来看下,如何根据我们页面的情况,自己选择在何时上报首屏数据呢?

我们对外暴露一个 firstScreen 方法。

/**
* 首屏打点
*/

let pageStartTime = 0; // 首屏起点时间,默认为0,在单页应用中切换页面时需要重置此值

let firstScreen = () => {
    if (window.performance && performance.now && !firstScreenFlag) {
        firstScreenFlag = true;

        let firstScreenLog = window.firstScreenTime || positiveDiff(parseInt(performance.now()), pageStartTime);
        // 首屏打点可能会晚于页面加载,所以这里需要做下判断
        if (hasLoaded) {
            sendLog(firstScreenLog);
        } else {
            window.addEventListener('load', () => {
                // onload事件里同步执行的js会被计入到pageLoad内 包一层异步来解决
                setTimeout(() => {
                    sendLog(firstScreenLog);
                });
            });
        }
    }
};
let init = () => {
    // 上面的代码不重复了
    ...
    return {
        firstScreen,
    };
}

其中 firstScreenTime 我们可以在 html 文件中自己定义。

......
<!-- 上面是html部分 -->
<script>
    (function () {
        var positiveDiff = function (para1, para2) {
            var diff = para1 - para2;
            if (isNaN(diff)) {
                return -1;
            }
            if (diff > 60000) {
                return -22;
            }
            if (diff < 0) {
                return -21;
            }
            return diff % 1 !== 0 ? diff.toFixed(3) : diff;
        };
        window.firstScreenTime = positiveDiff(parseInt(performance.now()), 0);
    })();
</script>

如果我们没在HTML中定义这个属性,就会在我们调用 firstScreen 的时候,取当前的时间了。例如可以在我们的业务接口请求完成后调用。

指标

现在我们获取了很多性能数据。那我们如何判断一个资源,或者我们的页面性能好坏呢?

这里直接参考字节大佬的文章吧。

【字节前端】 Web Performance Metrics 与 Core Web Vitals 简介 —— 现代前端性能各个指标的具体含义和设计理念