通过阅读本文,你将学会:
- 常见的前端错误类型和捕获方式
- 前端错误如何上报服务端
- 服务端如何接收前端上报错误和日志记录
- 如何编写一个可以在项目打包时 上传
sourcemap文件的webpack plugin - 如何在服务端 通过 错误日志结合
sourcemap文件还原 错误代码所在位置 - 简单的 Jest 单元测试编写
正文开始
使用 try...catch
const func = () => {
console.log('fun start')
err
console.log('fun end')
}
try {
func()
} catch (err) {
console.log('err', err)
}
预览代码:trycatch捕获异常
- 缺点:无法捕获 异步 错误
const func = () => {
console.log('fun start')
err
console.log('fun end')
}
try {
setTimeout(() => {
func()
})
} catch (err) {
console.log('err', err)
}
那应该怎么捕获异步的错误呢?
window.onerror 捕获异步错误
const func = () => {
console.log('fun start')
err
console.log('fun end')
}
setTimeout(() => {
func()
})
window.onerror = (...args) => {
console.log('args:', args)
}
这里我们可以发先,使用 window.onerror 捕获到了我们的异步错误。
但是,它可以捕获到所有类型的错误吗?
比如:资源加载地址错误?
<img src="//xxsdfsdx.jpg" alt="">
window.onerror = (...args) => {
console.log('args:', args)
}
此时,我们看到该资源地址错误没有被 打印出来,那么我们该怎么捕获这种类型错误呢?
window.addEventListener('error)
资源地址错误怎么捕获?
<img src="/xxx.png" />
window.addEventListener('error', (event) => {
console.log('event err:', event)
}, true) // 第三个参数为 true ,选择捕获的方式监听
promise 怎么捕获?
window.addEventListener('unhandledrejection', (err) =>{})捕获
- 使用
try...catch无法捕获
const asyncFunc = () => {
return new Promise((res) => {
err
})
}
try {
asyncFunc()
} catch(e) {
console.log('err:', e)
}
- 使用
addEventListener('unhandledrejection')
const asyncFunc = () => {
return new Promise((res) => {
err
})
}
asyncFunc()
window.addEventListener('unhandledrejection', (event) => {
console.log('event err:', event)
})
问题:能否使用一个捕获方式捕获所有的错误?
const asyncFunc = () => {
return new Promise((res) => {
err
})
}
asyncFunc()
// 主动抛出捕获到的 promise 类型的错误
window.addEventListener('unhandledrejection', (event) => {
throw event.reason
})
window.addEventListener('error', (err) => {
console.log('err:', err)
}, true)
小结
| 异常类型 | 同步方法 | 异步方法 | 资源加载 | Promise | async / await |
|---|---|---|---|---|---|
| try/catch | y | y | |||
| onerror | y | y | |||
| addEventListener('error') | y | y | y | ||
| addEventListener('unhandledrejection') | y | y |
异常上报服务器
异常上报服务器主要有2 种方式,一是 动态创建 img 标签,二是直接使用
ajax发送请求上报。这里主要讲述第一种方式
动态创建 img 标签
- 错误监听和上报代码
// 上报错误
function uploadError({lineno, colno, error: { stack }, message, filename }) {
console.log('uploadError---', event)
// 整理我们要的错误信息
const errorInfo = {
lineno,
colno,
stack,
message,
filename
}
// 错误信息序列化后使用 base64 编码,避免出现特殊字符导致的错误
const str = window.btoa(JSON.stringify(errorInfo))
// 创建图片,使用图片给错误收集的后端服务器发送一个 get 请求,
// 上传的信息:错误资源,错误时间
new Image().src = `http://localhost:7001/monitor/error?info=${str}`
}
window.addEventListener('unhandledrejection', (event) => {
// 再次主动抛出
throw event.reason
})
window.addEventListener('error', (err) => {
console.log('error:', err)
// 上报错误
uploadError(err)
})
-
后端收集错误
-
搭建
eggjs工程,具体参考 Egg.js官网npm i egg-init -g egg-init backend --type=simple cd backend npm i npm run dev- 编写
error上传接口——添加路由
// /app/router.js module.exports = app => { const { router, controller } = app; router.get('/', controller.home.index); router.get('/monitor/error', controller.monitor.index) };- 编写
error上传接口——编写接口,这里使用到了 Buffer-Nodejs
// app/controller/monitor.js 'use strict'; const Controller = require('egg').Controller; class MonitorController extends Controller { async index() { const { ctx } = this; const { info } = ctx.query // Buffer 接受一个 base64 编码的数据 const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8')) console.log('error-info', json) ctx.body = 'hi, json'; } } module.exports = MonitorController;- 编写
error上传接口——测试
const info = window.btoa(JSON.stringify({test: 'err'})) // "eyJ0ZXN0IjoiZXJyIn0=" // rest-client 测试接口测试 GET http://localhost:7001/monitor/error?info=eyJ0ZXN0IjoiZXJyIn0= // 得到 log 结果:error-info { test: 'err' } - 编写
eggjs 记入错误日志
方式:
- 可以使用
fs写入文件进行记录 - 也可以使用 log4j 这种成熟的日志库
当然,在 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
// Buffer 接受一个 base64 编码的数据
const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8'))
console.log('error-info', json)
// 写入日志
this.ctx.getLogger('frontendLogger').error(json)
ctx.body = 'hi, json';
}
- 测试
// rest-client 测试
GET http://localhost:7001/monitor/error?info=eyJ0ZXN0IjoiZXJyIn0=
- 结果:查看
/logs/frontend.log文件中,有具体的日志信息
2021-04-03 11:58:48,543 ERROR 2180 [-/127.0.0.1/-/4ms GET /monitor/error?info=eyJ0ZXN0IjoiZXJyIn0=] { test: 'err' }
Vue项目中异常如何采集
npm i @vue/cli -g
vue create vue-app
cd vue-app
yarn install
yarn serve
- 编写代码,制造
error
// src/components/HelloWorld.vue
// ... 省略部分代码
export default {
name: 'HelloWorld',
props: {
msg: String
},
mounted() {
// methods 中没有定义方法 abc,报错 error
abc()
}
}
- 关闭
eslint,减少影响,让前端服务能跑起来,新建/编辑vue.config.js
// /vue.config.js
module.exports = {
// close eslint setting
devServer: {
overlay: {
warning: true,
errors: true
}
},
lintOnSave: false
}
- 捕获错误
// src/main.js
// 在 vue 里面统一使用这个 方式捕获错误
Vue.config.errorHandler = (err, vm, info) => {
console.log('errHandler:', err)
uploadError(err)
}
function uploadError({ message, stack }) {
console.log('uploadError---')
// 整理我们要的错误信息
const errorInfo = {
stack,
message,
}
// 错误信息序列化后使用 base64 编码,避免出现特殊字符导致的错误
const str = window.btoa(JSON.stringify(errorInfo))
// 创建图片,使用图片给错误收集的后端服务器发送一个 get 请求,
// 上传的信息:错误资源,错误时间
new Image().src = `http://localhost:7001/monitor/error?info=${str}`
}
new Vue({
render: h => h(App)
}).$mounted('#app')
- 打包 vue 项目,运行测试判断是否捕获错误
yarn build
cd dist
hs
- 删除
dist目录中的sourcemap映射文件,此时报错定位代码就不是源代码了,而是压缩后的代码,不美观
因为打包后的代码 js 文件主要有 2 种
app.xxx.js
app.xxx.js.map
我们可以看看 .map 文件的内容结构:
{
"version": 3,
"sources": [
"webpack:///webpack/bootstrap",
"webpack:///./src/App.vue",
"webpack:///./src/components/HelloWorld.vue",
"webpack:///./src/components/HelloWorld.vue?354f",
"webpack:///./src/App.vue?eabf",
"webpack:///./src/main.js",
"webpack:///./src/assets/logo.png",
"webpack:///./src/App.vue?7d22"
],
"names": [
"webpackJsonpCallback",
"data",
//...
],
"mappings": "aACE,SAASA,EAAqBC...",
"file": "js/app.9a4488cf.js",
"sourcesContent": [" \t// install a JSONP callback..."],
"sourceRoot": ""
}
主要包含了这些东西:
versionSource map的版本,目前为3soruces转换后的文件名names转换前的所有变量名和属性名mappings记录位置信息的字符串file转换后的文件名sourcesContent源内容列表(可选,和源文件列表顺序一致)sourceRoot源文件根目录(可选)
关于 source map 可以参考这 2 篇文章 source-map-阮一峰 和 Source Map 原理及源码探索 - Jooger的文章 - 知乎
后面,我们将从 app.xxx.js.map 中进行解析,还原错误代码
sourcemap 上传插件
编写一个 UploadSourceMapWebpackPlugin 插件,用于每次打包代码的时候自动上传到服务器指定目录
- 编写
webpack plugin
// frontend/plugin/uploadSourceMapWebpackPlugin.js
class UploadSourceMapWebpackPlugin {
constructor(options) {
this.options = options
}
apply(compiler) {
console.log('UploadSourceMapWebpackPlugin apply')
}
}
module.exports = UploadSourceMapWebpackPlugin
- 配置 插件
// /vue.config.js
// refer:https://cli.vuejs.org/zh/config/#configurewebpack
const UploadSourceMapWebpackPlugin = require('./plugin/uploadSourceMapWebpackPlugin')
module.exports = {
configureWebpack: {
plugins:[
new UploadSourceMapWebpackPlugin({
uploadUrl: 'http://localhost:7001/monitor/sourcemap'
})
]
},
// close eslint setting
devServer: {
overlay: {
warning: true,
errors: true
}
},
lintOnSave: false
}
- 打包测试
yarn build
# 此时,我们可以看到命令行中的 log
Building for production...UploadSourceMapWebpackPlugin apply
接下来,完成 UploadSourceMapWebpackPlugin 插件的详细功能
const path = require('path')
const glob = require('glob')
const fs = require('fs')
const http = require('http')
class UploadSourceMapWebpackPlugin {
constructor(options) {
this.options = options
}
apply(compiler) {
console.log('UploadSourceMapWebpackPlugin apply')
// 定义在打包后执行
compiler.hooks.done.tap('UploadSourceMapWebpackPlugin', async status => {
// 读取 sourceMap 文件
const list = glob.sync(path.join(status.compilation.outputOptions.path, `./**/*.{js.map,}`))
console.log('list', list)
// list [
// '/mnt/d/Desktop/err-catch-demo/vue-app/dist/js/app.d15f69c0.js.map',
// '/mnt/d/Desktop/err-catch-demo/vue-app/dist/js/chunk-vendors.f3b66fea.js.map'
// ]
for (let filename of list) {
await this.upload(this.options.uploadUrl, filename)
}
})
}
upload(url, file) {
return new Promise(resolve => {
console.log('upload Map: ', 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()
})
})
}
}
module.exports = UploadSourceMapWebpackPlugin
作用:
在每一次 build done 的时候:
- 读取
sourceMap文件 - 将读取的
sourceMap文件上传到指定服务器中
Eggjs 服务器 sourceMap 上传接口
- 新增后端路由
'use strict';
// /app/router.js
/**
* @param {Egg.Application} app - egg application
*/
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)
};
- 新增接口,文件信息写入
'use strict';
/app/controller/monitor.js
const Controller = require('egg').Controller;
const path = require('path')
const fs = require('fs')
class MonitorController extends Controller {
// ...
async upload() {
const { ctx } = this
// 拿到的是一个 流
const stream = ctx.req
const filename = ctx.query.name
const dir = path.join(this.config.baseDir, 'upload')
// 判断 upload 是否存在
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir)
}
const target = path.join(dir, filename)
// 创建写入流写入信息
console.log('writeFile====', target);
const writeStream = fs.createWriteStream(target)
stream.pipe(writeStream)
}
}
module.exports = MonitorController;
- 关闭
csrf
// /config/config.default.js
config.security = {
// 可能存在 scrf 风险,这里设置关闭
csrf: {
enable: false
}
}
- 测试
yarn build
# egg-server log info
writeFile==== D:\Desktop\err-catch-demo\backend\upload\app.d15f69c0.js.map
writeFile==== D:\Desktop\err-catch-demo\backend\upload\chunk-vendors.f3b66fea.js.map
Stack 解析函数
- 安装
error-stack-parser:
yarn add error-stack-parser
编写测试用例:
- 解析
error.stack信息
// /app/utils/stackparser.js
'use strict';
const ErrorStackParser = require('error-stack-parser');
const { SourceMapConsumer } = require('source-map');
const path = require('path');
const fs = require('fs');
module.exports = class StackParser {
constructor(sourceMapDir) {
this.sourceMapDir = sourceMapDir;
this.consumers = {};
}
parseStackTrack(stack, message) {
const error = new Error(message);
error.stack = stack;
const stackFrame = ErrorStackParser.parse(error);
return stackFrame;
}
async getOriginalErrorStack(stackFrame) {
const origin = [];
for (const v of stackFrame) {
origin.push(await this.getOriginPosition(v));
}
return origin;
}
// 从 sourceMap 文件读取错误信息
async getOriginPosition(stackFrame) {
let { columnNumber, lineNumber, fileName } = stackFrame;
fileName = path.basename(fileName);
// 判断 consumers 是否存在
let consumer = this.consumers[fileName];
if (!consumer) {
// 读取 sourceMap
const sourceMapPath = path.resolve(this.sourceMapDir, fileName + '.map');
// 判断文件是否存在
if (!fs.existsSync(sourceMapPath)) {
// 不存在则返回源文件
return stackFrame;
}
const content = fs.readFileSync(sourceMapPath, 'utf-8');
consumer = await new SourceMapConsumer(content, null);
this.consumers[fileName] = consumer;
}
const parseData = consumer.originalPositionFor({ line: lineNumber, columnNumber });
return parseData;
}
};
- 测试准备:先将
/upload内的.map文件拷贝到app/utils/__test__目录中 - 测试用例:
// 如何通过sourcemap手工还原错误具体信息? https://www.zhihu.com/question/285449738
// /app/utils/stackparser.spec.js
'use strict';
const StackParser = require('../stackparser');
// const { resolve } = require('path');
// const { hasUncaughtExceptionCaptureCallback } = require('process');
const error = {
stack: 'ReferenceError: abc is not defined\n' +
' at Proxy.mounted (http://127.0.0.1:8080/js/app.c82461cf.js:1:606)\n' +
' at i (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:8614)\n' +
' at c (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:8697)\n' +
' at Array.e.__weh.e.__weh (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:15852)\n' +
' at I (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:10078)\n' +
' at Q (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:31862)\n' +
' at mount (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:22532)\n' +
' at Object.e.mount (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:50901)\n' +
' at Object.8287 (http://127.0.0.1:8080/js/app.c82461cf.js:1:1066)\n' +
' at o (http://127.0.0.1:8080/js/app.c82461cf.js:1:1178)',
message: 'abc is not defined',
filename: 'http://127.0.0.1:8080/js/app.c82461cf.js:1:606',
};
it('test==========>', async () => {
const stackParser = new StackParser(__dirname);
// console.log('path', path.basename(__dirname));
// console.log('Stack:', error.stack);
const stackFrame = stackParser.parseStackTrack(error.stack, error);
stackFrame.map(v => {
// console.log('stackFrame: ', v);
return v;
});
const originStack = await stackParser.getOriginalErrorStack(stackFrame);
console.log('originStack=======>0', originStack[0]);
// 断言,需要手动修改下面的断言信息,只测试第 0 个例子
// eslint-disable-next-line no-undef
expect(originStack[0]).toMatchObject({
line: 15,
column: 8,
name: 'abc',
source: 'webpack://front/src/components/HelloWorld.vue',
});
});
这里,我们可以看到,我们需要通过 压缩后的代码报错信息还原成的 sourceMap 对应的文件路径和错误代码所在行数等详细信息:
{
line: 15,
column: 8,
name: 'abc',
source: 'webpack://front/src/components/HelloWorld.vue',
}
- 测试
cd backend/app/utils
npx jest stackparser --watch
显示测试用例通过,测试,我们就完成了:
- 前端常见异常上报服务端
- 服务端通过
sourceMap文件进行错误场景还原:错误代码所在文件和行数
此时,我们就可以精准定位错误代码了。
以上~~~
- 代码: error-catch-demo
- 原文地址:blog/jsmond2016
参考资料: