从零搭建前端异常捕获平台
@殷燃映客高级前端工程师
当前互联网产品竞争激烈的环境下,前端研发在一个产品的生产链上承担了越来越重要的角色。作为直达用户的一层应用,跟安卓、IOS 的原生 App 一样,可以说是直接衡量一个项目产品好坏的第一道关口。
和原生应用不同的是,前端应用在运行环境方面存在太多不可控的复杂因素,并没有一个相对稳定运行环境来保证我们的项目一定不出问题。在测试资源没有特别多的情况下,开发和测试人员测试再充分,「由于不同用户复杂的运行环境和操作,也难免会出现意想不到的问题」。
出现问题不可怕,可怕的是我们解决线上问题的手段贫乏,效率低下,导致项目的体验和质量低下。这也是一个产品初期,前端这一块发生比较普遍的现象。「想要让产品的质量和服务更上一层楼,不能总是等用户反馈问题,我们需要及时快速地响应解决错误,这时候一个好的监控平台就十分必要。」
市面上虽然有一些前端监控平台在一些大厂项目中使用,只是在一些中小型项目里,用这些收费的项目,可能有些不太合适。我们在这里只是从技术原理上探讨和讲述如何搭建一个精简的监控平台,重点在解释原理和引出思路,仅做参考,具体实现需要结合具体业务场景【底部附上源码地址】。
为了打造一个轻量级、易接入的监控平台,我们先理一下异常上报过程的整个流程架构,如下:
如图展示,在项目中只需要引入一段 js,即可开始监听错误事件,记录用户行为,检查运行环境信息,录屏(选择接入)等四个模块的任务。当一旦检测到错误发生,主动调用我们暴露给开发者的一个全局方法,即可实现上报。上报信息除了错误本身的信息以外,还包括此时记录下来的所有页面信息。
尽管流程图的结构已经展示了抓取错误主要途径是「监听错误事件」,我们还需要了解一下,前端的脚本语言 javascript 是如何监听错误事件的
JS 错误包含两种:
JS 引擎在检测语法没有错误的情况下,开始运行代码内容。由于 JS 是一门解释型语言,JS 引擎执行前并不知道要执行下一行代码的逻辑是否能被识别,所以可能会出现运行时的错误,如: 引用错误,类型错误等。
当运行发生错误时,会抛出一个错误对象 Error,此时需要用户需要用「try...catch」语句对错误进行捕获处理。如果在该行代码的上层所有调用栈中,都没有「catch」住这个错误,那就会在全局 window 下会触发一个「error 事件」,并执行「window.onerror()」。
JS 的内置对象 Error,就是用来生成描述所有错误对象的构造函数,包含内建的标准错误类型有:
虽然全局的 error 事件能够帮助我们监听到 Js 运行时的异常,可是一个页面里的错误可不仅仅就这些,因此需要其他方式检测到这些错误。
Promise 处理 JS 异步已经是非常普遍的一个方式,但如果开发者没有捕获 Promise 的「reject」处理方法,全局会触发「unhandledrejection 事件」,并执行「window.onunhandledrejection()」。如下图:
window.onunhandledrejection = e => {
console.log('onunhandledrejection=>', e)
}
function foo(){
return Promise.reject('Hello, Inke!');
}
var r = foo();
值得注意的是,如果一个 Promise 错误最初未被处理,但是稍后又得到了处理,则会触发「rejectionhandled 事件」。因此最好在监听到「unhandledrejection 事件」时,不要立刻触发上报,可以选择等待一定时间,监听这个 Promise 是否被处理了,到时再进行上报处理。
资源加载错误,小则影响页面展示,大则影响页面所有功能,因此也需要检测。img、script 里的 src 和 link 标签里的 href 属性存在时,会请求对应的资源。如果错误资源报错,该标签会触发 error 事件,执行 DOM 的「onerror」方法,但并不会冒泡到全局。因此需要在事件捕获阶段就监听到,由于此类事件的「target」是元素本身,而不是全局 window,因此可以和其他 js 报错区别开来。
前端页面的网络请求的方式有两种:「XMLHttpRequest」(简称 XHR)和「fetch」。都可以通过劫持这两个请求,来自定义事件,来实现监听(下文详细说明)
接下来,我们将针对流程图里的四个模块逐个讲解实现。
遇到的第一个问题就是这个,前面说到全局错误事件可以有这两种方式捕获到,确实都可以,功能上没什么差别。有不少人倾向「onerror」,因为兼容性更好,毕竟「addEventListener」在 IE8 以前不支持。但是,「onerror」有很大的缺点,就是可以被覆盖。我们总不希望,用户可以这样轻易地把我们的功能破坏掉吧。
因此采用「addEventListener」,开发者可以继续添加事件回调,那 IE8 以前我们就不支持了?并不是,IE8 以前有另一个方法「attachEvent」,只不过是监听的事件名前面要加 on。所以我们可以封装出一个事件监听的方法,来在项目内部使用。而且我们可以把监听的回调函数加上「try...catch」处理,防止内部报错对外部造成影响,因此也封装了一个函数包装方法。如下:
// 通用事件监听方法
const myAddEventListener = (name, fn, useCapture) => {
if (addEventListener) { // 所有主流浏览器,除了 IE 8 及更早版本
addEventListener(name, tryCatchFunc(fn), useCapture);
} else if (attachEvent) { // IE 8 及更早版本
attachEvent(`on${name}`, tryCatchFunc(fn));
}
}
// 对方法进行封装,防止内部报错
export const tryCatchFunc = fn => function (...args) {
try {
return fn.apply(this, args)
} catch (error) {
console.warn('内部报错', error)
}
}
如3.3.1提到的原理,Promise 的,Promise 监听的实现代码如下:
// 处理primise报错,设置了一个修复机制
const handlePromise = e => {
let promiseTimer = setTimeout(tryCatchFunc(() => {
const { reason } = e
e.message = reason && typeof reason === 'object' ? JSON.stringify(reason) : (reason + '')
e.name = 'unhandledrejection'
errorReport(e) // 上报错误
}), PROMISE_TIMEOUT)
edithAddEventListener('rejectionhandled', tryCatchFunc(event => {
if(event.promise === e.promise) {
if(promiseTimer) clearTimeout(promiseTimer)
promiseTimer = null
}
}))
}
如3.3.2提到的原理,在捕获期间监听全局 error 事件,代码如下:
// 捕获到错误时的回调函数
function errorReport(errorEvent) {
const errorTarget = errorEvent.target;
if (errorTarget !== window) {
// 元素错误,比如引用资源报错,只是普通事件,不是ErrorEvent;html标签的资源报错,暂时不知道发生在哪一行。
const tagName = errorTarget.tagName.toLowerCase()
let sourceUrl = ''
if(tagName === 'link') {
sourceUrl = errorTarget.href
} else sourceUrl = errorTarget.src
errorEvent.message = sourceUrl
errorEvent.name = 'resourceError'
errorEvent.targetDom = {
tagName,
className: errorTarget.className,
id: errorTarget.id,
outerHTML: errorTarget.outerHTML,
xPath: getXPath(errorTarget)
}
}
reportDebug(errorEvent) // 上报错误
}
从前端逻辑代码来说,这并不算是前端的错误。但是作为一个完整的 webview 应用,「网络请求」是页面功能实现非常重要的部分。上报网络请求的错误,可以协助我们排查服务端的问题。网络请求的监听不仅仅在错误的时候需要,用户行为记录里也需要,因此现在这里统一讲述吧。 一般前端请求都是「ajax 请求」,不过也有「fetch 请求」,以及前端框架自己封装的请求等等。总之他们封装的方法各不相同,但是万变不离其宗,他们都是对「window.XMLHttpRequest」进行了封装,所以我们只要能够监听到这个对象的一些事件,就能够把请求的信息分离出来,代码如图:
function ajaxEventTrigger(event) {
var ajaxEvent = new CustomEvent(event, { detail: this });
dispatchEvent(ajaxEvent);
}
const ajaxEvents = {
abort: 'ajaxAbort', // 请求中止
error: 'ajaxError', // 请求错误
load: 'ajaxLoad', // 请求加载
loadstart: 'ajaxLoadStart', // 请求加载开始
progress: 'ajaxProgress', // 请求中
timeout: 'ajaxTimeout', // 请求超时事件
loadend: 'ajaxLoadEnd', // 请求加载结束
}
const oldXHR = XMLHttpRequest;
function newXHR() {
var realXHR = new oldXHR();
Object.keys(ajaxEvents).forEach(eventName => {
realXHR.addEventListener(eventName,
function () {
ajaxEventTrigger.call(this, ajaxEvents[eventName]);
}, false);
})
return realXHR;
}
window.XMLHttpRequest = newXHR;
看的出来,经过这一段 js 的注入,全局的「XHR 请求」都会在各个阶段触发对应的事件,这样我们就可以监听我们需要的内容,包括跨域的错误。
通过上面的方法,已经能够监听到所有通过「AJAX 请求」了,然而却无法监听到「fetch」的请求事件,这是怎么回事呢?明明「fetch」也是基于「XMLHttpRequest」实现的一层封装啊。原来事实上,「fetch」的代码实现是「内置在浏览器中」的,它必然先用监控代码执行,所以,我们在添加监听事件的时候,是无法监听「fetch」里边的「XMLHttpRequest」对象的。怎么办呢?其实也简单,重写一个「fecth」即可,因此我们只需要类似于上面的方式重写一个「fetch」方法即可,代码如图:
//拦截原始的fetch方法
const oldFetchfn = fetch;
if(!oldFetchfn) return
function fetchEventTrigger(event, detail) {
var ajaxEvent = new CustomEvent(event, { detail });
dispatchEvent(ajaxEvent);
}
fetch = function (...args) { // 重写fetch方法,注入自定义事件
const now = +new Date()
const options = {
url: args[0],
method: 'GET',
body: null,
originUrl: args[0],
...args[1]
}
fetchEventTrigger.call(this, 'fetchStart', { options })
return oldFetchfn.apply(this, args).then(res => {
res.options = options
fetchEventTrigger.call(this, 'fetchEnd', res)
return res
}, err => {
err.options = options
fetchEventTrigger.call(this, 'fetchError', err)
}
)}
上述的代码,我们只是监听了「XHR 请求错误事件」和「fetch」的「reject 状态」,对于状态码非 2xx 的请求并不属于请求的错误,是业务代码逻辑的错误。可以把 XHR 理解为一个快递员,快递找不到(404)或者仓库爆炸了(500),这样的异常,并不是快递员(XHR)的错误事件,因此我们如果需要检测状态码非 2xx 的接口异常那就在请求结束后对拿到的「response」进行判断,因此上述代理 XHR 里的部分代码需要更改为:
const oldXHR = XMLHttpRequest;
const spelEvents = ['error', 'timeout', 'abort']
function newXHR() {
var realXHR = new oldXHR();
let isException = false // 判断是否已经是错误请求了,防止跨域这种错误触发两次错误事件
Object.keys(ajaxEvents).forEach(eventName => {
realXHR.addEventListener(eventName,
function (e) {
eventTrigger.call(this, ajaxEvents[eventName]);
if(eventName === 'loadend') { // 请求结束后对状态码进行判断
if(this.status > 399 && !isException) { // 判断状态码,是否是成功的请求,而且不是已经报错了的请求
eventTrigger.call(this, ajaxEvents['error'])
}
}
isException || (isException = spelEvents.indexOf(eventName) >= 0)
}, false);
})
return realXHR;
}
「fetch」方法也是一样,需要在「resolve」后进行判断,修改的部分代码如下:
const oldFetchfn = fetch;
if(!oldFetchfn) return
fetch = function (...args) {
... // 省略
return oldFetchfn.apply(this, args).then(res => {
res.options = options
if(res.status > 399) { // 判断状态码。是否是成功的请求
eventTrigger.call({ options }, 'fetchError')
}
eventTrigger.call({ options }, 'fetchEnd')
return res
}, err => {
err.options = options
eventTrigger.call({ options }, 'fetchError')
})
}
可能很多开发者都不知道,前端也可以通过一些技术性的操作得到能够还原用户所有操作的“「视频」”,这样就能够获取到用户在报错之前的所有行为,能够还原用户所有操作的“「视频」”。这是我们可以做的黑科技,目前市面上极少有项目有这个功能,这样可以非常直观地看到发生在用户端的错误现象,「从此告别让用户截屏录屏等繁琐操作」!问题反馈链路大大缩短。
话不多说,先看看我们实际的效果吧。有录屏并不是最惊讶的,惊讶的是,上传这样一段录屏数据,花费了多少数据量呢,据计算是「20kb~50kb」。也就是说并不会占用多少宽带。那对页面的性能会有影响吗,到底怎么实现的呢? 谜底揭晓,其实之所以这样的录屏并不算是真正的视频,是因为我们并没有去一帧一帧地绘图,来拼凑成所谓的视频。而是使用了使用MutationObserver[1](IE11 以下的浏览器不支持,其他不支持的浏览器版本还包括这些),监听页面 dom 的变化和用户交互事件,生成类似胶卷一样的 Dom 快照数据,所以只是记录了变化的 Dom, 并不会对页面性能有太多影响。gif 中的页面,并没有用到视频组件,里面实际上是一个 Dom 区域,用一个「iframe」包裹,按照时间戳还原用户端的 Dom 变化,因此我们得到数据量并没有很大。
由于这个功能是选择性接入的,因此并不影响主要流程。可以参考rrweb[2]这个开源的技术框架,选择性引入,具体使用的细节可以参考项目中的文档。
我们解决报错问题,除了专注报错本身,运行环境对代码的影响也非常大,因此我们需要了解尽量多的运行环境信息。一般运行环境的信息在「navigator.userAgent」已经描述得比较详细了,除此之外,我们还能获得什么信息?比如:
主动检测模块可以让我们收集到更多的环境信息,更加精准的定位到问题的根源。
计算延迟可以通过 js 加载一张 1x1 的极小图片,测试出图片加载的所用的时长。但是,第一次加载图像时,一般会比后续加载花费更长的时间,即使我们确保图像没有被缓存。因为第一次在两个主机之间打开「TCP 连接」时,它们需要“握手”。一旦建立连接,它就会保持打开状态,直到两端都通过类似的握手决定关闭它。所以我们尽量以后面几次数据的平均值为准,代码如图:
// 获取延迟,通过js加载一张1x1的极小图片,来测试图片加载的所用的时长
const measureDelay = (fn, count) => {
count = count || 1
let n = 0, timeid
const ld = () => {
const t = getCurrentTime(), img = new Image;
img.onload = () => {
const tcp = getCurrentTime() - t
n++
fn(tcp) // 存储延迟回调
if(n < count) timeid = setTimeout(ld, 1000);
}
img.src = '//xxx/test.gif?' + Math.random();
img.onerror = tryCatchFunc(eventPresent)
};
const img_start = new Image();
img_start.onerror = tryCatchFunc(eventPresent)
img_start.onload = ld
img_start.src = '//xxx/test.gif?' + Math.random();
}
const delays = []
const checkDelay = () => {
measureDelay(tcp => delays.push(tcp), 5)
}
计算网络速度也是类似的想法,只是图片要大一些,不过我们可以不用选择 img 标签的形式,而是用 ajax 请求的方式。原因主要是两点:
// 通过发起http请求,测试网络速度, 定时调用回调,参数为单位为KB/sec的数值
const measureBW = (fn, time) => {
const test = n => {
const startTime = Date.now();
measureBWSimple({ t : Math.random() }).then(res => { // 封装的发起GET请求,获取图片文件(有服务端支持最好)
const fileSize = res.length
const endTime = Date.now();
var speed = fileSize / ((endTime - startTime)/1000) / 1024;
fn && n && fn(Math.floor(speed));
if(n >= time) return
test(++n)
}).catch(e => {})
}
test(0)
}
// 浏览器自带API测网速,返回单位为KB/sec的数值
// https://juejin.im/post/5b4de6b7e51d45190d55340b
const testNetworkSpeed = () => {
if(navigator.connection && navigator.connection.downlink){ // 在 Chrome65+ 的版本中,有原生的方法
return navigator.connection.downlink * 1024 /8; //单位为KB/sec
}
}
// 检测网络速度
export const checkNetSpeed = () => {
const speed = testNetworkSpeed()
if(speed) return speeds.push(speed)
measureBW(speed => speeds.push(speed), 5)
}
不过对于执行这些检测的时机也是需要点技巧的,在不干扰用户行为的情况下,或者说,在浏览器空闲的时候执行这些检测才是合理的。因此我们可以利用一个 API 在合适的时机来调用这些数据。requestIdleCallback[3]就是一个在浏览器的空闲时段内调用的函数排队的 API,只不过这是一个比较新的 API,因此需要做好处理,代码如下图:
if (!requestIdleCallback) {
setTimeout(checkDelay, Math.random() * 6000 + 500) // 随机延后执行
return setTimeout(checkNetSpeed, Math.random() * 7000 + 500)
}
// 任务队列
const tasks = [
checkDelay, // 检测延迟
checkNetSpeed, // 检测网速
];
function myNonEssentialWork (deadline) {
// 如果帧内有富余的时间,或者超时
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && tasks.length > 0) {
tasks.shift()();
}
tasks.length > 0 && requestIdleCallback(myNonEssentialWork);
}
requestIdleCallback(myNonEssentialWork, { timeout: 2000 });
Performance[4]可以获取到当前页面中与性能相关的信息,performance.timing[5]提供了在加载和使用当前页面期间发生的各种事件的性能计时信息,performance.getEntries[6]提供了当前页面所有资源的性能数据,包括文件资源,渲染数据等,不过我们并不全部需要,可以通过performance.getEntriesByType[7]获取指定的资源数据。这些数据都可以协助我们在多个维度,分析页面的性能。这些性能数据对我们可以还是比较模糊的概念,里面的概念和有实际应用价值的性能数据,可以参考详解前端 Performance。
虽然我们有了录屏的功能,但是我们对用户的行为了解并不彻底,毕竟在操作频繁的情况下,录屏最多只提供几秒内的数据,因此更加详细的行为记录十分必要。因此我们选择从「用户交互」、「网络请求」、「路由跳转」三个维度来记录用户的行为,他们按照打开页面开始后的时间戳,依次记录,方便还原。
在模块化的开发方式下,数据对象作用域隔离不是问题,但为了方便添加记录,做到既可以添加数据,又可以根据唯一标识覆盖记录来保证数据是可以实时刷新,同时内部实现「溢出自动出栈」的效果,我们需要给目标对象添加一个「add」方法,项目中各个维度的记录都调用此方法, 代码如下:
const breadcrumbs = []
// 自定义add方法来添加数据,并且支持持根据唯一id,选择覆盖还是添加操作
breadcrumbs.add = data => {
const index = breadcrumbs.findIndex(i => data.eid && i.eid === data.eid) // 如果有相同的eid,代表只需要修改
if(index >= 0) return breadcrumbs.splice(index, 1, {...breadcrumbs[index], ...data})
if(breadcrumbs.length >= RECORD_COUNT) breadcrumbs.shift() // 超过数量限制后自动会移除最早的记录
breadcrumbs.push({
eid: getRandomID(), // 自定义实现随机ID方法
...data
})
}
有了这样的对象存储,接下来就可以着手如何添加记录了。
一个前端页面里比较隐藏的错误往往发生在用户在进行了某些交互操作后出现,因此为了清晰还原用户端的行为,记录用户的交互是第一要素。我们这里暂时只记录了点击事件,其他行为如滚动页面行为, 元素进入用户视野等等,或者有些操作是在「TouchStart」,「TouchMove」,「TouchEnd」等事件中进行,这些大多跟业务场景比较贴近,后续会用可配置的方式来记录上传这类行为,首先我们来看看如何实现记录点击事件。
// 监听全局点击事件
myAddEventListener('click', addActionRecord('click'), true)
/**
* 用户交互行为记录监控
*/
const addActionRecord = type => event => {
// 记录用户点击元素的行为数据
const errorTarget = event.target; // target支持性好
const tagName = errorTarget.tagName;
const className = errorTarget.className;
const id = errorTarget.id;
let outerHTML = errorTarget.outerHTML;
// 如果内容过长,截取上传
if (outerHTML.length > 200) outerHTML = outerHTML.substr(0, 100) + '... ...' + outerHTML.substr(-99);
const record = {
type,
time: getCurrentTime(),
timeStamp: event.timeStamp,
page: {
url: location.href,
title: document.title
},
detail: {
className,
id,
outerHTML,
tagName,
xPath: getXPath(errorTarget) // 自定义实现获取Xpath的方法
de
}
}
breadcrumbs.add(record)
};
由于开发者可能会对一些点击事件进行阻止冒泡,因此在监听点击事件时必须得在捕获阶段,同时,我们需要记录一些点击事件目标 Target 的详情,如:
// 获取Dom的xpath
const getXPath = element => {
const { parentNode } = element
if(parentNode === document) return `/html`
const tagName = getTagName(element).toLowerCase()
if (element === document.body) return `/html/${tagName}`
let ix = 0;
const siblings = parentNode.childNodes;
for (let i = 0; i < siblings.length; i++) {
const sibling = siblings[i];
if (sibling === element)
return `${getXPath(parentNode)}/${tagName}[${(ix + 1)}]`
if (sibling.nodeType === 1 && sibling.tagName === tagName) ix++;
}
}
在说明如何监听网络请求错误的事件时,我们讲解了如何对网络请求的各个阶段进行了事件监听。与点击这类事件不同,网络请求是一个异步的过程,不能只监听一次事件。设想一下,我们如果只监听开始请求事件,则将无法得知请求是否完成, 耗时多少;同样地如果只监听请求结束事件,万一在请求过程中页面发生了错误,开始触发上报,那我们此时就没有记录到此次请求。这都是不合理的,因此需要不断跟踪这一个请求从发起到结束的全部状态。在这之前我们需要了解 XHR 的请求事件的触发顺序,如图:由图可见,在没有发生网络错误的情况下,我们需要监听不同的事件来记录网络请求的所有过程,其中关键的节点包括:「loadstart」,「progress(upload)」,「progress」,「loadend」。不过我们发现监听这些事件时,发现以下几点问题:
const oldXHR = XMLHttpRequest;
const spelEvents = ['error', 'timeout', 'abort']
function newXHR() {
let realXHR = new oldXHR();
... // 省略其他事件
// 封装send方法
const send = realXHR.send;
realXHR.send = function(...arg){
send.apply(realXHR,arg);
realXHR.body = arg[0];
ajaxEventTrigger.call(realXHR, 'ajaxSend');
}
// 封装open方法
const open = realXHR.open;
realXHR.open = function(...arg){
open.apply(realXHR,arg)
realXHR.method = arg[0];
realXHR.originUrl = arg[1];
realXHR.async = arg[2];
ajaxEventTrigger.call(realXHR, 'ajaxOpen');
}
// 封装setRequestHeader方法
const setRequestHeader = realXHR.setRequestHeader;
realXHR.requestHeader = {};
realXHR.setRequestHeader = function(name, value){
realXHR.requestHeader[name] = value;
setRequestHeader.call(realXHR, name, value)
}
return realXHR;
}
window.XMLHttpRequest = newXHR;
因此为了每个事件有价值,拿到此时真实的网络请求数据,监听如下事件:「ajaxOpen」(替代 loadstart),「progress」,「loadend」。同时给 XHR 示例添加时间戳等方法,每次更新记录最新的耗时。
有一点需要注意,我们的上报和网络监测等内部功能,不应该添加到记录里,因此需要有白名单控制 fetch 请求的监听跟 XHR 类似,只需监听「fetchStart」和「fetchEnd」事件,只是抓取到的参数没有那么丰富。其他跟 XHR 一样,记录类型不同而已。添加记录的代码如下图:
/**
* 添加http请求记录监控,包括fetch
*/
const addHttpRecord = (xhr, type = 'XMLHttpRequest') => {
const { method, status, statusText, responseURL, originUrl, body = null,
requestHeader, startTime, endTime, _eid, timeStamp } = xhr
const elapsedTime = endTime - startTime // 请求耗时
if(ajaxWhiteList.indexOf(originUrl && originUrl.split('?')[0]) >= 0) return //白名单接口不记录
const record = {
eid: _eid,
type,
time: Date.now(),
timeStamp,
page: {
url: location.href,
title: document.title
},
elapsedTime,
detail: {
method, // 请求方法
status, // 状态码
body, // post请求的body
requestHeader,
responseHeader: xhr.getAllResponseHeaders(),
statusText, // 状态
responseURL, // 接口响应地址
originUrl, // 请求的原始参数地址
}
}
if(!timeStamp) delete record.timeStamp
breadcrumbs.add(record)
}
为什么要记录页面跳转呢?有时候我们的页面错误,并不是因为当前页面的逻辑出错了,而可能受其他页面的逻辑影响。在单页应用中就很常见,「状态管理」使多个页面的数据得以共享,当我能够清晰地了解到我的路由跳转是什么样的顺序,我们就能评估报错会是受到了哪里的影响。
除此之外,还有一个很重要的原因,页面之间的通信经常是以 url 的形式完成,如果发生原子参数丢失,或者拼接错误,如果我们不知道页面之前的跳转,自然很难找到原因。这曾经在我们公司一个项目中就困扰了非常之久,不了解报错的页面参数如何丢失,从哪里跳转过来,url 如何发生了变化,如果加上这一个检测,我们就能解决这一痛点。 于是我们可以通过什么样的方式监听页面 url 的变化呢?
if(!history.replaceState && history.pushState ) return
function proxyState (prop, fn) {
return function(...arg){
arg[2] && fn && eventTrigger.call({
oldURL: location.href,
newURL: arg[2],
method: prop,
}, 'navigationChange');
return fn.apply(this, arg);
}
}
// 封装history.pushState
history.pushState = proxyState('pushState', history['pushState'])
// 封装history.replaceState
history.replaceState = proxyState('replaceState', history['replaceState'])
但是如果在 IE 浏览器环境下,是「IE8 以前」不支持 hashchange 的。这种情况下就用定时器时刻监听 url 是否有变化。那么代码如下:
if(isIE8) {
let url = location.href
const now = performance.timing.navigationStart || getCurrentTime()
setInterval(function() {
if(url!=location.href){
addUrlRecord('intervalCheck')({
oldURL: url,
newURL: location.href,
timeStamp: new Date() - now // 模拟时间戳
})
url = location.href
}
}, 250);
} else {
myAddEventListener('hashchange', addUrlRecord('hashchange'))
myAddEventListener('navigationChange', addUrlRecord())
}
// 记录路由跳转
const addUrlRecord = method => event => {
const record = {
type: 'navigation',
time: getCurrentTime(),
method: method || event.detail.method,
timeStamp: event.timeStamp,
detail: {
from: {
url: event.oldURL || event.detail.oldURL,
title: document.title
},
to: {
url: event.newURL || event.detail.newURL,
}
}
}
breadcrumbs.add(record)
}
当然,除此之外,「location.href」和「location.replace」也会跳转页面,更改 url,但并不会触发 popstate。而且可惜的是,这两个 location 的属性是不可配置「configurable: false」的,所以无法重写。不过到了这里我们并不用为这里不能监听而沮丧,毕竟这两个方法会重置页面,因此我们只需要知道报错的 url 和「documen.referrer」也能够了解到足够的信息,甚至可以上报一些本地存储的数据,这样可以不用非得知道这里是如何跳转,也能排查问题了。
为什么没有用 popstate 事件?最开始的想法里,popstate 肯定是需要的,也确实能够监听 URL 变化,但是有问题的是:1. 单页应用的 hashchange 事件触发时,popstate 也会触发,监听多余了。2.popstate 的事件回调里,拿不到上一个页面的 url,监听没有意义
脚本收集完错误信息之后,需要一个后台来存储这些信息,同时来根据服务端收到的错误日志,来查看解决问题。这部分属于数据分析的功能了,可以单独拿出来讲,在这里就不细聊了。
一个监控平台,核心就是数据收集上报。本文作为搭建监控平台的数据上报部分,已经把前端脚本能够拿到的错误和相关信息拿到了,后续的错误统计处理、性能分析部分属于一个后台项目了,后续会讲解如何处理这些数据,如何搭建一个可视化的监控后台和相关功能,同时也希望这篇文章能帮助大家了解如何编写一个数据上报的脚本。
感谢观看~
源码地址:https://github.com/inkefe/edith-script
MutationObserver API:https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver
[2]rrweb:https://github.com/rrweb-io/rrweb
[3]requestIdleCallback API:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback
[4]Performance:https://developer.mozilla.org/zh-CN/docs/Web/API/Performance
[5]PerformanceTiming:https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceTiming
[6]performance.getEntries:https://developer.mozilla.org/zh-CN/docs/Web/API/Performance/getEntries
[7]performance.getEntriesByType:https://developer.mozilla.org/zh-CN/docs/Web/API/Performance/getEntriesByType