前端项目中的错误追踪与上报的若干方法
错误追踪是前端项目中不可不谈的一个主题,最近巩固学习 JavaScript 的基础内容,也对前端错误追踪有了一些理解,这篇文章简单的整理一下前端项目中错误追踪的若干种方法。
错误和异常
错误和异常是两种相似的概念,可以分割看,也可以结合在一起看。一般来说,错误可以理解为非主观的,异常可理解为主观的,但这样的理解因人而异,本文将错误和异常同等对待 ,同等处理。
从前端应用上来看,大致的错误和异常可以分为两个大类:
- 文件载入异常;
- 运行时异常;
文件载入异常又可能有很多的原因导致,比如:
- 网络异常;
- 文件路径异常;
- 文件解析异常;
运行时异常往往表示的是 JavaScript 脚本的异常(CSS 和 HTML 即便解析异常也不会报错),按照 JavaScript 的规范,对错误的定义,又可细分为如下错误:
- EvalError: eval 错误;
- RangeError: 范围错误;
- ReferenceError: 引用错误;
- TypeError: 类型错误;
- URIError: URI 错误;
- SyntaxError: 语法错;
- …
如果对 JavaScript 的运行时异常继续细分,可以分出更多细节,这里不再赘述。
错误追踪和现场恢复
谈到异常捕获,当然是指通过 JavaScript 来捕获。很容易理解,我们希望可以追踪前端项目中出现的错误,并且可以记录前端脚本的调用链,在错误发生的时候,可以快速恢复现场,以便我们可以快速定位错误的位置,所以这样的需求需要满足两个关键点:
- 错误的追踪;
- 恢复出错现场;
追踪错误也需要满足几个关键点:
- 函数名;
- 入参;
- 出参;
- 执行耗时;
而恢复现场则需要:
- 函数调用链;
也就是说,当错误发生的时候,我们可以知道应用中是通调用了哪些函数,通过输入输出什么样的参数导致了错误和异常,有了这些数据 ,我们就可以快速的恢复出错现场,来实现错误定位。
异常捕获
一般来说,通过 window.onerror
定义可以拿到应用抛出的大部分未被捕获的异常。
window.onerror = function(errorMsg, url, lineNumber, columnNumber, errorObj) {
console.log("errorMsg", errorMsg);
console.log("url", url);
console.log("lineNumber", lineNumber);
console.log("columnNumber", columnNumber);
console.log("errorObj", errorObj);
}
但 onerror 只是用来监听错误,并不会捕获错误,所以脚本的代码块会在出错的地方停止运行。
对于前端项目而言,我们大部分的功能都被拆解到一个函数上运行,所以 function 构成了前端应用的最小执行单元,下面介绍几种通过 JavaScript 进行函数错误捕获和追踪的方案,有侵入式的和非侵入式的,都可参考。
函数包裹器
可能最容易想到的就是在函数运行的时候包裹一层,这样可以比较方便的拿到函数的入参、出参和调用链,实现也相对简单。
function trace (fn) {
let _self = this;
return function () {
try {
// TODO:记录入参
let res = fn.apply(_self, arguments);
// TODO:记录出参 + 执行时长
return res;
}
catch (e) {
// 捕获异常
}
}
}
当我们需要调用某个函数时,可以借助 trace 函数来实现:
let sum = trace(function (a, b) {
return a + b;
});
当被包裹的函数抛出异常时,就可以通过 trace 来捕获。
另一种实现方式是通过修改 Function 的原型对象来实现,效果与定义一个 trace 函数基本类似。
Function.prototype.trace = function () {
let _self = this;
return function () {
try {
// TODO:记录入参
let res = _self.apply(this, arguments);
// TODO:记录出参 + 执行时长
return res;
}
catch (e) {
// 捕获异常
}
}
}
如果一个函数需要被追踪,通过 trace 方法即可:
function sum (a, b) {
return a + b
}
const mysum = sum.trace();
mysum(1, 2);
// 3
不过使用包裹器的这种方式存在明显的缺点:
- 书写非常繁琐,需要再每个需要被追踪的函数外增加包裹器;
- 完全侵入式,需要改动现有代码全部的函数调用部分;
- 不利于移植;
- 对实例对象不友好;
上面的方法只是起到了一个简单追踪的目的,但却非常松散,与现有项目无法解耦。
包装器模式
使用函数包裹器的思路,加上使用闭包,组成一个包装器设计模式。我们使用闭包的方式修改上述包裹器的方案。
首先定义一个 TraceStash 类,用于实现包装器和调用链的追踪。
function TraceStash () {
this.traceList = [];
// 调用链
this.callChain = [];
this.onError = function () { }
}
然后定义一个包装器函数,用于对输入的对象进行包装。因为我们默认了函数是前端应用的最小执行单元,所以我们最终的目的是对函数进行包装。除了函数本身以外,一个对象中可能也包含了方法,方法也是一个函数定义,所以我们需要接受函数和对象两种类型的输入参数,如果输入参数既不是函数也不是对象,就原样返回,不对其进行追踪。所以可以有以下定义:
/**
* 包装器
* @description 包装器用于将任意一个输入参数包装成一个支持 trace 的对象
* @param {any} obj 输入参数
*/
TraceStash.prototype.decorator = function (p, parent = "") {
if (utils.isFunction(p)) {
return this.decoratorFunction(p, parent);
}
if (utils.isObject(p)) {
return this.decoratorObject(p, parent);
}
return p;
}
由于我们需要追踪调用链,也就是对被包装的函数和对象的关系进行记录,所以这里 parent
参数用来对包装器设置初始名称。
下面需要定义 decoratorObject
和 decoratorFunction
两个函数了。
/**
* 对输入的函数进行包装
* @param {function} fn 输入函数,被包装函数
* @returns {function} 包装后的 fn 函数
*/
TraceStash.prototype.decoratorFunction = function (fn, parent = "") {
fn._name = fn._name || fn.name;
return this.activeTrace(fn, parent);
}
/**
* 对输入的对象进行深度包装
* @param {object} obj 被包裹的对象
* @param {string}} parentName 父节点名称
* @returns {object} 被包装后的 obj 对象
*/
TraceStash.prototype.decoratorObject = function (obj, parent = "") {
for (let key in obj) {
let rootName = obj.name || parent;
if (utils.isObject(obj[key])) {
obj[key] = this.decoratorObject(obj[key], rootName ? `{rootName}.{key}` : key);
continue;
}
if (utils.isFunction(obj[key])) {
obj[key]._name = key;
obj[key] = this.decoratorFunction(obj[key], parent);
continue;
}
}
return obj;
}
上面的代码很好理解,无论是对象还是函数,最终的被包装对象都是函数。在 decoratorFunction
方法中,我们需要通过 activeTrace
来激活对传入函数的追踪,下面定义 activeTrace
方法。
/**
* 对传入的函数开启追踪
* @param {fnction} fn 被追踪的函数
* @returns
*/
TraceStash.prototype.activeTrace = function (fn, parent = "") {
if (fn._trace) {
return fn;
}
let _path = parent ? ( fn._name ? (parent + "." + fn._name) : parent) : fn._name;
let fnTrace = new Trace(fn, _path);
this.traceList.push(fnTrace);
let ret = function () {
try {
// TODO:记录入参
let result = fnTrace.exec(this, arguments);
// TODO:记录出参 + 执行时长
return result;
}
catch (e) {
// 捕获异常
}
}
ret._name = fn._name;
ret._path = _path;
ret._trace = true;
return ret;
}
可以看到,在 activeTrace
方法中,我们对传入的函数进行了追踪实例化,是通过 new Trace()
来实现的,Trace
对象的设计是为了实现追踪其本身和调用链的解耦,由于篇幅的原因,Trace
对象的定义就不在这里展开了。
使用这个包装器也是比较简单的,这个包装器可以做到对现有代码只进行较小的改动,例如有如下一个对象:
let obj = {
data: {
a: 1,
b: 2
},
add: function () {
return this.data.a + this.data.b;
},
obj: function () {
return {
count: 0,
times: function () {
return ++this.count
}
}
},
number: {
random: function (max) {
if ((max - 0 | 0) !== (max - 0)) {
throw 'max number must be a integer';
}
return Math.ceil(Math.random() * max);
},
ary: function () {
return function () {
return function () {
return Array.from(arguments)
}
}
}
}
}
如果需要对这个对象添加追踪,则只需要的调用 decorator
进行包装即可,两行代码即可实现:
const trace = new TraceStash();
obj = trace(obj);
另外,如有有一个函数需要被追踪,也可以使用包装器进行包装:
function sum (a, b) {
if (typeof b === 'undefined') {
throw 'b is undefined';
}
return a + b;
}
// 追踪 sum 函数
const mysum = trace(sum);
虽然相比上一个包裹器方案有了很大的改进,不过这个包装器模式依然存在一些小问题:
- 半侵入式,仍然需要改动部分现有代码;
- 对内部函数无法追踪;
- 对返回值无法追踪;
- 对 Promise 无法追踪;
侵入式的这个问题暂时是无解的,这样的设计模式避免不了要改写现有的代码,对函数做侵入;内部函数因为外部无法访问,所以无法追踪,这是语言特性,目前也是无解的;Promise 的问题倒是有解决的办法,只是可能不够完美;对返回值的追踪是可以实现的,当一个函数执行后返回一个新的函数或者对象时,可以继续追踪返回的函数或者对象。
下面可以看包装器的改进版。
包装器模式改进
这个改进版主要为了解决两个问题:
- 增加对函数返回值的追踪;
- 增加对 Promise 的追踪或者捕获;
追踪函数返回值
追踪内嵌函数还是比较好理解的,看一个例子:
function ary () {
return function () {
return function () {
return Array.from(arguments)
}
}
}
如果要追踪上述函数,直接使用包装器进行包装,只能捕获到外层函数,但是这个函数的内部又返回了一个函数,当这个函数出现异常时,我们前面的包装器是捕获不到的,所以也无法记录这个函数的入参和出参信息。
同理,如果一个函数或者对象的内部在运行时返回了另一个函数或者对象,按照现在的方案我们依然是捕获不到的。
所以,为了解决上述问题,我们需要实现动态包装,也就是需要对被包装函数做一个检测,当被包装函数执行后,检测其返回值是否为函数或者对象,如果是函数或者对象,那么就对返回值再做一次包装。
修改 activeTrace
函数如下:
/**
* 对传入的函数开启追踪
* @param {fnction} fn 被追踪的函数
* @returns
*/
TraceStash.prototype.activeTrace = function (fn, parent = "") {
...
let _self = this;
let ret = function () {
try {
// TODO:记录入参
let result = fnTrace.exec(this, arguments);
// TODO:记录出参 + 执行时长
// 如果返回值是函数,则包装这个函数
if (utils.isFunction(result)) {
result._name = R;
result = _self.activeTrace(result, _path);
}
// 如果返回值是一个对象,则包装这个对象
if (utils.isObject(result)) {
result = _self.decoratorObject(result, _path + "." + R);
}
return result;
}
catch (e) {
// 捕获异常
}
return result;
}
...
return ret;
}
通过以上改进,当一个函数在运行时返回新的函数或者对象时,返回的函数和对象也可以被追踪到。
追踪 Promise 对象
对 Promise 的追踪是有一些难点的,主要问题在于我们无法获取到 Promise 每次 resolve 的那个函数,所以从追踪粒度上来说,可以实现对整个 Promise 的追踪,但是无法精确到对每个 resolve 的回调函数进行追踪。同理,我们也仅可以实现对整个 Promise 进行错误捕获。
对 Promise整体的追踪相对比较好实现,一般来说,一个 Promise 对象也是在函数中返回的,所以我们对函数返回值再做一个分支处理即可。
/**
* 对传入的函数开启追踪
* @param {fnction} fn 被追踪的函数
* @returns
*/
TraceStash.prototype.activeTrace = function (fn, parent = "") {
...
let _self = this;
let ret = function () {
try {
// TODO:记录入参
let result = fnTrace.exec(this, arguments);
// TODO:记录出参 + 执行时长
...
if (utils.isPromise(result)) {
result.then(function (res) {
// TODO:记录 Promise 出参 + 执行时长
})
.catch (function (error) {
// 捕获异常
})
}
return result;
}
catch (e) {
// 捕获异常
}
return result;
}
...
return ret;
}
但仅仅对 Promise 增加 then 和 catch 是不够的,如果 Promise 的异常不是通过 reject 抛出,我们依然无法捕获到,所以还需要增加 window.onerrror
回调,上文已经有说明,这里不做展开。
到目前为止,虽然还不是足够完美,但大部分自定义的函数和对象产生的错误我们都已经可以追踪。
对 Promise 的追踪是不完美的,例如我们人为加的 catch 捕获不一定会发生。
原生事件方法拦截
如果看到了这里,应该也会发现上述包装器存在的问题,那就是无法处理原生事件,举个例子:
window.addEventListener('load', function () {
throw "window loaded error"
});
上面的这个 window.onload 回调事件是无法被包装的,所以也就导致了无法追踪,但是前端页面中有许许多多的原生事件,这个庞大的群体如果无法被追踪,那么肯定会增加后续定位错误的难度。
对于这些原生的事件方法,可以拦截他们,并通过重新定义原生方法来实现追踪。
以 addEventListener
为例,我们需要通过调用这个方法来动态的为 DOM 元素增加事件监听器,通过重写 addEventListener
方法,就可以实现对回调函数的追踪。
重写 EventTarget.prototype.addEventListener 方法:
const ETP_addEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, listener, useCapture) {
let _self = this;
let fn = function () {
try {
// TODO:记录入参
let res = listener.apply(this, arguments);
// TODO:记录出参 + 执行时长
return res;
}
catch (e) {
// 捕获异常
}
}
fn._name = listener.name;
fn._trace = true;
ETP_addEventListener.call(_self, type, fn, useCapture);
}
假设有一个按钮需要添加单击事件,则:
document.getElementById('btn').addEventListener('click', function (e) {
console.log(e, 'btn clicked');
// others
})
可以看到并不需要对现有的代码做太大改动,直接拦截原生方法并重写即可。基于这个思路其实可以将 EventTarget
接口的方法全部重写,以便达到拦截的目的。
同样的,我们也可以重新 XMLHttpRequest
的相关接口,实现对 Ajax 的拦截,以便可以追踪异步请求出现的异常。
不过,以上重写原生方法的方式无法拦截这样的事件定义:
document.getElementById('btn').onclick = function (e) {
throw "error";
}
这种方式定义的事件回调无法被捕获,如果需要获取上述事件的错误,则需要借助 window.onerror
来处理。
ES6 修饰符
通过使用 ES6 的修饰符特性也能实现错误追踪,其原理依然是包装器模式,只不过 ES6 在语言层面上帮我们实现了包装器,而不需要我们在编程式手动包装。
不过修饰器特性在浏览器端的支持不是很好,有较大的兼容性问题,需要 Babel 转码,这里就不展开了。
Babel 全局侵入
借助 Babel 最函数的全局侵入应该是最简单粗暴的方案了。
这个方案的原理也比较好理解:Babel 将源码进行 AST 解析,然后遍历 AST 语法树,对 FunctionDeclaration
节点和 FunctionExpression
节点做注入,加入追踪代码。
这种方案的有如下优点:
- 全局注入,一步到位;
- 项目源码无需修改;
- 可以追踪内部函数;
- 可以实现对源码的调用栈记录,无需 SourceMap;
但是缺点也是明显的:
- 转码的结果是对源码的全局嵌入;
-
目标代码体积增大;
- 会与现有的代码会产生强耦合;
之前在网络上也有看到基于 Babel 做异常追踪注入的,不过个人感觉这种方法不够友好。这种方式相当于我们在写代码的时候对每一个函数调用都进行了 try catch 包裹,并记录调用参数,本质上还是侵入式的,而且是源码层面的侵入,只是借助了一个工具帮我们实现了自动化而已。
这里也不展开了,具体的实现是借助 AST 抽象语法树做节点遍历和注入来完成的,相当于开发了一个 Babel 的插件。
总结
上面是我最近学习和整理的一些方法,但是每一种方法单独来看都不够完美。最理想的情况是我们可以重写 Function 的构造函数,这种方式虽然可以实现,但却需要我们使用 new
关键字来创建函数,这其实是没有多少帮助的。后续我将基于上文的思路,结合每种方案的优点,尝试开发一个相对完美的错误追踪插件。