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
事件。
需要注意的是,上述代码的 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
的算法,得出一串字符串。
为什么是这样的算法逻辑呢?我们试想想,如果 nodeName
、 message
以及 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
字段判断是否为前端资源后,我们就可以再创建一份错误信息对象。
由于本节课我们只重点学习前端资源和脚本错误两种错误事件,所以,对于链路日志中的 name
、 level
、 type
三个字段,我会根据实际的情况采用不同的值。例如,如果当前是前端资源, name
就用 resource-load-error
固定值, level
就使用警告 Warning
, type
属性采用资源 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种事件都是最常见的问题,在实际项目中,还不止这些。例如用户上传图片是否成功?音视频是否能播放?页面表单能否按正常流程提交等等。
前端页面的每一步交互动作,都会给用户带来最真实的感受。把最基础的前端资源加载的稳定性、资源的加载速度以及业务逻辑的兼容性做到极致,是每一个前端同学需要提高的技能。
下节课,我们会学习另外一种特殊日志,行为日志。
思考题
在这节课中,我们一共学习了监听三种通用性问题的实现方法。那么除了这些通用问题,不妨思考一下,在你负责的前端项目里,还有哪些需要我们重点关注的资源类问题?
欢迎你在留言区和我交流。如果觉得有所收获,也可以把课程分享给更多的朋友一起学习。我们下节课见!