Skip to content

10 资源和脚本异常指标:监听资源和脚本状态,收集异常数据

你好,我是三桥。

前两节课,我们学习了读取网页指标和封装接口状态的方法,同时通过实战把有效信息转化为数据指标模型,并且封装到了前端全链路SDK里面。

今天,我们继续设计另一个数据指标:前端资源状态。

前端资源是用户体验的重要资产

曾经有一个前端项目我印象特别深刻,主要是做H5营销活动的前端工程。

我评估完这个前端工程后,发现两个有趣的问题。

首先,这个项目是基于Vue技术栈实现的,src目录下有超过200个目录,每个目录就是一个独立的营销活动。目录内不仅包含了JavaScript代码和CSS代码,还包括图片和视频。

第二,使用本地和生产环境打包后运行项目发现,每个活动页面加载的前端JavaScript文件超过300个,而且每个页面加载时间接近5秒以上。

我就好奇,难道这个项目一直没有用户反馈加载慢的问题吗?我认为肯定有的。那为什么没有前端同学去优化这个项目呢?我猜,要么是能力有限,要么是改造工程量大。

像这种前端项目,不影响业务使用和用户体验倒还好,但如果因为加载速度慢导致用户留存率低,那真的只能怪前端同学了。

类似加载超过300个JavaScript文件这样的问题,其实是由于前端工程的打包工具没处理好,导致所有目录的JS逻辑都被打包进来了,并且是一次性统一加载的。

我从这个项目总结出一个结论,那就是前端资源是Web应用在用户体验上的重要资产,如果我们不能提前发现请求资源的问题,迟早都会影响我们的用户,甚至产品的体验。

那问题来了,在前端全链路的解决方案中,我们应该重点关注前端哪些资源的哪些问题呢?

我认为有3个地方需要重点监听资源的状态,监听前端资源下载状态、记录前端资源加载耗时以及监听前端脚本异常报错。

怎样全局监听前端资源状态

我阅读过不少前端工程项目代码,其中有不少还是会使用 onerror 来捕获图片下载失败事件,然后执行 onerror 函数。下面就是一段使用 onerror 事件的示例代码。

<html>
    <img
        id="errorImage"
        src="https://static001.geekbang.org/resource/image/66/b1/66c84d9eff5102e8a79c81fbc4b061b1.jpg"
    />
    <script>
        const img = document.getElementById('errorImage')
        img.onerror = function() {
            // 在这里处理当图片下载或渲染异常时的逻辑
        }
    </script>
</html>

事实上,一个前端工程项目里不止一张图片,不可能对每张图片都实现一遍 onerror 事件,所以上述的实现方案并不能直接应用在项目中。

正确的做法是绑定全局 error 事件来监听资源加载情况。例如可以通过下面这种代码格式来初始化 error 事件。

window.addEventListener('error', handlerError, true)

需要注意的是,上述代码的 error 事件是全局的,也就是说它能监听到前端页面运行时报错的事件,包括 <script><link> 引入的JavaScript和CSS文件。我们统一监听的,是JS和CSS文件、图片以及音视频等最基本的前端资源。

具体实现的逻辑如下代码。

window.addEventListener('error', (event) => {
  let target = event.target || event.srcElement;
  let isResTarget = isResourceTarget(target as HTMLElement);
  if (isResTarget) {
    // 处理全链路关注的前端资源
    saveResourceError(event)
  }
})

从上述代码可以看到一个新的函数 isResourceTarget ,它是一个元素过滤器,可以筛选前端页面重点关注的资源文件。具体的实现逻辑参考如下代码。

export const isResourceTarget = (target: HTMLElement) =>
  target instanceof HTMLScriptElement ||
  target instanceof HTMLLinkElement ||
  target instanceof HTMLImageElement ||
  target instanceof HTMLVideoElement ||
  target instanceof HTMLAudioElement

当然了, isResourceTarget 函数只判断匹配了脚本、链接、图片、音频和视频这5种常见的元素资源。如果需要监控更多的前端资源,你可以根据项目的实际情况扩展该函数。

第二个函数是 saveResourceError 。在匹配到错误的资源类型后,这个函数会把匹配到的内容转换成全链路需要的信息,并存储到 resources 数组内。具体的实现逻辑代码参考如下。

public saveError(event: ErrorEvent) {
  const target = event.target || event.srcElement;

  const nodeName = (target as HTMLElement).nodeName

  const url = (target as HTMLElement).getAttribute('src') || (target as HTMLElement).getAttribute('href')

  const dataId = hashCode(`${nodeName}${event.message}${url}`)

  const traceDataResource: TraceDataResource = {
    dataId,
    name: 'resource-load-error',
    level: TraceDataSeverity.Error,
    message: event.message,
    time: getTimestamp(),
    type: TraceDataTypes.RESOURCE
  }
  this.resources.push(traceDataResource)
}

有点细心的前端同学会发现在上述代码里有2个有趣的地方。

第一地方是 dataId 的生成。我们希望通过一些关键的信息组合,再配合 hashCode 的算法,得出一串字符串。

为什么是这样的算法逻辑呢?我们试想想,如果 nodeNamemessage 以及 url 三个字段值都相同,那最终的 dataId 一定是相同的。假设在某个时间,全链路监控发现了大量相同的 dataId ,那我们就有理由相信前端应用出现了一些共性的问题,例如某一张图一直加载失败。

第二个地方是我用了 this.resources.push 存储一份错误的资源信息。为什么是存储而不是直接发起上报请求呢?因为如果出现大量的资源访问异常,那么 error 事件就会捕获到大量的错误资源,如果我们的实现是立刻发起一次上报行为,那么就有可能发生洪水般的请求,影响用户体验。

总得来说,我们实现全链路的目标就是把有问题的资源收集回来。

是否可以发现加载慢的资源?

在前端工程项目里,JavaScript是负责页面功能和交互的,CSS是负责页面布局的。这两种资源加载速度的快慢,都会影响到用户访问前端页面的首屏加载速度。

另外,有些前端项目经过长期的需求迭代后,JavaScript文件的数量越来越多,体积会越来越大,同样也会让用户访问页面的加载时间更长。

那怎样做才能获取到前端资源的加载耗时呢?

在Web技术标准里, PerformanceObserver 对象是一个性能监测对象,可以生成一个观察者回调函数,然后使用 observe() 方法监视实体对象为 resource 的资源,并调用该观察者的回调函数。

好了,我们现在就通过 PerformanceObserver 对象实现前端资源加载速度的实时监听。

由于我们的全链路SDK是基于Class类封装,所以首先需要在SDK的代码基础上增加 PerformanceObserver 观察者对象,具体实现代码逻辑如下。

export class BaseTrace implements BaseTraceInterface {

  public observer = null

  public constructor(options: TraceOptions) {
    // 忽略其它业务逻辑

    this.observer = new PerformanceObserver((list, observer) => {
      list.getEntries().forEach((entry) => {
        if (entry.entryType === 'resource') {
          this.handleObserverResource(entry as PerformanceResourceTiming)
        }
      });
    });
  }
}

在上述代码里,我们在BaseTrace类的构造函数中创建了观察者函数,并在回调函数内判断。如果当前实体资源是resource类型,那就要进入 handleObserverResource 函数处理逻辑。

接着说说 handleObserverResource 函数内部。首先是通过实体资源对象的duration属性值判断耗时时长。我们设定了每个资源加载时长超过1秒时,就会上报全链路监控。具体的实现逻辑如下代码。

export class BaseTrace implements BaseTraceInterface {

  public resources: TraceDataResource[] = []
  public observer = null

  public handleObserverResource(entry: PerformanceResourceTiming) {
    if (entry.entryType === 'resource') {
      let level = TraceDataSeverity.Info
      if (entry.duration > 1000 && entry.duration < 1500) {
        level = TraceDataSeverity.Warning
      } else  if (entry.duration > 1500) {
        level = TraceDataSeverity.Error
      }
      entry.duration > 1000 && this.resources.push({
        url: entry.name,
        name: `${entry.entryType}-duration-${entry.initiatorType}`,
        type: TraceDataTypes.PERF,
        level,
        message: `duration:${Math.round(entry.duration)}`,
        time: getTimestamp(),
        dataId: hashCode(`${entry.entryType}-${entry.name}`),
      })
    }
  }
}

我们是通过 entry.duration 属性值得到的时间,以1000毫秒至1500毫秒之间和超过1500毫秒两种情况来判断资源性能问题的严重级别。如果命中了我们预设的性能问题,就把相关实体资源记录到 resources 属性内。

最后,在SDK初始化后,再调用 observe() 来触发resource资源的监控事件。具体的实现代码如下。

export class BaseTrace implements BaseTraceInterface {

  public observer = null

  public constructor(options: TraceOptions) {
    // 忽略其它业务逻辑
  }

  public static init(options: TraceOptions): BaseTrace {
    const traceSdk = new BaseTrace(options)

    traceSdk.observer.observe({
      entryTypes: ["resource"],
    });

    // 忽略部分逻辑
    window.traceSdk = traceSdk
    return traceSdk
  }
}

就这样,我们完整地实现了监听耗时较长的前端资源的逻辑。

实际上,前端资源加载速度的快慢,在网页指标里的FCP和LCP里都能够体现出来。但这里的实现方案,就是细化FCP和LCP里面的资源加载部分的时间细节,通过资源加载时间,我们能很轻松地判断FCP和LCP优化的方向。

我们再次强调一点,在前端全链路的监控维度里,JavaScript和CSS是用户体验最重要的核心资源,必须重点关注它们的加载成功率和性能。

如何有效监听脚本异常

只要学过前端技术的同学都知道,JavaScript是一门弱类型的语言,无论我们把代码写得如何完美,都肯定存在着运行报错的问题。

那么,在前端全链路的设计里,应该如何把脚本错误和全链路结合起来呢?我们以前面课程已实现的代码作为基础进行适配改造。

首先,我们在全局监听 error 事件的直接调用 saveError 函数,不再判断是否前端资源。例如下面代码。

public onGlobalError() {
  const _t = this
  window.addEventListener('error', (event) => {
    _t.saveError(event)
  })
}

接着,把是否前端资源的判断逻辑放在 saveError 函数内。

public saveError(event: ErrorEvent) {
  const target = event.target || event.srcElement;
  const isResTarget = isResourceTarget(target as HTMLElement);
  const nodeName = (target as HTMLElement).nodeName

  if (!isResTarget) {
    const traceData: TraceTypeData = {
      dataId: 0,
      name: 'script-error',
      level: TraceDataSeverity.Error,
      message: event.message,
      time: getTimestamp(),
      type: TraceDataTypes.JAVASCRIPT,
      stack: event.error.stack
    }
    this.resources.push(traceData)
  } else {
    const url = (target as HTMLElement).getAttribute('src') || (target as HTMLElement).getAttribute('href')
    const traceData: TraceTypeData = {
      dataId: hashCode(`${nodeName}${event.message}${url}`),
      name: 'resource-load-error',
      level: TraceDataSeverity.Warning,
      message: event.message,
      time: getTimestamp(),
      type: TraceDataTypes.RESOURCE,
      stack: null
    }
    this.resources.push(traceData)
  }
}

为什么我们要把 isResourceTarget 的判断放在 saveError 内呢?主要原因有2个。第一,无论资源加载失败还是脚本失败,都会触发全局 error 事件。第二,由于我们已经设计好全链路的数据结构,所以无论是哪种错误,数据字段和内容几乎没有差异。

因此, saveError 函数是可以当作一个通用error信息存储器。

好了,经过 isResTarget 字段判断是否为前端资源后,我们就可以再创建一份错误信息对象。

由于本节课我们只重点学习前端资源和脚本错误两种错误事件,所以,对于链路日志中的 nameleveltype 三个字段,我会根据实际的情况采用不同的值。例如,如果当前是前端资源, name 就用 resource-load-error 固定值, level 就使用警告 Warningtype 属性采用资源 RESOURCE

Vue的特殊性

另外,现在很多前端项目都是基于Vue技术栈构建的,除了JavaScript的全局error事件,Vue框架也提供了Vue组件内部异常捕获的全局事件errorHandler。

errorHandler事件官方的定义是,为应用内抛出的未捕获错误指定一个全局处理函数。也就是说,Vue应用组件内如果出行运行时错误的逻辑,又没做捕获处理,那么就会触发errorHandler事件。

所以,当我们使用Vue这类框架做项目时,为了确保覆盖更完整的业务链路和错误问题,还需要在该事件下记录全链路的相关日志。参考代码如下。

app.config.errorHandler = (err, instance, info) => {
  // 处理错误,例如:报告给一个服务
  const dataId = hashCode(`${err.name}${event.message}${url}`)
  const traceTypeData = {
    dataId,
    name: 'Vue-error',
    level: TraceDataSeverity.Warning,
    message: `${event.toString()}(${info})`,
    time: getTimestamp(),
    type: TraceDataTypes.VUE,
    stack: err.stack
  }
}

总结

前端资源是Web应用的重要资产,因为有了它们,Web才能给用户提供完整的Web功能。

在前端全链路的监控范围里,全链路应该重点关心有3种资源类的通用问题。它们分别是资源文件加载状态、资源响应时长以及脚本运行时报错。

当然了,这3种事件都是最常见的问题,在实际项目中,还不止这些。例如用户上传图片是否成功?音视频是否能播放?页面表单能否按正常流程提交等等。

前端页面的每一步交互动作,都会给用户带来最真实的感受。把最基础的前端资源加载的稳定性、资源的加载速度以及业务逻辑的兼容性做到极致,是每一个前端同学需要提高的技能。

下节课,我们会学习另外一种特殊日志,行为日志。

思考题

在这节课中,我们一共学习了监听三种通用性问题的实现方法。那么除了这些通用问题,不妨思考一下,在你负责的前端项目里,还有哪些需要我们重点关注的资源类问题?

欢迎你在留言区和我交流。如果觉得有所收获,也可以把课程分享给更多的朋友一起学习。我们下节课见!