经过了大量测试及联调的项目在有些时候还是会有十分隐蔽的bug存在,这种复杂而又不可预见性的问题唯有通过完善的监控机制才能有效的减少其带来的损失,因此对于直面用户的前端而言,异常捕获与上报是至关重要的

一、前端监控系统

市面上非常完善的前端监控系统:

做前端监控大体从以下几个方面考虑:
前端异常捕获与上报 - 图1

二、try catch

  1. try {
  2. var a = 1;
  3. var b = a + c;
  4. } catch (e) {
  5. // 捕获处理
  6. console.log(e); // ReferenceError: c is not defined
  7. }

如 async_await 等异步代码可通过 try_catch 来捕获异常,这种方式大部分情况可行,不至于让页面挂掉,缺点是需要在捕获异常的代码上进行包裹,会导致页面臃肿不堪,不适用于整个项目的异常捕获。

三、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. };
  8. console.log(a);

前端异常捕获与上报 - 图2

window.onerror 可以拦截到大部分的详细报错信息,还提供了错误行列号,可以精准的进行定位,但是无法处理跨域代码、MVVM 代码 及 Promise 异常

四、MVVM 中的错误处理

在MVVM框架中使用 window.onerror 是捕获不到异常的,因为异常信息被框架自身的异常机制捕获了。

1、Vue.config.errorHandler

比如Vue 2.x中我们应该这样捕获全局异常

  1. Vue.config.errorHandler = function (err, vm, info) {
  2. let {
  3. message, // 异常信息
  4. name, // 异常名称
  5. script, // 异常脚本url
  6. line, // 异常行号
  7. column, // 异常列号
  8. stack // 异常堆栈信息
  9. } = err;
  10. // vm为抛出异常的 Vue 实例
  11. // info为 Vue 特定的错误信息,比如错误所在的生命周期钩子
  12. }

2、React 16+ 的 ErrorBoundary

react也提供了异常处理的方式,在 React 16.x 版本中引入了 Error Boundary

  1. class ErrorBoundary extends React.Component {
  2. constructor(props) {
  3. super(props);
  4. this.state = { hasError: false };
  5. }
  6. componentDidCatch(error, info) {
  7. this.setState({ hasError: true });
  8. // 将异常信息上报给服务器
  9. logErrorToMyService(error, info);
  10. }
  11. render() {
  12. if (this.state.hasError) {
  13. return '出错了';
  14. }
  15. return this.props.children;
  16. }
  17. }

使用组件:

  1. <ErrorBoundary>
  2. <MyWidget />
  3. </ErrorBoundary>

五、跨域 之 Script error

跨域之后 window.onerror 是无法捕获异常信息的,所以统一返回 Script error. ,解决方案便是 script 属性配置 crossorigin=“anonymous” 并且服务器添加Access-Control-Allow-Origin。一般的CDN网站都会将Access-Control-Allow-Origin配置为*,即所有域都可以访问。

  1. <!-- http://localhost:3031/ -->
  2. <script>
  3. window.onerror = function() {
  4. console.log(arguments);
  5. };
  6. </script>
  7. <script src="http://cdn.xxx.com/index.js"></script>

前端异常捕获与上报 - 图3

解决方案:

  1. <script src="http://cdn.xxx.com/index.js" crossorigin="anonymous"></script>

六、Promise

使用 Promise.catch() 函数

七、SourceMap

生产环境一般会将代码压缩一下再发布,这时候便出现了压缩后的代码无法找到原始报错位置的问题。

如图,我们用 webpack 将代码打包压缩成 bundle.js :

  1. // webpack.config.js
  2. var path = require('path');
  3. // webpack 4.1.1
  4. module.exports = {
  5. mode: 'development',
  6. entry: './client/index.js',
  7. output: {
  8. filename: 'bundle.js',
  9. path: path.resolve(__dirname, 'client')
  10. }
  11. }

最后页面引入的脚本文件是这样的:

  1. !function(e){var o={};function n(r){if(o[r])return o[r].exports;var t=o[r]={i:r,l:!1,exports:{}}...;

异常信息:lineNo可能是一个非常小的数字,一般是1,而columnNo会是一个很大的数字,这里是730,因为所有代码都压缩到了一行。
前端异常捕获与上报 - 图4

在webpack中开启source-map功能:

  1. module.exports = {
  2. ...
  3. devtool: '#source-map',
  4. ...
  5. }

打包压缩的文件末尾会带上 source-map 的注释:该文件对应的map文件为bundle.js.map

  1. !function(e){var o={};function n(r){if(o[r])return o[r].exports;var t=o[r]={i:r,l:!1,exports:{}}...;
  2. //# sourceMappingURL=bundle.js.map

source-map 文件 是一个 JSON 对象:

  1. version: 3, // Source map的版本
  2. sources: ["webpack:///webpack/bootstrap", ...], // 转换前的文件
  3. names: ["installedModules", "__webpack_require__", ...], // 转换前的所有变量名和属性名
  4. mappings: "aACA,IAAAA,KAGA,SAAAC...", // 记录位置信息的字符串
  5. file: "bundle.js", // 转换后的文件名
  6. sourcesContent: ["// The module cache var installedModules = {};..."], // 源代码
  7. sourceRoot: "" // 转换前的文件所在的目录

八、栈递归

对于某些浏览器可能不会显示调用栈信息,可以通过 arguments.callee.caller 来做栈递归

九、错误上报

1、未压缩文件上报

在脚本代码没有被压缩的情况下可以直接捕获后上传对应的异常信息,通常可以通过 img 标签的 src 发起一个请求。

2、压缩文件上报

提交异常

将异常信息传递给接口:

  1. window.onerror = function(errorMessage, scriptURI, lineNo, columnNo, error) {
  2. // 构建错误对象
  3. var errorObj = {
  4. errorMessage: errorMessage || null,
  5. scriptURI: scriptURI || null,
  6. lineNo: lineNo || null,
  7. columnNo: columnNo || null,
  8. stack: error && error.stack ? error.stack : null
  9. };
  10. if (XMLHttpRequest) {
  11. var xhr = new XMLHttpRequest();
  12. xhr.open('post', '/middleware/errorMsg', true); // 上报给node中间层处理
  13. xhr.setRequestHeader('Content-Type', 'application/json'); // 设置请求头
  14. xhr.send(JSON.stringify(errorObj)); // 发送参数
  15. }
  16. }

sourceMap 解析

附:source-map API

前端浏览器解析

前端浏览器可以对map文件进行解析,但因为前端解析速度较慢,所以这里不做推荐

服务器解析

通过将异常信息提交到node 中间层,然后解析map文件后将数据传递给后台服务器:

  1. const express = require('express');
  2. const fs = require('fs');
  3. const router = express.Router();
  4. const fetch = require('node-fetch');
  5. const sourceMap = require('source-map');
  6. const path = require('path');
  7. const resolve = file => path.resolve(__dirname, file);
  8. // 定义post接口
  9. router.post('/errorMsg/', function(req, res) {
  10. let error = req.body; // 获取前端传过来的报错对象
  11. let url = error.scriptURI; // 压缩文件路径
  12. if (url) {
  13. let fileUrl = url.slice(url.indexOf('client/')) + '.map'; // map文件路径
  14. // 解析sourceMap
  15. let smc = new sourceMap.SourceMapConsumer(fs.readFileSync(resolve('../' + fileUrl), 'utf8')); // 返回一个promise对象
  16. smc.then(function(result) {
  17. // 解析原始报错数据
  18. let ret = result.originalPositionFor({
  19. line: error.lineNo, // 压缩后的行号
  20. column: error.columnNo // 压缩后的列号
  21. });
  22. let url = ''; // 上报地址
  23. // 将异常上报至后台
  24. fetch(url, {
  25. method: 'POST',
  26. headers: {
  27. 'Content-Type': 'application/json'
  28. },
  29. body: JSON.stringify({
  30. errorMessage: error.errorMessage, // 报错信息
  31. source: ret.source, // 报错文件路径
  32. line: ret.line, // 报错文件行号
  33. column: ret.column, // 报错文件列号
  34. stack: error.stack // 报错堆栈
  35. })
  36. }).then(function(response) {
  37. return response.json();
  38. }).then(function(json) {
  39. res.json(json);
  40. });
  41. })
  42. }
  43. });
  44. module.exports = router;

通过前端传过来的异常文件路径获取服务器端 map 文件地址,
然后将压缩后的行列号传递给 sourceMap 返回的 promise 对象进行解析,
通过 originalPositionFor 方法就能获取到原始的报错行列号和文件地址,最后通过 ajax 将需要的异常信息统一传递给后台存储,完成异常上报。
下图可以看到控制台打印出了经过解析后的真实报错位置和文件:
前端异常捕获与上报 - 图5

注意点

若你的应用访问量很大,那么一个小异常都可能会把你的服务器搞挂,所以上报的时候可以进行信息过滤和采样等。

设置一个调控开关,服务器也可以对相似的异常进行过滤,在一个时间段内不进行多次存储。