前端SDK开发学习|青训营笔记

110 阅读6分钟

这是我参与「第五届青训营」伴学笔记创作活动的第 12 天,欢迎各位大佬批评指正。

前端监控是什么

在一些业务场景中,可能由于各种因素的问题导致页面长时间无法加载,或者是页面卡顿、资源错误等诸多问题,那么怎么样快速准确定位到问题的根源再进一步排除、修复问题呢?这就需要用到SDK技术。

前端监控主要是为了获取页面加载过程中每个部分性能开销数据,便于快速定位页面卡顿、白屏或是异常错误等,主要是监测性能指标、异常事件和用户行为三个部分。本文主要针对前两个进行介绍。

性能指标监测

早期的页面是纯静态的,但随着web应用的爆发,页面的交互也越来越复杂。因此在2010 年 8 月W3C成立了web性能工作组,致力于制定衡量web应用性能的方法和API

1. 早期的性能标准模型:

截屏2023-02-13 16.45.50.png

2. 以用户为中心的性能指标:

传统指标专注于容易衡量的技术细节,但是很难反映出用户真正关心的是什么。因此专注于用户视角下的浏览体验的性能指标诞生了。大概包括:

用户体验指标
发生了什么FP (First Paint), FCP (First Contentful Paint)
内容有用吗FMP (First Meaningful Paint), SI (Speed Index), LCP(Largest Contentful)
内容可用吗TTI (Time to Interactive), TBT(Total Blocking Time)
是否令人愉悦FID (First Input Delay), CLS(Cumulative Layout Shift)
  • TTI:测量页面从开始加载到主要子资源完成渲染,并且能够快速、可靠响应用户输入的所需时间。(是反映页面的可用性重要指标,TTI值越小,代表用户能够更早操作页面,体验也就越好。

  • SI:衡量页面可视区域加载速度,帮助监测页面加载体验差异。比如下图A和B加载速度一样,但是明显A用户体验更好。 截屏2023-02-13 17.01.34.png

  • LCP:最大内容在可视区域内变为可见的时间点。比如一篇文章中一大段文字或者产品页面上的一张图片,用于理解页面内容最有用的元素。(更加容易理解,并且能够给出和FMP相似的结果,容易计算和上报;更加常用)。

  • TBT:量化主线程在空闲之前的繁忙程度,有助于理解在加载期间,页面无法响应用户输入的时间有多久。(一般认为如果一个任务在主线程上运行时间超过50ms,那就是一个长任务。超过50ms后的任务耗时都算作阻塞时间。而一个页面的TBT是从FCPTTI之间所有长任务阻塞时间总和。 比如下图的TBT为60ms。 截屏2023-02-13 17.49.33.png

  • CLS:量化了页面加载期间,视口中元素的移动程度。比如当我们点击按钮的时候,突然增加一块内容使得按钮偏移了原本的位置,这种位移会降低用户体验。

异常事件监控

静态资源错误

加载页面所需的HTMLCSSJS文件或各类多媒体文件拉取时,发生了预期之外的错误,如网络异常等,导致静态资源无法加载。

因此主要监控HTTP协议状态码。即当状态码 >= 400,认为发生了异常事件。

JS 错误

在页面运行时发生了JS错误会严重影响页面的正常渲染和交互,是前端监控重中之重

白屏异常

因为白屏没有标准化的监听方法,通常可以借助判断DOM树的结构来粗略判断白屏是否发生。

可能导致白屏因素包括:

  • 发生JS错误导致关键资源渲染失败。
  • 请求异常或静态资源加载失败。
  • 长时间的JS主线程繁忙阻塞渲染任务。

SDK 实现

1. PerformancePerformanceOberver

/**
* fp/fcp --> paint
* lcp --> largest-contentful-paint
* fip --> first-input
*/
const entryTypes = ['paint', 'largest-contentful-paint', 'first-input'];

// 1. 通过window.performance 对象拿到 fp, fcp, fip.
window.performance.getEntriesByType('paint');
window.performance.getEntriesByType('first-input');

// 2. 通过PerformanceObserver 监听(获取的值比Performance多)
const p = new PerformanceOberver(list => {
    for(const entry of list.getEntries()) {
        console.log(entry);
    }
});
p.observe({ entryTypes });

// 封装 monitor:1.起名字;2.监听能力;3.主动开启;4.上报能力。
function createPerMonitor(report:({name:string, data:any}) => void) {
    const name = 'performance';
    const entryTypes = ['paint', 'largest-contentful-paint', 'first-input'];
    
    function start() {
        const p = new PerformanceOberver(list => {
            for(const entry of list.getEntries()) {
                report({name,data:entry});
            }
        });
        p.observe({ entryTypes });
        
    return {name, start}
}

2. JS 错误监控

  • 利用window.addEventListener()方法监听error事件可以拿到 js 错误。
  • unhandledrejection用于监听 promise 状态 reject 之后但是没有处理函数时会被触发。
// 1. js错误
window.addEventListener('error', (e) => {
    // e.error不为空时为js错误
    if(e.error) {
        //处理逻辑
    }
});

// 2.promise-reject
window.addEventListener('unhandledrejection', (e) => {
    //处理逻辑
});

// 封装monitor
function createJsErrorMonitor(report:({name:string, data:any}) => void) {
    const name = 'js-error';
    function start() {
         window.addEventListener('error', (e) => {
            // e.error不为空时为js错误
            if(e.error) {
                report({name, data:{type:e.type, message:e.message}});
            }
        });
        window.addEventListener('unhandledrejection', (e) => {
            report({name, data:{type:e.type, reason:e.reason}});
        });
    }
    
    return {name,start};
}

3. 静态资源错误监控

注意,静态资源错误与js错误监听方法一致,但是要注意区分:e.target || e.srcElement,如果为空,则return排除掉。(srcElement是较老写法。目前已经废弃,但是为了兼容仍然写上。

// 1. 静态资源错误
window.addEventListener('error', (e) => {
    const target = e.target||e.srcElement;
    //如果target不存在则排除
    if(!target) {
        return;
    }
    //判断是link还是script错误
    if(target instanceOf HTMLElement) {
        // link或者script
        let url;
        if(target.tagName.toLocaleLowerCase() === 'link') {
            // 通过href获取资源链接
            url = target.getAttribute('href');
        } else {
            // 通过src获取资源链接
            url = target.getAttribute('src');
        }
    }
//要在捕获阶段监听
},true);

// 封装monitor
function createResourceMonitor(report:({name:string, data:any}) => void) {
    const name = 'resource';
    function start() {
        window.addEventListener('error', (e) => {
            const target = e.target||e.srcElement;
            //如果target不存在则排除
            if(!target) {
                return;
            }
            //判断是link还是script错误
            if(target instanceOf HTMLElement) {
                // link或者script
                let url;
                if(target.tagName.toLocaleLowerCase() === 'link') {
                    // 通过href获取资源链接
                    url = target.getAttribute('href');
                } else {
                    // 通过src获取资源链接
                    url = target.getAttribute('src');
                }
                return ({name, data:{url}});
            }
        },true);
    }
    return {name,start};
}

4. 请求错误监控

请求错误一般监听xhrfetch发生错误。

PShook函数,在原有函数中注入自己函数的一些逻辑。

//hook函数
function hookMethod(obj:any, key:string, hookFunc:Function) {
    return (...params:any[]) => {
        obj[key] = hookFunc(obj[key], ...params);
    }
}

// hook xhr对象的open方法
hookMethod(XMLHttpRequest.prototype, 'open', (origin: Function) =>
    function (method: string, url: string) {
        this.payload = {
        method,
        url,
        };
        // 执行原函数
        origin.apply(this, [method, url]);
    }
)();

// hook xhr 对象的 send 方法监听到错误的请求
hookMethod(XMLHttpRequest.prototype, 'send', (origin: Function) =>
    function (...params: any[]) {
        this.addEventListener("readystatechange", function () {
        if (this.readyState === 4 && this.status >= 400) {
        this.payload.status = this.status;
        console.log(this.payload);
        }
        });
        origin.apply(this, params);
    }
)(); 

测试
//const xhr = new XMLHttpRequest();
//xhr.open("post", "111.cc");
//xhr.send();

// 封装成一个 monitor

function createXhrMonitor(report: ({ name: string, data: any }) => void) {
    const name = "xhr-error";
    function hookMethod(
        obj: any,
        key: string,
        hookFunc: Function,
        ) {
            return (...params: any[]) => {
                obj[key] = hookFunc(obj[key], ...params)
            }
        }
        
        function start() {
            hookMethod(XMLHttpRequest.prototype, 'open', (origin: Function) =>
            function (this, method: string, url: string) {
                this.payload = {
                method,
                url,
                };
                origin.apply(this, [method, url]);
            }
        )();
        
        hookMethod(XMLHttpRequest.prototype, 'send', (origin: Function) =>
            function (this, ...params: any[]) {
                this.addEventListener("readystatechange", function () {
                    if (this.readyState === 4 && this.status >= 400) {
                        this.payload.status = this.status;
                        report({ name, data: this.payload });
                    }
                });
                origin.apply(this, ...params);
            }
        )();
    }
    return { name, start }
}

5. 按需加载监控

封装一个简易通用的SDK。利用Navigator.sendBeacon()方法,可用来将统计数据发送给服务器。

function createSdk(url: string) {
    const monitors: Array<{ name: string, start: Function }> = [];
    const sdk = {
        url,
        report,
        loadMonitor,
        monitors,
        start,
    }

    function report({ name: string, data: any }) {
        // 注意:数据发送前需要先序列化为字符串
        navigator.sendBeacon(url, JSON.stringify({ name: string, data: any }));
    }

    function loadMonitor({ name: string, start: Function }) {
        monitors.push({ name: string, start: Function });
        // 实现链式调用
        return sdk;
    }

    function start() {
        monitors.forEach(m => m.start());
    }

    return sdk;
}