脚本错误模块主要是对 JS 报错进行监控,上报,聚合,展示。脚本错误监控系统从0到1需要经过以下步骤:首先,既然是 JS 错误,那我们需要知道什么是错误,怎样去定义一个错误,我们需要收集怎样的信息,知道了错误后,下一步就是错误的捕获,那捕获错误有什么方式,分别有什么特点,最后,我们又选取了什么方式。

错误类型

什么是 JavaScript 的错误,有什么类型,有什么属性,我们需要收集什么信息。

在 JS 中,定义了八种错误类型,在错误发生时抛出不同的错误对象。

TypeError - 类型错误

当值的类型为非预期类型时发生的错误。

  1. let num = 123456;
  2. num.toUpperCase();
  3. let obj = new 10;
  4. console.log('name' in true);

比如以上这段代码,num 是一个数字类型的变量,却调用了字符串的方法,10 不是构造函数,new 10 显然不符合类型预期,又或者 in true,都是类型错误操作导致的错误。

RangeError - 范围错误

当一个值不在其允许的范围或者集合中时发生的错误。

  1. const arr1 = new Array(99 ** 99);
  2. const arr2 = new Array(-20);
  3. function test() {
  4. test();
  5. }
  6. test();

这里定义了两个数组,长度为 99 ** 99-20,超出了数组最大的长度范围和小于0,都会抛出范围错误。

当递归调用函数并不退出,导致 JS 执行进入死循环,也会抛出 RangeError

值得注意的是,死循环导致 RangeError 这是在普通浏览器中的表现,在火狐浏览器中,将报错为 InternalError

InternalError - 内部错误

正如前面所说的,在火狐浏览器中,死循环会导致 InternalError,这是火狐自定义的一个错误,当 JS 引擎被太多的递归,太多的切换情况等淹没时,就会发生这种情况。

  1. switch(num) {
  2. case 1:
  3. ...
  4. break
  5. case 2:
  6. ...
  7. break
  8. case 3:
  9. ...
  10. break
  11. ... up to 1000 cases
  12. }

ReferenceError - 引用错误

当一个不存在的变量被引用时发生的错误。

  1. console.log(dog);
  2. let cat = dog;

假如代码中没有 dog 变量,却引用了,就会引发这个错误,注意,这里声明了,没定义,是不会报这个错误的。

SyntaxError - 语法错误 和 EvalError

新版的 JavaScript 使用 SyntaxError 替代 EvalError

这个错误相对少见,这是因为当我们的代码出现语法错误时,页面将会“挂掉”,所以我们自己一般是写不出来这样的错误的。

  1. let cat dog = '';

dog 会标红,页面无法正常展示。

URIError

对URI进行编码或解码有问题,则会引发URIError。

一般来说也就是使用了 decodeURIencodeURI 等处理函数,输入了错误的参数导致这个错误。

  1. decodeURI('%');
  2. 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 可以捕获同步执行的错误,比如:

  1. try {
  2. throw new Error('This is a test error');
  3. } catch(error) {
  4. console.warn(error);
  5. }

但是,try catch 无法捕获异步错误。比如我们在 try 代码块中写了一个 setTimeout 并抛出一个错误。

  1. try {
  2. console.log(1);
  3. setTimeout(() => {
  4. console.log(2);
  5. throw new Error('This is a test error 2');
  6. }, 1000);
  7. console.log(3);
  8. } catch(error) {
  9. console.warn(error);
  10. }
  11. console.log(4);

这里的打印顺序是 1、3、2、4,但是错误并没有被捕获到,这是因为 try catch 代码是同步代码,当执行到 setTimeout 时,try catch 已经执行完了,也就无法捕获了。

另外,也无法捕获 SyntaxError 语法错误

  1. try {
  2. let cat dog = '';
  3. } catch (e) {
  4. console.log(e);
  5. }

window.onerror

当发生错误时,window 会触发一个 ErrorEvent 接口的 error 事件,并执行 window.onerror()

  1. window.onerror = function(errorMessage, scriptURI, lineNo, columnNo, error) {
  2. console.log('errorMessage: ' + errorMessage); // 异常信息
  3. console.log('scriptURI: ' + scriptURI); // 异常文件路径
  4. console.log('lineNo: ' + lineNo); // 异常行号
  5. console.log('columnNo: ' + columnNo); // 异常列号
  6. console.log('error: ' + error); // 异常堆栈信息
  7. return true;
  8. };

onerror 虽然可以捕获错误,但是像静态资源异常,或者接口异常的错误都是无法捕获到的。他有一个特点,就是当我 return true; 的时候,异常不向上抛出、浏览器不会在控制台报错。并且,window.onerror 最好写在所有JS脚本的前面,否则有可能捕获不到错误。

虽然无法捕获资源等异常错误,但理论上,我们可以使用这个方法来做脚本错误监控,因为捕获到的信息是满足我们的需求的。不过,我们并不考虑,这是因为当这个方法在业务代码中重写的话,我们的错误监控脚本将无法生效。

window.addEventListener

相对于 window.onerror,该方法不仅可以捕获脚本错误,还可以捕获资源加载错误和网络请求异常。

  1. <img src="xxxxxx.jpg" alt="">
  2. window.addEventListener('error', (error) => {
  3. console.log('捕获到异常:', error);
  4. }, true)

上面这种资源加载异常可以被捕获到,但是网络请求异常不会向上冒泡,因此,想要捕获网络异常错误需要在捕获阶段才能捕获到。

Promise Catch

Promise 产生错误,但是没有被 catch 掉的话,就会产生一种 (in promise) 错误,这种错误无法被 try catchwindow.onerrorwindow.addEventListener('error') 捕获到。

  1. const promise = new Promise((resolve, reject) => {
  2. if (1 + 1 > 3) {
  3. resolve('success');
  4. } else {
  5. reject('error');
  6. }
  7. })
  8. try {
  9. promise
  10. .then((res) => console.log(res))
  11. // .catch((err) => console.warn(err))
  12. } catch (err) {
  13. console.log(err);
  14. }

像这种 promise 错误发生的时候会抛出一个 unhandledrejection 的事件,这时候,可以使用 window.addEventListener('unhandledrejection') 来进行捕获。

  1. window.addEventListener('unhandledrejection', (error) => {
  2. console.log('unhandledrejection-error', error);
  3. }, true)

这里的 error 会返回两个属性:

  • PromiseRejectionEvent.promise:被 rejectedJavaScript Promise
  • PromiseRejectionEvent.reason:一个值或 Object 表明为什么 promiserejected

当 reason 是 Object 时,里面包含 stack 属性表示堆栈信息

捕获方法总结

JS 的错误捕获方法有 4 种,try catch 虽然可以捕获,但是显然不适用于监控系统的实现,因为只能在业务代码中逐个实现。window.onerror 虽然可以捕获错误,在脚本错误模块也是可以满足需求,但是有可能被业务代码重写,导致不能全局捕获。因此,最后我们采用 window.addEventListener('error')window.addEventListener('unhandledrejection') 分别来捕获普通错误未catch的promise错误

定义错误

了解什么是错误,也捕获了错误,我们需要将错误信息展示在监控平台上,这时候就面临一个展示问题,也就是错误的定义,从什么维度来定义一个错误呢?

  1. 每次发生错误都会上报,如果每一个错误都当成一个错误来收集和展示,那么数量将会非常大,影响用户数和发生次数都是 1。
  2. 如果只用错误信息(xxx is not a function)来区分一个错误,但是同个错误信息有可能是由不同的代码引起的,所以要加入行号和列号。
  3. 不同的文件的同一行同一列还是有可能发生同样的错误,所以需要加上文件名。

因此最终我们的错误定义如下:

同个错误 = 行号 + 列号 + 文件名 + 错误信息

代码实现

前面说过,错误的基本属性是比较少的,只有 messagename 和非标准属性 stack,那我们对于错误的定义是需要行号和列号的,所以我们需要从错误事件中来获取这些信息。

  1. public jsErrorListener = (evt: Event): void => {
  2. try {
  3. const e = evt as ErrorEvent;
  4. const error = e.error || {};
  5. const stack = error.stack || '';
  6. const type = this.getErrorType(stack) || OTHER_ERROR;
  7. const jsErrorOpt = {
  8. msg: error.message || e.message,
  9. st: this.stackSlice(stack),
  10. fn: e.filename,
  11. ln: e.lineno,
  12. cn: e.colno,
  13. type,
  14. };
  15. this.report({
  16. dt: 'error',
  17. d: jsErrorOpt,
  18. });
  19. } catch (e) {
  20. console.log(e);
  21. }
  22. };
  23. public promiseErrorListener = (evt: Event): void => {
  24. try {
  25. const e = evt as PromiseRejectionEvent;
  26. const reason = e.reason || {};
  27. const stack = reason.stack;
  28. const type = this.getErrorType(stack) || OTHER_ERROR;
  29. const loc: ErrorLocation = this.getErrorLocation(stack) || {};
  30. const jsErrorOpt = {
  31. msg: this.getPromiseErrorMsg(e.reason),
  32. st: this.stackSlice(stack),
  33. fn: loc.filename,
  34. ln: loc.lineno,
  35. cn: loc.colno,
  36. type: 'unhandledrejection-' + type,
  37. };
  38. this.report({
  39. dt: 'error',
  40. d: jsErrorOpt,
  41. });
  42. } catch (e) {
  43. console.log(e);
  44. }
  45. };

此外,我们也不需要对所有的错误类型进行统计,比如 InternalError, 而对于自定义的错误类型,我们就使用一个 OtherError 来兜底。

Script Error.

有时候,我们的错误信息会展示一个 Script error.,但是我们并不知道这个 Script error. 表达的是什么意思,而造成这种现象的原因是 浏览器的同源策略

我们在线上的版本,经常做静态资源 CDN 化,这就会导致我们常访问的页面跟脚本文件来自不同的域名,这时候如果没有进行额外的配置,就会容易产生Script error.。这是为了避免信息泄露,语法错误的细节将不会报告,而是使用简单的 Script error. 代替。

解决这种问题通常是给 script 标签添加 crossOrigin 属性。

  1. <script src="xxxxxx.js" crossorigin></script>

避免错误

最后是一些个人认为可以尽量避免错误的方法。

TypeScript

使用 TypeScript,可以解决大部分问题,个人感觉可以解决90%的问题,但是前提条件是,不要把 Typescript 变成 AnyScript,那样失去了意义。以前想的是,后端返回的接口可能有很多东西,写个接口似乎不实际,但实际上,这是不难的,有条件的话,在前端可以对表单、列表必要的字段做一些限制,通常我根据接口的返回字段设置一个 Interface,花不了多少时间,却能避免错误,并且编译器还会做相应的提示。

try catch 兜底

try catch 的使用,前面说过,Promise 的错误如果没有被捕获到,那么可能会报相应的 unhandledrejection 错误,不仅如此,我们平常开发的时候,应该对有可能发生错误的地方做一个处理,有了 try catch 兜底,至少能避免可能发生的 “页面挂掉” 情况。

防御性编程

我们会说不要相信用户的输入,同时,我们也不要相信后端,我们不知道后端会返回什么东西给我们,比如列表,当空的时候,应该返回的是 [],而有时候,后端会返回一个 null,这时候尽管使用了 TS,但也可能导致页面错误。所以,我们也需要防御这种情况的发生,除了要防御接口问题之外,还要防御像其他可能发生的问题,像网络问题、渲染问题等。

CodeReview

这个环节往往不受重视,但其实很有必要,我们不能保证每个人的代码风格一致,但,我们至少要有一个基础的标准,哪些代码不应该这样写,或者说,改成那样会更好。比如说,我对后端的枚举值之前是会直接写成数字去做 严格等 的,这样短期上看不会有问题,但长期来看,埋藏了一个坑,下一个维护这段代码的人可能就会不知道表达什么。虽然不算错误,但是用一个文件保存枚举变量,取代这种 魔法数字 会更好。