4.2 ErrorBoundary标签
错误边界仅可以捕获其子组件的错误。错误边界无法捕获其自身的错误。如果一个错误边界无法渲染错误信息,则错误会向上冒泡至最接近的错误边界。这也类似于 JavaScript 中 catch {} 的工作机制。
创建ErrorBoundary组件
import React from 'react'; export default class ErrorBoundary extends React.Component { constructor(props) { super(props); } componentDidCatch(error, info) { // 发生异常时打印错误 console.log('componentDidCatch',error) } render() { return this.props.children; } }
在src/index.js中包裹App标签
import ErrorBoundary from './ErrorBoundary' ReactDOM.render( <ErrorBoundary> <App /> </ErrorBoundary> , document.getElementById('root'));
最终运行的结果
5. 跨域代码异常
(待...)
6. IFrame异常
(待...)
上一篇我们主要谈到的JS错误如何收集。这篇我们说说异常如何上报和分析。
三、异常上报
1. 选择通讯方式
1.1 动态创建img标签
其实上报就是要将捕获的异常信息发送到后端。最常用的方式首推动态创建标签方式。因为这种方式无需加载任何通讯库,而且页面是无需刷新的。基本上目前包括百度统计 Google统计都是基于这个原理做的埋点。
new Image().src = 'http://localhost:7001/monitor/error'+ '?info=xxxxxx'
通过动态创建一个img,浏览器就会向服务器发送get请求。可以把你需要上报的错误数据放在querystring字符串中,利用这种方式就可以将错误上报到服务器了。
1.2 Ajax上报
实际上我们也可以用ajax的方式上报错误,这和我们再业务程序中并没有什么区别。在这里就不赘述。
2.1 上报哪些数据
我们先看一下error事件参数:
属性名称 | 含义 | 类型 |
message | 错误信息 | string |
filename | 异常的资源url | string |
lineno | 异常行号 | int |
colno | 异常列号 | int |
error | 错误对象 | object |
error.message | 错误信息 | string |
error.stack | 错误信息 | string |
其中核心的应该是错误栈,其实我们定位错误最主要的就是错误栈。
错误堆栈中包含了绝大多数调试有关的信息。其中包括了异常位置(行号,列号),异常信息
有兴趣的同学可以看看这篇文章
2.2 上报数据序列化
由于通讯的时候只能以字符串方式传输,我们需要将对象进行序列化处理。
大概分成以下三步:
- 将异常数据从属性中解构出来存入一个JSON对象
- 将JSON对象转换为字符串
- 将字符串转换为Base64
当然在后端也要做对应的反向操作 这个我们后面再说。
window.addEventListener('error', args => { console.log( 'error event:', args ); uploadError(args) return true; }, true); function uploadError({ lineno, colno, error: { stack }, timeStamp, message, filename }) { // 过滤 const info = { lineno, colno, stack, timeStamp, message, filename } // const str = new Buffer(JSON.stringify(info)).toString("base64"); const str = window.btoa(JSON.stringify(info)) const host = 'http://localhost:7001/monitor/error' new Image().src = `${host}?info=${str}` }
四、异常收集
异常上报的数据一定是要有一个后端服务接收才可以。
我们就以比较流行的开源框架eggjs为例来演示
1. 搭建eggjs工程
# 全局安装egg-cli npm i egg-init -g # 创建后端项目 egg-init backend --type=simple cd backend npm i # 启动项目 npm run dev
2. 编写error上传接口
首先在app/router.js添加一个新的路由
module.exports = app => { const { router, controller } = app; router.get('/', controller.home.index); // 创建一个新的路由 router.get('/monitor/error', controller.monitor.index); };
创建一个新的controller (app/controller/monitor)
'use strict'; const Controller = require('egg').Controller; const { getOriginSource } = require('../utils/sourcemap') const fs = require('fs') const path = require('path') class MonitorController extends Controller { async index() { const { ctx } = this; const { info } = ctx.query const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8')) console.log('fronterror:', json) ctx.body = ''; } } module.exports = MonitorController;
看一下接收后的结果
3. 记入日志文件
下一步就是讲错误记入日志。实现的方法可以自己用fs写,也可以借助log4js这样成熟的日志库。
当然在eggjs中是支持我们定制日志那么我么你就用这个功能定制一个前端错误日志好了。
在/config/config.default.js中增加一个定制日志配置
// 定义前端错误日志 config.customLogger = { frontendLogger : { file: path.join(appInfo.root, 'logs/frontend.log') } }
在/app/controller/monitor.js中添加日志记录
async index() { const { ctx } = this; const { info } = ctx.query const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8')) console.log('fronterror:', json) // 记入错误日志 this.ctx.getLogger('frontendLogger').error(json) ctx.body = ''; }
最后实现的效果
五、异常分析
谈到异常分析最重要的工作其实是将webpack混淆压缩的代码还原。
1. Webpack插件实现SourceMap上传
在webpack的打包时会产生sourcemap文件,这个文件需要上传到异常监控服务器。这个功能我们试用webpack插件完成。
1.1 创建webpack插件
/source-map/plugin
const fs = require('fs') var http = require('http'); class UploadSourceMapWebpackPlugin { constructor(options) { this.options = options } apply(compiler) { // 打包结束后执行 compiler.hooks.done.tap("upload-sourcemap-plugin", status => { console.log('webpack runing') }); } } module.exports = UploadSourceMapWebpackPlugin;
1.2 加载webpack插件
webpack.config.js
// 自动上传Map UploadSourceMapWebpackPlugin = require('./plugin/uploadSourceMapWebPackPlugin') plugins: [ // 添加自动上传插件 new UploadSourceMapWebpackPlugin({ uploadUrl:'http://localhost:7001/monitor/sourcemap', apiKey: 'kaikeba' }) ],
1.3 添加读取sourcemap读取逻辑
在apply函数中增加读取sourcemap文件的逻辑
/plugin/uploadSourceMapWebPlugin.js
const glob = require('glob') const path = require('path') apply(compiler) { console.log('UploadSourceMapWebPackPlugin apply') // 定义在打包后执行 compiler.hooks.done.tap('upload-sourecemap-plugin', async status => { // 读取sourcemap文件 const list = glob.sync(path.join(status.compilation.outputOptions.path, `./**/*.{js.map,}`)) for (let filename of list) { await this.upload(this.options.uploadUrl, filename) } }) }
1.4 实现http上传功能
upload(url, file) { return new Promise(resolve => { console.log('uploadMap:', file) const req = http.request( `${url}?name=${path.basename(file)}`, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', Connection: "keep-alive", "Transfer-Encoding": "chunked" } } ) fs.createReadStream(file) .on("data", chunk => { req.write(chunk); }) .on("end", () => { req.end(); resolve() }); }) }
1.5服务器端添加上传接口
/backend/app/router.js
module.exports = app => { const { router, controller } = app; router.get('/', controller.home.index); router.get('/monitor/error', controller.monitor.index); // 添加上传路由 router.post('/monitor/sourcemap',controller.monitor.upload) };
添加sourcemap上传接口
/backend/app/controller/monitor.js
async upload() { const { ctx } = this const stream = ctx.req const filename = ctx.query.name const dir = path.join(this.config.baseDir, 'uploads') // 判断upload目录是否存在 if (!fs.existsSync(dir)) { fs.mkdirSync(dir) } const target = path.join(dir, filename) const writeStream = fs.createWriteStream(target) stream.pipe(writeStream) }
最终效果:
执行webpack打包时调用插件sourcemap被上传至服务器。
2. 解析ErrorStack
考虑到这个功能需要较多逻辑,我们准备把他开发成一个独立的函数并且用Jest来做单元测试
先看一下我们的需求
输入 | stack(错误栈) | ReferenceError: xxx is not defined\n' + ' at http://localhost:7001/public/bundle.e7877aa7bc4f04f5c33b.js:1:1392' |
SourceMap | 略 | |
输出 | 源码错误栈 | { source: 'webpack:///src/index.js', line: 24, column: 4, name: 'xxx' } |
搭建Jest框架
首先创建一个/utils/stackparser.js文件
module.exports = class StackPaser { constructor(sourceMapDir) { this.consumers = {} this.sourceMapDir = sourceMapDir } }
在同级目录下创建测试文件stackparser.spec.js
以上需求我们用Jest表示就是
const StackParser = require('../stackparser') const { resolve } = require('path') const error = { stack: 'ReferenceError: xxx is not defined\n' + ' at http://localhost:7001/public/bundle.e7877aa7bc4f04f5c33b.js:1:1392', message: 'Uncaught ReferenceError: xxx is not defined', filename: 'http://localhost:7001/public/bundle.e7877aa7bc4f04f5c33b.js' } it('stackparser on-the-fly', async () => { const stackParser = new StackParser(__dirname) // 断言 expect(originStack[0]).toMatchObject( { source: 'webpack:///src/index.js', line: 24, column: 4, name: 'xxx' } ) })
整理如下:
下面我们运行Jest
npx jest stackparser --watch
显示运行失败,原因很简单因为我们还没有实现对吧。下面我们就实现一下这个方法。
2.1 反序列Error对象
首先创建一个新的Error对象 将错误栈设置到Error中,然后利用error-stack-parser这个npm库来转化为stackFrame
const ErrorStackParser = require('error-stack-parser') /** * 错误堆栈反序列化 * @param {*} stack 错误堆栈 */ parseStackTrack(stack, message) { const error = new Error(message) error.stack = stack const stackFrame = ErrorStackParser.parse(error) return stackFrame }
运行效果
2.2 解析ErrorStack
下一步我们将错误栈中的代码位置转换为源码位置
const { SourceMapConsumer } = require("source-map"); async getOriginalErrorStack(stackFrame) { const origin = [] for (let v of stackFrame) { origin.push(await this.getOriginPosition(v)) } // 销毁所有consumers Object.keys(this.consumers).forEach(key => { console.log('key:',key) this.consumers[key].destroy() }) return origin } async getOriginPosition(stackFrame) { let { columnNumber, lineNumber, fileName } = stackFrame fileName = path.basename(fileName) console.log('filebasename',fileName) // 判断是否存在 let consumer = this.consumers[fileName] if (consumer === undefined) { // 读取sourcemap const sourceMapPath = path.resolve(this.sourceMapDir, fileName + '.map') // 判断目录是否存在 if(!fs.existsSync(sourceMapPath)){ return stackFrame } const content = fs.readFileSync(sourceMapPath, 'utf8') consumer = await new SourceMapConsumer(content, null); this.consumers[fileName] = consumer } const parseData = consumer.originalPositionFor({ line:lineNumber, column:columnNumber }) return parseData }
我们用Jest测试一下
it('stackparser on-the-fly', async () => { const stackParser = new StackParser(__dirname) console.log('Stack:',error.stack) const stackFrame = stackParser.parseStackTrack(error.stack, error.message) stackFrame.map(v => { console.log('stackFrame', v) }) const originStack = await stackParser.getOriginalErrorStack(stackFrame) // 断言 expect(originStack[0]).toMatchObject( { source: 'webpack:///src/index.js', line: 24, column: 4, name: 'xxx' } ) })
看一下结果测试通过。
2.3 将源码位置记入日志
async index() { console.log const { ctx } = this; const { info } = ctx.query const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8')) console.log('fronterror:', json) // 转换为源码位置 const stackParser = new StackParser(path.join(this.config.baseDir, 'uploads')) const stackFrame = stackParser.parseStackTrack(json.stack, json.message) const originStack = await stackParser.getOriginalErrorStack(stackFrame) this.ctx.getLogger('frontendLogger').error(json,originStack) ctx.body = ''; }
运行效果:
六、开源框架实战
1. Sentry
Sentry 是一个开源的实时错误追踪系统,可以帮助开发者实时监控并修复异常问题。它主要专注于持续集成、提高效率并且提升用户体验。Sentry 分为服务端和客户端 SDK,前者可以直接使用它家提供的在线服务,也可以本地自行搭建;后者提供了对多种主流语言和框架的支持,包括 React、Angular、Node、Django、RoR、PHP、Laravel、Android、.NET、JAVA 等。同时它可提供了和其他流行服务集成的方案,例如 GitHub、GitLab、bitbuck、heroku、slack、Trello 等。目前公司的项目也都在逐步应用上 Sentry 进行错误日志管理。
2. Fundebug
Fundebug专注于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和Java线上应用实时BUG监控。 自从2016年双十一正式上线,Fundebug累计处理了10亿+错误事件,付费客户有阳光保险、荔枝FM、掌门1对1、核桃编程、微脉等众多品牌企业。欢迎免费试用!
总结
截止到目前为止,我们把前端异常监控的基本功能算是形成了一个MVP(最小化可行产品)。后面需要升级的还有很多,对错误日志的分析和可视化方面可以使用ELK。发布和部署可以采用Docker。对eggjs的上传和上报最好要增加权限控制功能。
参考代码位置: github.com/su37josephx…
欢迎指正,欢迎Star。