本文主要记录 Vue2 项目部署后如果发生报错及异常,如何获取到详细的错误信息,并自动通知相关开发人员进行处理。《项目地址》,让我们开始吧!
痛点
通常我们在本地进行开发的时候,如果发生错误或者异常,Vue2 会在浏览器给我们一个堆栈错误信息的提示
并且可以看到该错误发生在哪个组件,哪个文件的哪一行
点击文件会在控制台Sources打开该文件并定位到错误位置
这极大的方便了我们查找错误原因的过程,但是当我们的代码部署到服务器上的时候,通常为了项目优化,不会进行 sourcemap 文件的打包,这个时候,错误信息通常是这个样子的
是不是一脸茫然?点开对应的文件也是打包压缩后的代码,根本无从查看错误信息并对应到源代码的具体文件及具体发生错误位置。
不仅如此,当你收到业务反馈的问题,但是自己打开浏览器一番操作,并没有遇到问题的时候,是不是觉得无从下手了呢?
接下来我们要做的就是当线上环境报错的时候,收集错误信息,并及时通知开发人员。
浏览器中捕获异常通常通过以下方法
addEventListener('error', callback)||window.onerror= callback
这里不再赘述。
Vue2 向我们提供了API errorHandler 进行异常捕获。
说明:我的后台服务是用 Node.js 的 eggjs 框架创建的,你可以用任何自己熟悉的后端语言和框架编写,逻辑也很简单。
主要功能点:
1. 打包时生成sourcemap文件上传到后台
因为 Vue2 使用 webpack 作为模块打包器,所以这里需要编写一个 webpack 插件,作用是打包完成后读取所有 sourcemap 上传到后台,并将打包输入目录中的 sourcemap 文件删除,减少线上环境的资源请求
vue.config.js 中引入及使用
// 引入upload sourcemap webpack plugin
const UploadSourceMapWebPackPlugin = require('./src/plugins/uploadSourceMapWebPackPlugin')
module.exports = {
configureWebpack: {
plugins: [
// 使用 upload sourcemap webpack plugin
new UploadSourceMapWebPackPlugin({
emptyFolderUrl:`${process.env.VUE_APP_MONITOR_BASE_API}/mointor/emptyFolder`,
uploadUrl: `${process.env.VUE_APP_MONITOR_BASE_API}/mointor/uploadSourceMap`
})
]
}
}
uploadSourceMapWebPackPlugin.js
// 上传sourcemap文件插件
const glob = require('glob')
const path = require('path')
const fs = require('fs')
const http = require('http')
class UploadSourceMapWebPackPlugin {
constructor(options) {
this.options = options
}
apply(compiler) {
// 打包时上送sourcemap
// 通过环境变量判断当前环境
let env = '';
if (process.env.VUE_APP_BASE_API === '测试环境地址') {
env = 'uat'
} else if (process.env.VUE_APP_BASE_API === '生产环境地址') {
env = 'prod'
}
// 定义在打包后执行
compiler.hooks.done.tap('uploadSourceMapWebPackPlugin', async status => {
// 处理目标文件夹(没有创建,有则清空)
http.get(`${this.options.emptyFolderUrl}?env=${env}`,()=>{
console.log('handle folder success')
}).on("error",(e)=>{
console.log(`handle folder error: ${e.message}`)
})
// 读取sourcemap文件
const list = glob.sync(path.join(status.compilation.outputOptions.path, './**/*.{js.map,}'))
for (const filename of list) {
// 上传sourcemap
await this.upload(this.options.uploadUrl, filename, env)
// 删除sourcemap
await fs.unlinkSync(filename)
}
})
}
// 上传文件方法
upload(url, file,env) {
return new Promise(resolve => {
const req = http.request(
`${url}?name=${path.basename(file)}&&env=${env}`,
{
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;
后台 emptyFolder
// 准备要上传的sourcemap的目标文件夹(没有则创建,如果有历史文件则清除)
async emptyFolder() {
const { ctx } = this;
const env = ctx.query.env;
const dir = path.join(this.config.baseDir, `upload/${env}`);
// 判断upload/env文件夹是否存在
if (!fs.existsSync(dir)) {
// 没有创建
fs.mkdirSync(dir);
} else {
// 有清空
const files = fs.readdirSync(dir);
files.forEach(file => {
// 每一个文件路径
const currentPath = dir + '/' + file;
// 因为这里存放的都是.map文件,所以不需要判断是文件还是文件夹
fs.unlinkSync(currentPath);
});
}
}
2. 后台服务接收上传的sourcemap文件并保存
// 前端打包时,上送sourcemap文件
async uploadSourceMap() {
const { ctx } = this;
const stream = ctx.req,
filename = ctx.query.name,
env = ctx.query.env;
// 要上传的目标路径
const dir = path.join(this.config.baseDir, `upload/${env}`);
// 目标文件 const target = path.join(dir, filename);
// 写入文件内容 const writeStream = fs.createWriteStream(target);
stream.pipe(writeStream);
}
3. 利用errorHandler进行异常捕获并上送报错信息到后台
main.js 中引入及使用
// 引入handleError
import { handleError } from './utils/monitor'
// 使用handleError进行异常捕获并上传
handleError(Vue)
mointor.js
import axios from 'axios'
// 获取浏览器信息
function getBrowserInfo() {
const agent = navigator.userAgent.toLowerCase();
const regIE = /msie [\d.]+;/gi;
const regIE11 = /rv:[\d.]+/gi;
const regFireFox = /firefox/[\d.]+/gi;
const regQQ = /qqbrowser/[\d.]+/gi;
const regEdg = /edg/[\d.]+/gi;
const regSafari = /safari/[\d.]+/gi;
const regChrome = /chrome/[\d.]+/gi;
// IE10及以下
if (regIE.test(agent)) {
return agent.match(regIE)[0];
}
// IE11
if (regIE11.test(agent)) {
return 'IE11';
}
// firefox
if (regFireFox.test(agent)) {
return agent.match(regFireFox)[0];
}
// QQ
if (regQQ.test(agent)) {
return agent.match(regQQ)[0];
}
// Edg
if (regEdg.test(agent)) {
return agent.match(regEdg)[0];
}
// Chrome
if (regChrome.test(agent)) {
return agent.match(regChrome)[0];
}
// Safari
if (regSafari.test(agent)) {
return agent.match(regSafari)[0];
}}
const handleError = Vue => {
// vue 捕获错误钩子函数
Vue.config.errorHandler = (err, vm) => {
// 本地开发环境抛出异常
// if (process.env.NODE_ENV === "development") throw Error(err)
// 获取环境信息
let environment = '测试环境';
if (process.env.VUE_APP_BASE_API === "生产环境地址") {
environment = '生产环境'
}
/*
上送报错信息
这里可以定制任何信息,比如用户信息,用户点击历史记录,用户路由历史记录等
*/
axios({
method: 'post',
url: `${process.env.VUE_APP_MONITOR_BASE_API}/mointor/reportError`,
data: {
environment,
location: window.location.href,
message: err.message,
stack: err.stack,
// 当前组件
component: vm.$vnode.tag,
// 浏览器信息
browserInfo: getBrowserInfo(),
// 以下信息可以放在vuex store中维护
// 用户ID
userId:'001',
// 用户名称
userName:'张三',
// 路由记录
routerHistory:[
{
fullPath:'/login',
name:'Login',
query:{},
params:{},
},{
fullPath:'/home',
name:'Home',
query:{},
params:{},
}
],
// 点击记录
clickHistory:[
{
pageX:50,
pageY:50,
nodeName:'div',
className:'test',
id:'test',
innerText:'测试按钮'
}
],
}
});
}}
export { handleError }
4. 后台收到报错结合sourcemap文件解析错误信息通知开发人员
// 前端报错,上报error
async reportError() {
const { ctx } = this;
const { environment, location, message, stack, component, browserInfo, userId, userName, routerHistory, clickHistory } = ctx.request.body;
let env = '';
if (environment === '测试环境') {
env = 'uat';
} else if (environment === '生产环境') {
env = 'prod';
}
// 组合sourcemap文件路径
const sourceMapDir = path.join(this.config.baseDir, `upload/${env}`);
// 解析报错信息
const stackParser = new StackParser(sourceMapDir);
let routerHistoryStr = '<h3>router history</h3>',
clickHistoryStr = '<h3>click history</h3>';
// 组合路由历史信息
routerHistory && routerHistory.length && routerHistory.forEach(item => {
routerHistoryStr += `<p>name:${item.name} | fullPath:${item.fullPath}</p>`;
routerHistoryStr += `<p>params:${JSON.stringify(item.params)} | query:${JSON.stringify(item.query)}</p><p>--------------------</p>`;
});
// 组合点击历史信息
clickHistory && clickHistory.length && clickHistory.forEach(item => {
clickHistoryStr += `<p>pageX:${item.pageX} | pageY:${item.pageY}</p>`;
clickHistoryStr += `<p>nodeName:${item.nodeName} | className:${item.className} | id:${item.id}</p>`;
clickHistoryStr += `<p>innerText:${item.innerText}</p><p>--------------------</p>`;
});
// 通过上送的sourcemap文件,配合error信息,解析报错信息
const errInfo = await stackParser.parseStackTrack(stack, message);
// 获取当前时间
const now = new Date();
const time = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()} ${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}`;
// 组织邮件正文
const mailMsg = `
<h3>message:${message}</h3>
<h3>location:${location}</h3>
<p>component:${component}</p>
<p>source:${errInfo.source}</p>
<p>line::${errInfo.lineNumber}</p>
<p>column:${errInfo.columnNumber}</p>
<p>fileName:${errInfo.fileName}</p>
<p>functionName:${errInfo.functionName}</p>
<p>time::${time}</p>
<p>browserInfo::${browserInfo}</p>
<p>userId::${userId}</p>
<p>userName::${userName}</p>
${routerHistoryStr}
${clickHistoryStr}
`;
// sendMail('发件箱地址', '发件箱授权码', '收件箱地址', 主题 environment, 正文 mailMsg);
sendMail('发件箱地址', '发件箱授权码', '收件箱地址', environment, mailMsg);
ctx.body = {
header: {
code: 0,
message: 'OK',
},
};
ctx.status = 200;
}
5. 发送邮件方法
'use strict';
const nodemailer = require('nodemailer');
// 发送邮件方法
function sendMail(from, fromPass, receivers, subject, msg) {
const smtpTransport = nodemailer.createTransport({
host: 'smtp.qq.email',
service: 'qq',
secureConnection: true,
// use SSL
secure: true,
port: 465,
auth: {
user: from,
pass: fromPass,
},
});
smtpTransport.sendMail({
from,
// 收件人邮箱,多个邮箱地址间用英文逗号隔开
to: receivers,
// 邮件主题
subject,
// 邮件正文
html: msg,
}, err => {
if (err) {
console.log('send mail error: ', err);
}
});
}
module.exports = sendMail;
End
至此,Vue2 的异常监控就完成了,后续会写一篇 Vue3 的异常监控文章,逻辑上是一样的,区别在于 Vue3 采用 rollup 作为模块打包器,所以在编写打包插件的时候会有一些区别,感兴趣的点个关注吧!
关于本文有任何问题或建议,欢迎留言讨论!
\