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

148 阅读10分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 12 天

一、本堂课重点内容:

本堂课的知识要点有哪些?

  • 认识前端监控
  • 常用性能指标
  • 前端常见异常
  • 小试牛刀-SDK开发实践
  • 渐入佳境-封装通用SDK
  • 探索更多

二、详细知识点介绍:

认识前端监控

前端监控是什么?

前端监控就是尽可能采集浏览器工作过程以及用户交互中产出的性能指标与发生的异常事件并上报到平台完成消费

为什么需要前端监控

从用户的角度:

知晓自己当前遇到什么问题

  • 网页打开速度慢
  • 网页卡顿
  • 图片无法正常展示
  • 页面无法正常加载

从开发者角度解答:

  • 页面某个关键资源渲染太慢
  • 页面同步计算任务太重,阻塞渲染
  • 客户端网络状态差或上游服务节点异常
  • 页面脚本执行失败,关键资源加载失败,请求失败等

前端监控通过对页面数据的采集和上报,帮助开发者更快速的对质量差的页面进行分析和归因

前端监控的内容

  • 性能指标
  • 异常事件
  • 用户行为

image.png

常用性能指标

传统的性能指标主要依赖Navigation Timing或者 Navigation Timing ,通过记录一个文档从发起请求到加载完毕的各阶段的性能耗时,以加载速度来衡量性能。

但是它们很难反映出用户所真正关心的是什么,这就是创建用户为中心的性能指标的原因,专注于用户视角下的浏览体验。

  • FP(First Paint)首次渲染时间点,FP之前是白屏

  • FCP(First Contentful Paint)首次有内容渲染的时间点

  • FMP(First Meaningful Paint)首次绘制有意义内容的时间点

  • TTI(Time to Interactive)测量页面从开始加载到主要子资源完成渲染,并能够快速、可靠地响应用户输入所需的时间,反映页面可用性的重要指标。越小,体验越好

  • SI(Speed Index)衡量页面可视区域加载速度,帮助检测页面的加载体验差异

  • FID(First Input Delay)测量从用户第一次与页面交互直到浏览器对交互信息作出响应,实际能够开始处理事件时处理程序所经过的时间

  • LCP(Largest Contentful Paint)最大内容在可视区域内变得可见的时间点,最大的元素,容易理解、给出与FMP相似的结果,容易计算和上报

  • TBT(Total Blocking Time)量化主线程在空闲之前的繁忙程度,有助于理解在加载期间,页面无法响应用户输入的时间有多久;长任务

  • CLS(Cumulative Layout Shift)量化了页面加载期间,视口中元素的移动程度

如何分析和调优性能瓶颈

衡量

  • Lighthouse
  • 阿里云的ARMS
  • New Relic

采集过程:

以阿里云的ARMS为例,采集通常是由一个JavaScriptSDK来完成的 采集指标

FCP: 首次绘制内容的耗时 

TTI: 页面可交互的时间 

PageLoad: 页面完全加载的时间

FPS: 前端页面帧率

静态资源及API请求成功率

排查

FCP首次绘制内容的耗时

  • Loading图标
  • 骨架屏
  • SSR服务端渲染

TTI页面可交互的时间

  • 核心内容同步加载
  • 非核心内容异步加载
  • 图片懒加载

PageLoad 页面完全加载的时间

异步加载

FPS 前端页面帧率

  • reactwindow
  • 渲染优化

静态资源及API请求成功率

  • 静态资源能用CDN就用CDN,大幅提高静态资源加载的成功率
  • 域名解析时报可以采用静态资源域名自动切换的方案
  • HTTPS

收益

通过TP数 TP50,TP90来具体量化技术转化效果

前端常见异常

  • 静态资源错误:在拉取加载静态资源的过程中发生了预期之外的错误,如网络异常等,导致静态资源无法正常渲染到页面上
  • 请求异常(请求响应状态码>=400)
  • JS错误
  • 白屏异常(通常通过判断DOM树的结构来初略判断)
    • 1.发生JS错误导致关键资源渲染失败
    • 2.请求异常或静态资源加载失败
    • 3.长时间的JS线程繁忙阻塞渲染任务
  1. window.addEventListenererrorunhandledrejection可以监控全局JS错误

  2. window.addEventListenererror可以监控到静态资源错误

  3. 请求监控异常,XMLHttpRequest对象和fetch对象

常见的请求异常

  • 200 - 请求成功
  • 301 - 资源(网页等)被永久转移到其它URL
  • 404 - 请求的资源(网页等)不存在
  • 500 - 内部服务器错误

分类

  • 信息响应(100–199)

  • 成功响应(200–299)

  • 重定向(300–399)

  • 客户端错误(400–499)

  • 服务器错误(500–599)

小试牛刀-SDK开发实践

性能指标监控

原理:浏览器提供的一些标准的对象,比如PerformancePerformanceObserver

  • PerformanceObserverwindow.performance可以访问到更多值,所以在实战中,通常会先尝试使用window.performance来获取值,如果没有,再尝试使用PerformanceObserver来获取值

  • 封装一个monitor, 之后整合到SDK中

  • 对这个monitor的需求

    1. 起名字,类似unique ID,和SDK中的其它monitor起到区分作用
    2. 监听能力
    3. 主动开启,而不是被动开启 (这里的意思是,不希望monitor一被创建就主动开启,而是希望通过SDK来管理monitor的启停)
    4. 上报能力
/**
 * 列举出性能指标对应的 entry type
 * fp,fcp --> paint
 * lcp --> largest-contentful-paint
 * fip --> first-input
 */
const entryTypes = ['paint', 'largest-contentful-paint', 'first-input']

// 1. 通过 PerformanceObserver 监听
const p = new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    console.log(entry);
  }
})
p.observe({ entryTypes });
// 2. 也可以通过 window.performance 对象拿到 fp fcp 和 fip。
// 注意如果同步打印他们是取不到值的,想想为什么?
window.performance.getEntriesByType('paint');
window.performance.getEntriesByType('first-input');


// 3. 封装成一个 monitor
function createPerfMonitor(report: ({ name: string, data: any }) => void) {
  const name = 'performance';
  const entryTypes = ['paint', 'largest-contentful-paint', 'first-input']
  function start() {
    const p = new PerformanceObserver(list => {
      for (const entry of list.getEntries()) {
        report({ name, data: entry });
      }
    })
    p.observe({ entryTypes });
  }
  return { name, start }
}

JS错误监控

  • 监听JS错误,我们一般是在根节点或是全局对象监听(window)
  • 注意第二行中的回调函数的入参e,我们需要对其进行一个过滤,以防其混入其他类型的一个错误,只有 e.error不会空时,它才是一个合法的JS错误

window.addEventListener的error和unhandledrejection监控到全局的js错误,用type区分

// 1. 监听 js 执行报错
window.addEventListener("error", (e) => {
  // 只有 error 属性不为空的 ErrorEvent 才是一个合法的 js 错误
  if (e.error) {
    console.log('caputure an error', e.error);
  }
});
// throw(new Error('test'));
// 2. 监听 promise rejection
window.addEventListener("unhandledrejection", (e) => {
  console.log('capture a unrejection', e);
});
Promise.reject('test');

// 3. 封装成一个 monitor
function createJsErrorMonitor(report: ({ name: string, data: any }) => void) {
  const name = "js-error";
  function start() {
    window.addEventListener("error", (e) => {
      // 只有 error 属性不为空的 ErrorEvent 才是一个合法的 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 }
}

静态资源错误监控

  • 监听我们静态资源错误和监听JS错误是用同一套方案进行监听的,所以两种错误要注意进行区分
  • 注意这里是如何区分静态资源错误js error
  • 前端监控很关注的一点的是兼容性,所以这里e.srcElement虽然已经是deprecated,但为了支持老浏览器,所以也把它写上了
  • 静态资源只能在捕获阶段对它进行一个捕获,所以我们需要把addEventListener的第二个参数置为true,默认为false,为冒泡阶段捕获

window.addEventListener的error事件可以监测到静态资源错误,和js错误进行区分

// 1. 监控静态资源错误,注意需要在捕获阶段才能监听到
window.addEventListener('error', e => {
  // 注意区分 js error
  const target = e.target || e.srcElement;
  if (!target) {
    return
  }
  if (target instanceof HTMLElement) {
    let url;
    // 区分 link 标签,获取静态资源地址
    if (target.tagName.toLowerCase() === 'link') {
      url = target.getAttribute('href');
    } else {
      url = target.getAttribute('src');
    }
    console.log('异常的资源', url);
  }
}, true)
const link = document.createElement("link");
link.href = "1.css";
link.rel = "stylesheet";
document.head.append(link);

const script = document.createElement("script");
script.src = "2.js";
document.head.append(script);

// 2. 封装成一个 monitor
function createResourceErrorMonitor(report: ({ name: string, data: any }) => void) {
  const name = "resource-error";
  function start() {
    window.addEventListener('error', e => {
      // 注意区分 js error
      const target = e.target || e.srcElement;
      if (!target) {
        return
      }
      if (target instanceof HTMLElement) {
        let url;
        // 区分 link 标签,获取静态资源地址
        if (target.tagName.toLowerCase() === 'link') {
          url = target.getAttribute('href');
        } else {
          url = target.getAttribute('src');
        }
        report({ name, data: { url } });
      }
    }, true)
  }
  return { name, start }
}

请求异常监控

  • 前置知识: hook函数(钩子函数)

  • 这里hook函数的三个参数解释 :第一个参数 => 想要hook的对象; 第二个参数 => 想要hook的对象的参数; 第三个参数 => 我们想要注入的自己的逻辑

  • 注入的时候需要考虑清除,是否需要破坏原有的逻辑

// 1. 写一个简易的 hook 函数
function hookMethod(
  obj: any,
  key: string,
  hookFunc: Function,
) {
  return (...params: any[]) => {
    obj[key] = hookFunc(obj[key], ...params)
  }
}


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

// 3. 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();

// 4, 封装成一个 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 }
}

渐入佳境-封装通用SDK

目前我们编写的monitor是相互孤立的,monitor之间没有任何的联系,不能串成什么东西,需要一个统一的容器,需要封装一个通用的SDK

前端监控流程概述

  1. 数据采集
  2. 组装上报
  3. 清洗存储
  4. 数据消费

专为前端监控打造的请求函数:Navigator.sendBeacon()

慢请求

Performance Resource Timing获取请求各阶段耗时,找出所有慢请求

按需加载监控能力

  • 这里我们是使用函数式的方式来创建SDK,当然有些同学比较喜欢使用Class的方式来实现,是更接近OOP的一个思想,但是由于JS原生并不支持OOP,是通过原型链的方式实现的,在我们打包成代码的时候,会有一些为了实现Class这个关键字而作的一些Polyfill,为了减少我们SDK的体积,在我们实践过程中不会用Class,而是会用fn函数式的思想来封装SDK,会使我们SDK的体积更加精简
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;
}
const sdk = createSdk("111.com");
const jsMonitor = createJsErrorMonitor(sdk.report);
sdk.loadMonitor(jsMonitor).loadMonitor(createPerfMonitor(sdk.report));
sdk.start();
throw (new Error('test'));



function createJsErrorMonitor(report: ({ name: string, data: any }) => void) {
  const name = "js-error";
  function start() {
    window.addEventListener("error", (e) => {
      // 只有 error 属性不为空的 ErrorEvent 才是一个合法的 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 }
}

function createPerfMonitor(report: ({ name: string, data: any }) => void) {
  const name = 'performance';
  const entryTypes = ['paint', 'largest-contentful-paint', 'first-input']
  function start() {
    const p = new PerformanceObserver(list => {
      for (const entry of list.getEntries()) {
        report({ name, data: entry });
      }
    })
    p.observe({ entryTypes });
  }
  return { name, start }
}

探索更多

  • 用户行为监控对我们复现一个异常复现的发生一个过程也是非常重要的,也是不可缺少的一环
  • 清洗存储 => 一般是大数据相关的知识
  • 数据消费 => 一般是接口写的多一点

三、引用参考: