脚本错误模块主要是对 JS 报错进行监控,上报,聚合,展示。脚本错误监控系统从0到1需要经过以下步骤:首先,既然是 JS 错误,那我们需要知道什么是错误,怎样去定义一个错误,我们需要收集怎样的信息,知道了错误后,下一步就是错误的捕获,那捕获错误有什么方式,分别有什么特点,最后,我们又选取了什么方式。
错误类型
什么是 JavaScript 的错误,有什么类型,有什么属性,我们需要收集什么信息。
在 JS 中,定义了八种错误类型,在错误发生时抛出不同的错误对象。
TypeError - 类型错误
当值的类型为非预期类型时发生的错误。
let num = 123456;
num.toUpperCase();
let obj = new 10;
console.log('name' in true);
比如以上这段代码,num
是一个数字类型的变量,却调用了字符串的方法,10 不是构造函数,new 10
显然不符合类型预期,又或者 in true
,都是类型错误操作导致的错误。
RangeError - 范围错误
当一个值不在其允许的范围或者集合中时发生的错误。
const arr1 = new Array(99 ** 99);
const arr2 = new Array(-20);
function test() {
test();
}
test();
这里定义了两个数组,长度为 99 ** 99
和 -20
,超出了数组最大的长度范围和小于0,都会抛出范围错误。
当递归调用函数并不退出,导致 JS
执行进入死循环,也会抛出 RangeError
。
值得注意的是,死循环导致 RangeError 这是在普通浏览器中的表现,在火狐浏览器中,将报错为 InternalError
InternalError - 内部错误
正如前面所说的,在火狐浏览器中,死循环会导致 InternalError
,这是火狐自定义的一个错误,当 JS 引擎被太多的递归,太多的切换情况等淹没时,就会发生这种情况。
switch(num) {
case 1:
...
break
case 2:
...
break
case 3:
...
break
... up to 1000 cases
}
ReferenceError - 引用错误
当一个不存在的变量被引用时发生的错误。
console.log(dog);
let cat = dog;
假如代码中没有 dog
变量,却引用了,就会引发这个错误,注意,这里声明了,没定义,是不会报这个错误的。
SyntaxError - 语法错误 和 EvalError
新版的 JavaScript 使用 SyntaxError 替代 EvalError
这个错误相对少见,这是因为当我们的代码出现语法错误时,页面将会“挂掉”,所以我们自己一般是写不出来这样的错误的。
let cat dog = '';
dog 会标红,页面无法正常展示。
URIError
对URI进行编码或解码有问题,则会引发URIError。
一般来说也就是使用了 decodeURI
、encodeURI
等处理函数,输入了错误的参数导致这个错误。
decodeURI('%');
encodeURI(123456);
Error
我们想要采集错误信息,往往要使用错误共同拥有的属性。Error
类型是以上几种错误类型的父类,也就是说,上述的错误类型都是继承自 Error
,说明他们也都拥有 Error
的属性。
Error.prototype.message
:错误信息Error.prototype.name
:错误名
上面是 Error
的两个属性,似乎信息量有点少,但实际上,Error 还有其他一些厂商特定扩展属性,也就是非标准属性了,一般来说,我们不能直接在生产环境使用。简单了解一下:
Microsoft
- Error.prototype.description:错误信息(message相似)
- Error.prototype.number:错误码
Mozilla
Error.prototype.fileName
:产生错误的文件名Error.prototype.lineNumber
:产生错误的行号Error.prototype.columnNumber
:产生错误的列号Error.prototype.stack
:错误堆栈——大部分都支持
这里的 stack 属性目前大部分浏览器都是支持的,虽说不是标准属性,但是在生产环境使用也是没问题的。
捕获方法
掌握了以上的知识,对错误有了一个大概的了解,知道了错误类型,知道了错误属性,接下来需要了解,如何来捕获这些错误了。
try catch
try catch
可以捕获同步执行的错误,比如:
try {
throw new Error('This is a test error');
} catch(error) {
console.warn(error);
}
但是,try catch
无法捕获异步错误。比如我们在 try 代码块中写了一个 setTimeout
并抛出一个错误。
try {
console.log(1);
setTimeout(() => {
console.log(2);
throw new Error('This is a test error 2');
}, 1000);
console.log(3);
} catch(error) {
console.warn(error);
}
console.log(4);
这里的打印顺序是 1、3、2、4
,但是错误并没有被捕获到,这是因为 try catch
代码是同步代码,当执行到 setTimeout
时,try catch
已经执行完了,也就无法捕获了。
另外,也无法捕获 SyntaxError
语法错误
try {
let cat dog = '';
} catch (e) {
console.log(e);
}
window.onerror
当发生错误时,window 会触发一个 ErrorEvent
接口的 error 事件,并执行 window.onerror()
window.onerror = function(errorMessage, scriptURI, lineNo, columnNo, error) {
console.log('errorMessage: ' + errorMessage); // 异常信息
console.log('scriptURI: ' + scriptURI); // 异常文件路径
console.log('lineNo: ' + lineNo); // 异常行号
console.log('columnNo: ' + columnNo); // 异常列号
console.log('error: ' + error); // 异常堆栈信息
return true;
};
onerror
虽然可以捕获错误,但是像静态资源异常,或者接口异常的错误都是无法捕获到的。他有一个特点,就是当我 return true;
的时候,异常不向上抛出、浏览器不会在控制台报错。并且,window.onerror
最好写在所有JS脚本的前面,否则有可能捕获不到错误。
虽然无法捕获资源等异常错误,但理论上,我们可以使用这个方法来做脚本错误监控,因为捕获到的信息是满足我们的需求的。不过,我们并不考虑,这是因为当这个方法在业务代码中重写的话,我们的错误监控脚本将无法生效。
window.addEventListener
相对于 window.onerror
,该方法不仅可以捕获脚本错误,还可以捕获资源加载错误和网络请求异常。
<img src="xxxxxx.jpg" alt="">
window.addEventListener('error', (error) => {
console.log('捕获到异常:', error);
}, true)
上面这种资源加载异常可以被捕获到,但是网络请求异常不会向上冒泡,因此,想要捕获网络异常错误需要在捕获阶段才能捕获到。
Promise Catch
当 Promise
产生错误,但是没有被 catch
掉的话,就会产生一种 (in promise)
错误,这种错误无法被 try catch
、window.onerror
和 window.addEventListener('error')
捕获到。
const promise = new Promise((resolve, reject) => {
if (1 + 1 > 3) {
resolve('success');
} else {
reject('error');
}
})
try {
promise
.then((res) => console.log(res))
// .catch((err) => console.warn(err))
} catch (err) {
console.log(err);
}
像这种 promise
错误发生的时候会抛出一个 unhandledrejection
的事件,这时候,可以使用 window.addEventListener('unhandledrejection')
来进行捕获。
window.addEventListener('unhandledrejection', (error) => {
console.log('unhandledrejection-error', error);
}, true)
这里的 error 会返回两个属性:
PromiseRejectionEvent.promise
:被rejected
的JavaScript Promise
。PromiseRejectionEvent.reason
:一个值或Object
表明为什么promise
被rejected
。
当 reason 是 Object 时,里面包含 stack 属性表示堆栈信息
捕获方法总结
JS 的错误捕获方法有 4 种,try catch
虽然可以捕获,但是显然不适用于监控系统的实现,因为只能在业务代码中逐个实现。window.onerror
虽然可以捕获错误,在脚本错误模块也是可以满足需求,但是有可能被业务代码重写,导致不能全局捕获。因此,最后我们采用 window.addEventListener('error')
和 window.addEventListener('unhandledrejection')
分别来捕获普通错误
和未catch的promise错误
。
定义错误
了解什么是错误,也捕获了错误,我们需要将错误信息展示在监控平台上,这时候就面临一个展示问题,也就是错误的定义,从什么维度来定义一个错误呢?
- 每次发生错误都会上报,如果每一个错误都当成一个错误来收集和展示,那么数量将会非常大,影响用户数和发生次数都是 1。
- 如果只用错误信息(xxx is not a function)来区分一个错误,但是同个错误信息有可能是由不同的代码引起的,所以要加入行号和列号。
- 不同的文件的同一行同一列还是有可能发生同样的错误,所以需要加上文件名。
因此最终我们的错误定义如下:
同个错误 = 行号 + 列号 + 文件名 + 错误信息
代码实现
前面说过,错误的基本属性是比较少的,只有 message
、name
和非标准属性 stack
,那我们对于错误的定义是需要行号和列号的,所以我们需要从错误事件
中来获取这些信息。
public jsErrorListener = (evt: Event): void => {
try {
const e = evt as ErrorEvent;
const error = e.error || {};
const stack = error.stack || '';
const type = this.getErrorType(stack) || OTHER_ERROR;
const jsErrorOpt = {
msg: error.message || e.message,
st: this.stackSlice(stack),
fn: e.filename,
ln: e.lineno,
cn: e.colno,
type,
};
this.report({
dt: 'error',
d: jsErrorOpt,
});
} catch (e) {
console.log(e);
}
};
public promiseErrorListener = (evt: Event): void => {
try {
const e = evt as PromiseRejectionEvent;
const reason = e.reason || {};
const stack = reason.stack;
const type = this.getErrorType(stack) || OTHER_ERROR;
const loc: ErrorLocation = this.getErrorLocation(stack) || {};
const jsErrorOpt = {
msg: this.getPromiseErrorMsg(e.reason),
st: this.stackSlice(stack),
fn: loc.filename,
ln: loc.lineno,
cn: loc.colno,
type: 'unhandledrejection-' + type,
};
this.report({
dt: 'error',
d: jsErrorOpt,
});
} catch (e) {
console.log(e);
}
};
此外,我们也不需要对所有的错误类型进行统计,比如 InternalError
, 而对于自定义的错误类型,我们就使用一个 OtherError
来兜底。
Script Error.
有时候,我们的错误信息会展示一个 Script error.
,但是我们并不知道这个 Script error.
表达的是什么意思,而造成这种现象的原因是 浏览器的同源策略
。
我们在线上的版本,经常做静态资源 CDN
化,这就会导致我们常访问的页面跟脚本文件来自不同的域名,这时候如果没有进行额外的配置,就会容易产生Script error.
。这是为了避免信息泄露,语法错误的细节将不会报告,而是使用简单的 Script error.
代替。
解决这种问题通常是给 script
标签添加 crossOrigin
属性。
<script src="xxxxxx.js" crossorigin></script>
避免错误
最后是一些个人认为可以尽量避免错误的方法。
TypeScript
使用 TypeScript
,可以解决大部分问题,个人感觉可以解决90%的问题,但是前提条件是,不要把 Typescript
变成 AnyScript,那样失去了意义。以前想的是,后端返回的接口可能有很多东西,写个接口似乎不实际,但实际上,这是不难的,有条件的话,在前端可以对表单、列表必要的字段做一些限制,通常我根据接口的返回字段设置一个 Interface
,花不了多少时间,却能避免错误,并且编译器还会做相应的提示。
try catch 兜底
try catch
的使用,前面说过,Promise 的错误如果没有被捕获到,那么可能会报相应的 unhandledrejection
错误,不仅如此,我们平常开发的时候,应该对有可能发生错误的地方做一个处理,有了 try catch
兜底,至少能避免可能发生的 “页面挂掉” 情况。
防御性编程
我们会说不要相信用户的输入,同时,我们也不要相信后端,我们不知道后端会返回什么东西给我们,比如列表,当空的时候,应该返回的是 []
,而有时候,后端会返回一个 null
,这时候尽管使用了 TS
,但也可能导致页面错误。所以,我们也需要防御这种情况的发生,除了要防御接口问题之外,还要防御像其他可能发生的问题,像网络问题、渲染问题等。
CodeReview
这个环节往往不受重视,但其实很有必要,我们不能保证每个人的代码风格一致,但,我们至少要有一个基础的标准,哪些代码不应该这样写,或者说,改成那样会更好。比如说,我对后端的枚举值之前是会直接写成数字去做 严格等
的,这样短期上看不会有问题,但长期来看,埋藏了一个坑,下一个维护这段代码的人可能就会不知道表达什么。虽然不算错误,但是用一个文件保存枚举变量,取代这种 魔法数字
会更好。