liangbus / blogging

Blog to go
10 stars 0 forks source link

关于前端异常监控 #21

Open liangbus opened 4 years ago

liangbus commented 4 years ago

前言

在公司有做过一些简单的异常监控,但是并没有很全面,自己调研整理了一下常用的方案,以备日后完善系统之用

为降低脚本执行的错误率,更快定位前端异常的情况,很多前端团队都有自己的前端监控系统,市面上也有一些颇受好评的第三方异常监控系统,如 sentry. 本文结合自己的一些见解和实践,围绕下面几个大的方向去讨论。

异常捕获/处理

前端的常见的异常主要分为两类,JS 脚本异常及网络相关异常

  1. Script 代码异常又分为很多种,常见的如下
  2. JS 语法错误(中文分号,缺少括号等)
  3. JS 运行时错误(变量未声明,Can't read property 'xxx' of undefined 等)
  4. Promise 抛出异常
  5. 外部脚本异常(如iframe,跨域脚本)
  6. 资源加载异常

常见异常示例

<script>
  error
  console.log('You wont see me.')
</script>
<script>
  console.log('You will see me!!!')
</script>

输出

Uncaught ReferenceError: error is not defined You will see me!!! 这里两个 script 标签属于两个不同的代码块,属于不同的宏任务,(JS 单线程的工作方式就是不停的去队列将任务取出来执行,具体细节参考 JS 的事件循环机制,本文不展开说明)因此可以看到第一个 log 被 JS 报错给阻断了,异常没有被捕获,但是这并不影响第二个任务块的执行

下面讨论下针对对以上异常的捕获方式

1. try-catch

最熟悉不过的就是 try-catch,还是用上面的例子

<script>
  try{
    error
  }
  catch(err) {
    console.log('We Caught Error -> ', err)
  }
  console.log('Check if you can see me')
</script>
<script>
  console.log('You will see me!!!')
</script>

输出

We Caught Error -> ReferenceError: error is not defined at error-script-demo.html:14 Check if you can see me You will see me!!! 这时异常被 catch 掉了,所以不会阻断当前任务的执行,两个 log 都正常输出

另外 try-catch 无法捕获 JS 语法错误,举个🌰

<script>
  try{
    function()
  }
  catch(err) {
    console.log('We Caught Error -> ', err)
  }
  console.log('Check if you can see me')
</script>
<script>
  console.log('You will see me!!!')
</script>

随便写了个错误的写法,来看输出

Uncaught SyntaxError: Function statements require a function name You will see me!!!

可以看到第一个任务中的异常没有被捕获到,从而后面的代码被阻断了 这种语法错误类型的异常,只要在项目中像使用类似 ESlint 这种代码检测工具,一般都能提前发现并避免,否则就需要好好检查自己项目的开发流程及测试流程了

同时 try-catch 对异步的异常也无法进行捕获,如下

<script>
  try{
    setTimeout(() => {
      error.log()
    }, 0)
  }
  catch(err) {
    console.log('We Caught Error -> ', err)
  }
  console.log('Check if you can see me')
</script>
<script>
  console.log('You will see me!!!')
</script>

输出

Check if you can see me You will see me!!! Uncaught ReferenceError: error is not defined at error-script-demo.html:11

try-catch 我们常用于 JS 运行时可预知可能会出现错误的地方捕获异常,通常是针对特定的业务场景,针对性比较强,且对代码的执行效率有所影响,因此在一个项目中,并不会大量使用。

window.onerror

window.onerror 是一个全局异常捕获事件,但能够捕获到未被捕获的脚本异常 注意,是未被捕获 window.onerror 可以捕获到语法错误(在 Chrome 浏览器测试,版本 80.0.3987.132(正式版本) 64 位,自己测试是可以的,但是在网上看到一些文章说不可以,具体自行测试吧),也可以捕获到异步产生的异常,同时在其回调函数上,有充足的异常信息,可以看出,它比 try-catch 功能要更强大一些

window.onerror = function(message, source, lineno, colno, error) { ... }
<script>
  window.onerror = function(message, source, lineno, colno, error) {
    console.info('Inside window.onerror', message, source)
    console.info('source', source)
    console.info('lineno, colno', lineno, colno, error)
    console.error('tag', error)
  }
</script>
<script>
  try{
    error.log()
  } catch(err) {
    console.error('Caught error', err)
  }
  function()
  console.log('Check if you can see me')
</script>
<script>
  setTimeout(() => {
    foo()
  }, 0)
  console.log('You will see me!!!')
</script>

输出 image

另外 window.onerror 无法捕获到 http 异常,比如图片加载失败,示例

<script>
  window.onerror = function(message, source, lineno, colno, error) {
    console.info('Inside window.onerror', message, source)
    console.info('source', source)
    console.info('lineno, colno', lineno, colno, error)
    console.error('tag', error)
  }
</script>
<body>
  <img src="http://www.abc.com/img/1234.jpg">
</body>

image

控制台只有一条异常输出,这是浏览器自己输出的,我们并没有捕获到

从上面可以看到,即使我们已经通过 window.onerror 方法捕获了异常,但是浏览器还是会输出自己的异常打印信息(浏览器默认的,并非我们手动打印的信息),此时我们可以通过 return true 来阻止其在控制台打印额外的信息,默认返回值是 false

GET http://www.abc.com/img/1234.jpg net::ERR_CONNECTION_RESET

更多见 MDN 上详细说明 GlobalEventHandlers.onerror

window.addEventListener

对于一些 http 请求相关的异常,我们可以通过 addEventListener 给 window 注册 error 事件

<script>
  window.onerror = function(err) {
    console.log('caught by window.onerror : ', err)
  }
  window.addEventListener('error', function(err) {
    console.log('Inside window.addEventListner -> ', err)
  }, true)
</script>
<body>
  <img src="http://abcd.b.com/img/abcd.jpg" alt="">
</body>

可以看到图片请求失败的异常被捕获到了 注意 addEventListener 的第三个参数,它的参数名为 useCapture,我把它设置为 true(默认为 false),因为 http 相关异常不会事件冒泡,因此必须在其捕获阶段将其捕获,但是 EventListener 捕获不了 http 的状态码,也就是 404, 500 这些都拿不到,还是得配合后端接口日志来进行排查

image

注意:

<script>
window.addEventListener('error', function(err) {
    console.log('Inside window.addEventListner -> ', err)
}, true)
var img = new Image()
img.src = 'https://a.vpimg2.com/upload/flow/2020/03/31/97/158564544872915.jpg'
</script>

image

因为 window.onerror 有详细的调用栈信息,更适合去捕获一些脚本方面的异常,而 addEventListener error 事件,更适合捕获一些网络加载相关的异常,因此可以加以区分

// 仅处理资源加载错误
window.addEventListener('error', (event) => {
  let target = event.target || event.srcElement;
  let isElementTarget = target instanceof HTMLScriptElement || target instanceof HTMLLinkElement || target instanceof HTMLImageElement;
  // console.log('isEl', isElementTarget);
  if (!isElementTarget) return false;
  const url = target.src || target.href;
  // 上报资源地址
  console.log('资源加载位置', event.path);
  console.error('静态资源错误捕获:','resource load exception:', url);
}, true);// 关于这里为什么不可以用 e.preventDefault() 来阻止默认打印,是因为这个错误,我们是捕获阶段获取到的,而不是冒泡;

Promise catch

<script>
  window.addEventListener('error', function(err) {
    console.log('Inside window.addEventListner -> ', err)
  }, true)
  window.onerror = function(message, source, lineno, colno, error) {
    console.info('Inside window.onerror', message, source)
    return true
  }
</script>
<script>
  Promise.reject('You are nothing!')
</script>

输出

Uncaught (in promise) You are nothing!

对于 Promise 抛出的异常,之前使用的 try-catch, window.onerror 和 addEventListener error 都无法捕获,只能够使用 Promise 自己的 catch 方法以及全局提供的 unhandledrejection 来捕获。

注意,当 Promise 的异常被自己 catch 掉的话,是不会触发 unhandledrejection 事件的,该事件如其名,只处理未被捕获的 Promise rejection,这也可以看成对 Promise 存在永远无法捕获的异常一个兜底处理方案吧。

window.addEventListener('unhandledrejection', function(err) {
    console.log('uuuuuuuuuuuuuuuuuuuuunhandleRejection -> ', err)
  }, true)

crossorigin

对于一些跨域的外部脚本,由于同源策略限制,如果其执行出现异常的话,会直接报 Script Error,没有其他任何信息。

解决思路无非两种

  1. 同源化策略,内联 js 或者使用相同域
  2. 跨源资源共享机制

方式 1 简单粗暴解决了问题,但是也带来更大的问题,无法利用好文件缓存和 CDN 的优势

接下来主要说说方式2 先来看看具体问题,这里我引用了一个外部脚本,这个脚本对其他库有依赖,我没有引就会报错

<script>
  window.addEventListener('error', function(err) {
    console.log('Inside window.addEventListner -> ', err)
  }, true)
  window.onerror = function(message, source, lineno, colno, error) {
    console.info('Inside window.onerror', message, source)
    console.info('source', source)
    console.info('lineno, colno', lineno, colno, error)
    console.error('error', error)
    return true
  }
</script>
<script src="https://shop.vipstatic.com/js/public/core3.1.0-hash-71efefef.js?2018010403"></script>

可以看到 window.onerror 和 addEventListener error 事件都只能拿到一个 Script Error 错误信息

image

跨域资源共享,可以通过给 script 加 crossorigin 属性,增加 crossorigin 属性后,浏览器将自动在请求头中添加一个 Origin 字段,发起一个 跨来源资源共享 请求。Origin 向服务端表明了请求来源,服务端将根据来源判断是否正常响应,若为合法请求,后端在响应头上设置 Access-Control-Allow-Origin,否则依然会报跨域异常

<script src="https://shop.vipstatic.com/js/public/core3.1.0-hash-71efefef.js?2018010403" crossorigin></script>

这时再强制刷新一下(注意是强制,因为有可能会读缓存从而继续报错)

image

这时可以看到,已经有完整的报错信息了。

image

iframe 异常

我们来看下用之前的异常捕获能否捕获到 iframe 中的异常

<script>
  window.addEventListener('error', function(err) {
    console.log('Inside window.addEventListner -> ', err)
  }, true)
  window.addEventListener('unhandledrejection', function(err) {
    console.log('uuuuuuuuuuuuuuuuuuuuunhandleRejection -> ', err)
  }, true)
  window.onerror = function(message, source, lineno, colno, error) {
    console.info('Inside window.onerror', message, source)
    console.info('source', source)
    console.info('lineno, colno', lineno, colno, error)
    console.error('error', error)
    return true
  }
</script>
<body>
  live-server-demo
  <iframe src="http://127.0.0.1:5501/index.html" frameborder="0" style="display: block;border: 1px solid #cfcfcf;"></iframe>
</body>

// http://127.0.0.1:5501/index.html
<body>
  <p>I am in an iframe</p>
  LIVE-SERVER-TEST
  <script>
    error
  </script>
</body>

可以看到,控制台有报错,但只有一条浏览器自己的报错信息,并没有触发我的事件声明

image

那如何监听到 iframe 里面的错误呢? 对于是同源的 iframe ,我们可以通过 window.frames 这个对象拿到页面上的 iframe 对象,它是一个类数组对象,我们可以这样:

<iframe src="./index.html" frameborder="0" style="width: 800px; height: 500px;display: block;border: 1px solid #cfcfcf;"></iframe>
  <script>
    window.frames[0].onerror = function(message, source, lineno, colno, error) {
      console.log('Caught inner iframe ERROR!')
      console.log({
        message, source, lineno, colno, error
      })
      return true
    }
  </script>

成功捕获到异常

image

但是对于非同源的,这种方法是行不通的,需要使用到一些额外的通信手段了,因为对 iframe 不是很熟悉,所以就不详情展开了,具体可以参考文章 跨域,你需要知道的全在这里

另外还有一些框架相关的异常捕获 api,像 react 的 Error Boundaries,这里不一一展开说明,自行查看官方文档即可。

异常上报

前面介绍了这么多异常的类型和捕获的方法,那前端拿到了异常信息之后,要怎么上报呢?常见的有两种方案

  1. ajax 上报数据
  2. 图片 src 属性上报

第1种就是封装个接口,像平时发送请求一样发送数据,但是鉴于 ajax 本身也可能会产生异常,所以大家还是第2种方式居多

方式2 利用的是 img 标签的 src 属性,它可以发起一个单向 get 请求,因为上报日志我们并不需要获取响应数据,所以单向即可满足要求(也许会节省带网络带宽什么的,但我没有求证),并且 src 还具备跨域能力,非常方便,业界很多大厂像淘宝,京东都是使用 img src 来发送请求

参考: 前端监控体系怎么搭建? 脚本错误量极致优化-监控上报与Script error