【得物技术】聊聊前端错误和监控

1,429 阅读6分钟

为什么会有 BUG???

代码是人写的,不管你再怎么用心和小心总会出小 Bug,特别是在前端场景多变的场景下面,我们先来梳理一下会有哪些方面导致 Bug 发生。

代码编写问题

这种问题多半是因为没有思考全面或者一个手滑导致的问题,这种问题影响面也不高,一般在开发和测试阶段就能发现。这种问题我们通过细心和 Code Review 避免。

用户浏览器环境问题

对于前端来说我们会去兼容各种系统比如安卓、IOS 等,最头痛的还是安卓各种版本系统,用户系统版本可能是4.x、5.x ..还有国内各种魔改的内核,我们在测试中可能只能覆盖大部分手机和用户。

用户操作问题

作为开发来说我们经常一条主流程跑完发现没有问题,但是一个页面到了用户他可能有其他操作逻辑,往往他们这种操作会带来意想不到的 Bug,或者要满足多个条件才能触发的 Bug。

用户特定数据导致的问题

我们正常开发和测试阶段数据都是正常,往往用户的很多数据会有一定特殊性质。

我们为什么要做监控?

基于上述一些问题我们可以看出,出现 Bug 是在所难免。一旦出现问题可能会给公司带来巨大损失,那我们应该怎么去及时发现问题并解决呢?

下面我们围绕几个话题去聊聊:

  1. 常见错误分类
  2. 常见错误如何捕获
  3. 框架错误如何捕获和处理
  4. 如何进行上报

常见错误分类

JS 运行时错误

指代码在用户浏览器运行时候发生的错误,如语法错误、代码异常等,通过Error的构造器可以创建一个错误对象。当运行时错误产生时,Error的实例对象会被抛出。Error对象也可用于用户自定义的异常的基础对象。

下面列出了各种内建的标准错误类型。

code.png

Error 类型

  • SyntaxError 语法错误
  • TypeError 类型错误
  • RangeError 范围错误
  • ReferenceError 引用错误
  • EvalError eval错误
  • URIError 给 encodeURI()或 decodeURl()传递的参数无效

code (1).png

未 Catch 的 Promise 错误

Promise 是用于解决异步操作。

一个 Promise 会有几种状态:

  • 待定(pending):初始状态,既没有被兑现,也没有被拒绝。
  • 已兑现(fulfilled): 意味着操作成功完成。
  • 已拒绝(rejected): 意味着操作失败。

Promise 是用于解决 JS 回调地狱等问题。Promise 有个问题是内部抛出错误无法被 window 捕获。

来个例子验证一下:

code (1) (1).png

注意如果使用了 async/await 也会返回 Promise对象, 也无法被 window 捕获。

来个例子验证一下:

code (2).png

异步请求错误

异步错误主要分为2部分:

  • XMLHttpRequest
  • fetch api 市面上常用的 Axios Jq 都是基于 XMLHttpRequest 封装。

资源加载错误

资源包含 img sctipt link style video audio 等加载失败资源。

常见错误如何捕获

window.onerror (捕获 Js 运行时错误)

window.onerror 是一个全局变量,默认是 null,当 Js 运行触发错误时候会调用 window.onerror() 进行执行。

首先来看看我们代码怎么写:

code (3).png

有同学就来问了,为什么我们没有要错误发生的行号和列号,不要慌后面会讲。

下面说下 onerror 不是万能的,看看他使用时候需要哪些注意事项:

  1. window.onerror 可以被覆盖处理时候需要注意
  2. 应该在所以 script 标签前监听,不然无法监听错误
  3. 无法捕获异步和网络错误
  4. 默认无法获取不同域错误

出于前端性能优化的目的,我们经常把 Js 放到不同域上面,这是你会发现你什么信息也无法收集到,查阅文档后发现是浏览器安全限制,加载跨域脚本发生错误时候,避免信息泄露不会抛出详细错误报告,只抛出简单Script error.

如果想收集不同域 Js 错误需要给跨域 Js 文件 Header 添加 Access-Control-Allow-Origin:* 和 script 标签添加 crossorigin 属性。

window.addEventListener('error') (捕获 Js 运行时错误和资源加载错误)

window.addEventListener 功能和 window.onerror类似,差异是参数有所不同,强大的是可以捕获到资源加载异常比如我们img。

来测试一下一张图片404的情况:

code (4).png

WX20201217-172925.png

可以看到 window.addEventListener 可以捕获到图片加载失败了。

下面看看我们价值一个亿的错误捕获和资源捕获的代码我们应该怎么写:

<body>
<!-- 来一张打不开的图片 -->
<img src="http://www.xxx.png" alt="" srcset="" />
<script>
  window.addEventListener(
    "error",
    (e) => {
      // * message: 错误信息(字符串)
      // * source: 发生错误当脚本URL
      // * lineno: 发生错误当行号
      // * colno: 发生错误当列号
      // * error: Error对象
      // * target: Target 对象
      let { message, filename, lineno, colno, error, target } = e;
      if (target !== window) {
        //   各种资源错误
        _sendError({
          topic: "linkError",
          message: target.tagName,
          stack: target.src,
        });
      } else {
        //   和onerror同样的收集错误
        _sendError({
          topic: "error",
          message,
          stack: error.stack,
        });
      }
    },
    true
  );
</script>
</body>

unhandledrejection (未捕获 reject 的 Promise 错误)

当Promise 被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection 事件;这可能发生在 window 下,但也可能发生在 Worker 中。

  window.addEventListener("unhandledrejection", (event) => {
    _sendError({
      topic: "promise",
      // reject 返回错误
      message: event.reason,
      stack: event.reason.stack ? event.reason.stack : '' ,
    });
  });

XMLHttpRequest 和 fetch (接口请求)

对于请求我们需要改写他原生方法,在触发时候进行上报。市面上所有 Http 都是基于 XMLHttpRequest 和 fetch 进行封装的,所有不用特殊去处理如 axios 等库。

改写 XMLHttpRequest

// 获取系统 xhr
const oldXHR = window.XMLHttpRequest;
window.XMLHttpRequest = function() {
const realXHR = new oldXHR();
let _openMethods, _openUrl, _sendData;
const open = realXHR.open;
const send = realXHR.send;
// 自定义open 获取url 和方式
realXHR.open = function(...arg) {
  _openMethods = arg[0];
  _openUrl = arg[1];
  open.apply(realXHR, arg);
};
// 自定义send获取发送参数
realXHR.send = function(...arg) {
  _sendData = arg[0];
  send.apply(realXHR, arg);
};
// 需要监听的事件list
const eventList = [
  "abort",
  "error",
  "load",
  "loadstart",
  "progress",
  "readystatechange",
  "loadend",
  "timeout",
];
for (const item of eventList) {
  const event = `ajax${item.charAt(0).toUpperCase() + item.slice(1)}`;
  realXHR.addEventListener(
    item,
    function() {
      const ajaxEvent = new CustomEvent(event, {
        detail: Object.assign(realXHR, {
          _openMethods,
          _openUrl,
          _sendData,
        }),
      });
      window.dispatchEvent(ajaxEvent);
    },
    false
  );
}
return realXHR;
};

//   使用demo
window.addEventListener("ajaxReadystatechange", function(e) {
console.log(e.detail);
});

//   捕获错误进行上报
window.addEventListener("ajaxError", function(e) {
const { _openMethods, _openUrl, _sendData } = e.detail;
_sendError({
  topic: "ajaxError",
  methods: _openMethods,
  url: _openUrl,
  data: _sendData,
});
});
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://10.176.229.59:3000/test");
xhr.send(JSON.stringify({ a: "b" }));

改写 fetch。

<!--书写中-->

console.error

为什么我们需要去捕获 console.error 错误呢?

  1. 各种框架如果没有传入 errorHandler 默认通过 console.error 输出错误的。 VueErrorhandler

指定一个处理函数,来处理组件渲染方法执行期间以及侦听器抛出的未捕获错误。这个处理函数被调用时,可获取错误信息和应用实例。

ReactComponentDidCatch

AngularHandleError

The default implementation of ErrorHandler prints error messages to the console. To intercept error handling, write a custom exception handler that replaces this default as appropriate for your app.

  1. 一些常规错误程序员也喜欢默认通过 console.error输出。
const consoleError = window.console.error;
const self = this;
window.console.error = function() {
  const error = arguments[0];
  _sendError({
    topic: "consoleError",
    message:error.message || '',
    stack: error.stack ? error.stack : String(error),
  });

  consoleError.apply(window, arguments);
};

框架错误如何捕获和处理

上面有提到框架错误目前都可以通过 console.error 去捕获,那我们为什么需要单独讲解各个框架单独错误呢?下面我们通过 Vue 源码分析出使用 console.error 优缺点。

下面我们以 vue3 源码为例去看看错误如何抛出的:

// 为了说明白删除了部分不重要代码
export function handleError(
  err: unknown,
  instance: ComponentInternalInstance | null,
  type: ErrorTypes,
  throwInDev = true
) {
    // app-level handling
    const appErrorHandler = instance.appContext.config.errorHandler
    // 判断是否有传入错误函数 app.config.errorHandler
    // 如果有就直接回调处理
    if (appErrorHandler) {
      callWithErrorHandling(
        appErrorHandler,
        null,
        ErrorCodes.APP_ERROR_HANDLER,
        [err, exposedInstance, errorInfo]
      )
      return
    }
  // 输出错误
  logError(err, type, contextVNode, throwInDev)
}

/**
 * 执行错误处理
 * @param fn config 传入的错误处理函数
 * @param instance 组件实例
 * @param type 错误类型 LifecycleHooks | ErrorCodes
 * @param args
 */
export function callWithErrorHandling(
  fn: Function,
  instance: ComponentInternalInstance | null,
  type: ErrorTypes,
  args?: unknown[]
) {
  let res
  // 容错处理,如果传入其他执行错误的函数,不会导致应用错误
  try {
    res = args ? fn(...args) : fn()
  } catch (err) {
    handleError(err, instance, type)
  }
  return res
}

function logError(
  err: unknown,
  type: ErrorTypes,
  contextVNode: VNode | null,
  throwInDev = true
) {
  console.error(err)
}

首先框架判断我们是否有传入 errorHandler,如果未传入调用 logError 方法进行输出但是输出仅限于错误的堆栈信息,如果传入 errorHandler 将会拿到组件实例,生命周期等信息。通过 Vue 源码分析得出结论 console.error 可以不入侵代码捕获框架内部抛出错误但是错误信息不全面,通过侵入式可以获取更加全面的错误信息。具体哪种方式更好就看各种场景需求了。

Vue 错误捕获配置

首先我们查阅官方文档配置 Vue.config.errorHandler 传入一个 function 就能捕获错误了,但是上文有提到异步导致的错误其实是无法捕获的,下面我们改进一下写个插件:


const errorHandler = (error, vm, info) => {
   _sendError({
      topic: "vueErrorHandler",
      message: info,
      vm:String(vm),
      stack: error.stack ? error.stack : error ,
    });
  return Promise.reject(error)
}
let GlobalError = {
  install: Vue => {
    Vue.config.errorHandler = errorHandler
    Vue.mixin({
      beforeCreate() {
        // 获取当前vue methods 上所有方法
        const methods = this.$options.methods || {}
        Object.keys(methods).forEach(key => {
          let fn = methods[key]
          // 改写默认方法全部加上 catch
          this.$options.methods[key] = function(...args) {
            let ret = fn.apply(this, args)
            if (
              ret &&
              typeof ret.then === 'function' &&
              typeof ret.catch === 'function'
            ) {
              return ret.catch(errorHandler)
            } else {
              // 默认错误处理
              return ret
            }
          }
        })
      },
    })
    Vue.prototype.$throw = errorHandler
  },
}

Vue.use(GlobalError)

如何进行上报

我们通过各种信息收集后,需要把错误信息上报到错误收集平台。我们采用了通过 new Image() 方式进行上报,下面来看看我们为什么采用 new Image() 而不是 Ajax,首先来瞅瞅 使用 img 方式上报的优点:

  • 避免跨域(img 天然支持跨域);
  • 图片请求不占用 Ajax 请求限额;
  • 不会阻塞页面加载,影响用户的体验,只要new Image对象就好了,一般情况下也不需要append到DOM中,通过它的onerror和onload事件来检测发送状态。
const url = 'http://xxx.com/error?xxxx'
const img: HTMLImageElement = new Image();
img.onerror = () => {
  console.error('上报出了一丢丢问题!!')
};
img.src = url;

Nginx 配置

empty-gif是nginx的一个模块,用来返回1 x 1 px的空白图片,他的使用场景在于我们做数据打点前端上报时候通常会把用户端的参数放到一个图片请求上去。而使用nginx的这个模块要比我们自己放一张图在服务器上更高效,nginx把空白图片放到内存里,比从硬盘读取图片肯定速度更快,少了IO操作。

server {
  listen       80;
  server_name  xxx.com;
  root /export/www/error.com;

  location = /error {
    empty_gif;
  }
}

可以看到我们通过 nginx 后就返回了一张 1px 的 gif 图片,首先解释一下为什么要用1px gif 图片作为返回。 利用空白gif或1x1 px的img是互联网广告或网站监测方面常用的手段,简单、安全、相比PNG/JPG体积小,GIF的最低合法体积最小(最小的BMP文件需要74个字节,PNG需要67个字节,而合法的GIF,只需要43个字节)。

文|喝不醉再来

关注得物技术,携手走向技术的云端