优雅统一处理 JS 错误(Sentry 上报场景)

一、背景

Sentry 可以有效帮助我们发现项目中存在的问题。经过一段时间的使用和了解,我们对其中的错误进行了汇总。

Untitled

Untitled

Untitled

我们发现有大量的错误是由于 HTTP 请求 400 或者 401 导致。

二、错误分析

现有的 MSP 请求封装了统一的 request 请求,对 request.repsonse 进行了 interceptor 操作。即:进行了 response 的统一处理,由自己处理完后再返回给业务层。

之前我们在 interceptors.response 中对部分业务码进行了统一处理,如:401 未授权跳转到登录页。

之前为了忽略部分状态码,直接进行了 return 操作,此操作后,业务层会进入 Promise.then 环节。由于在 http status 400 或 401 情况下,API 返回的数据格式异常,所以导致了 Cannot read property of null 的错误。

request-helper.js 的 return 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (statusCode === 401) { // 未授权的登录,这种情况需要重定向到登录页面
if (window.location.pathname === '/login') return

Modal.confirm({
title: '提示',
content: '您当前处于未登录状态,部分功能无法使用',
okText: '去登录',
cancelText: '停留在此页',
onOk: () => {
store.dispatch('user/resetToken').then(() => {
window.location.reload()
})
},
onCancel: () => {
}
})

return
}

user.js 读取

1
2
3
4
5
6
7
8
9
10
11
refreshToken({ commit }) {
return new Promise((resolve, reject) => {
refreshToken().then(response => {
const { data } = response // 拿不到有效的数据,所以 data 为 null
commit('SET_TOKEN', data)
resolve()
}).catch(error => {
reject(error)
})
})
}

三、处理分析

关于部分请求的错误,如 401、403我们需要进行放行。理由如下

  • 401,用户没有授权,属于正常业务逻辑,不需要上报
  • 400,参数认证失败,属于正常的业务逻辑,不需要上报
  • 500,待定,此类问题一般由于内部错误导致(还有发版时会遇到),可以先正常上报。

所以我们看到,需要对 reponse 进行统一的兜底处理。但是我们发起方是在业务代码里(vue文件),所以是否存在 业务层的统一处理?

答案是:存在的。借助 window.onunhandledrejection

统一处理异常 main.js

1
2
3
4
window.onunhandledrejection = function(error) {
console.log(`Promise failed: ${error.reason}`)
error.preventDefault() // 抑制终端报错
}

业务 user.js

1
2
3
4
5
6
7
8
return new Promise((resolve, reject) => {
getInfo().then(res => {
commit('SET_USERNAME', username)
resolve(username)
}).catch(e => {
console.log('业务自行处理异常')
reject('我抛给了统一处理')
})

终端输出

Untitled

猜想:sentry 或者 vue,会不会使用这种方式统一捕捉错误。如果大家都用了,这个错误就不能有效传导了。所以我们需要保留其他已注册函数的处理句柄。

1
2
3
4
5
6
7
8
9
10
11
12
13
const _oldHandler = window.onunhandledrejection
window.onunhandledrejection = function(error) {
_oldHandler.apply(this, arguments)
console.log(`Promise failed1: ${error.reason}`)
error.preventDefault()
}

const _oldHandler2 = window.onunhandledrejection
window.onunhandledrejection = function(error) {
_oldHandler2.apply(this, arguments)
console.log(`Promise failed2: ${error.reason}`)
error.preventDefault()
}

通过保留上次处理函数句柄的方式,可以有效传导

Untitled

四、实现

综上,我们设定一个全局的错误处理函数来进行错误捕捉,而不用逐个去修改源码。处理方式如下

  • 1)request 层正常 reject 所有错误(即:正常向上 throw error)
  • 2)在 main.js 中注册全局的错误处理函数

4.1 正常 reject error

之前由于其他地方零零散散的会上报错误,部分被注释了,现在统一打开,正常在 Promoise.reject

4.2 注册全局处理函数

main.js 注册全局处理函数

1
2
3
4
5
import { globalErrorHandler } from './error-handler'
initSentry(Vue, router)

// 自己的全局处理函数一定要在 sentry 后面,这样可以拦截到他的错误上报函数,进而进行 hook
globalErrorHandler()

error-handler.js 处理错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 定义需要忽略上报的 http status code
const SkipHttpStatus = [400, 401]

// 处理全局错误
export function globalErrorHandler() {
const originalErrorHandler = window.onunhandledrejection

window.onunhandledrejection = function(error) {
const request = error.reason.request
if (request) { // 捕捉请求类错误,统一处理状态码
// http status code
const status = error.reason.request.status
if (SkipHttpStatus.includes(status)) {
console.log('Ignore request error, cause the http status is [%s]', status)
error.preventDefault() // 取消控制台的报错
return
}
}

// 其他情况,正常上报错误即可,这里主要就是 sentry 注册的处理函数
originalErrorHandler.apply(this, arguments) // 执行之前注册的错误处理函数
}
}

这里需要注意的是,需要先拦截到 sentry 的钩子,然后进行对应的情况处理。

当满足我们忽略条件时,不再进行上报。其他情况,正常上报

五、总结

  1. 不要干扰正常的异常抛出流程,该抛出异常时,大胆抛出
  2. 使用统一处理函数 window.onunhandledrejection 进行统一错误捕获。基本上目前主流的语言(或框架)都会提供统一的错误处理机制,所以首先想到
    的一定是“统一”处理,而不是每个业务代码都去改动。

  3. 注意使用 window.onunhandledrejection 时,不要干扰已经注册的处理函数,所以可以先“暂存”,之后再“恢复”。这个和 PHP 内核拓展开发时,hook
    内置的函数有异曲同工之妙。

    • PHP 内核示例
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      // 通过在 Module Init 环节拦截函数入口,进而 Hook,进行一系列自定义处理
      PHP_MINIT_FUNCTION(skywalking)
      {
      ZEND_INIT_MODULE_GLOBALS(skywalking, php_skywalking_init_globals, NULL);
      //data_register_hashtable();
      REGISTER_INI_ENTRIES();
      /* If you have INI entries, uncomment these lines
      */
      if (SKYWALKING_G(enable))
      {
      if (strcasecmp("cli", sapi_module.name) == 0 && cli_debug == 0)
      {
      return SUCCESS;
      }

      // 用户自定义函数执行器(php脚本定义的类、函数)
      ori_execute_ex = zend_execute_ex;
      zend_execute_ex = sky_execute_ex;

      // 内部函数执行器(c语言定义的类、函数)
      ori_execute_internal = zend_execute_internal;
      zend_execute_internal = sky_execute_internal;