Vue 2.0 生产环境前端错误日志记录实践

核心内容参考自
https://www.cnblogs.com/luozhihao/p/8635507.html
开始实现的时候知道很简单,中间的时候感觉就难了一点,后面实现出来的时候感觉真的很简单。
——————————————————————————
大佬和我进行了一番深刻的交流,总结起来就一句话,在生产环境的时候,前端的报错要能够记录下来。
开发环境的时候的报错,我们都能够直接通过看开发者工具就能够看到具体的报错信息,什么问题,哪一行、哪一列,那些代码报错,这是我们不会进行抓取考虑的——这些报错显然是我们肯定要解决的。
生产环境的时候,我们上传的代码都是打包以后的代码,对于打包好了抓取到的代码报错,我们可以通过source map查找到具体的代码报错,然后进行存储。
摘要:
window全局捕获错误+promise捕获错误+koa2存储+source map
那么,开始。
——————————————————————————
开始规划
俺们村里人,做事都有计划。
规划为两点,一点是捕获报错,一点是存储报错。

捕获报错——基础捕获(windows全局报错+promise报错)
我先解释下为什么叫基础捕获,因为我在搜索相关的解决方案的时候看到了国内外两家很牛提供前端报错日志收集解决方案的公司
国内:FunDebug https://www.fundebug.com/

国外: sentry https://sentry.io/welcome/

它们牛不仅仅是因为它们涵盖的语言、端多,还因为收集错误日志的内容上非常多,甚至FunDebug还能够模拟用户当时是怎么用的。
这也是我们不能选择这两家公司的原因。
那你选择本地版的咯【比我时下的实习生年薪还要高一倍,你觉得BOSS会不会要?

这就不提了,作为第一版的基础版,我只需要基础捕获就够了。
window全局报错捕获
全局捕获报错的时候,使用window.onerror就可以捕获到了。
But,wait
如果你使用的是MVVM框架,那么在是你用winodw.onerror方法捕获的报错,或者叫异常,可能就捕获不到。因为框架一般都有自身的异常机制来进行捕获。你可以通过覆盖这个方法的形式来进行。
比如:
Vue的
Vue.config.errorHandler = function (err, vm, info) {
let {
message, // 异常信息
name, // 异常名称
script, // 异常脚本url
line, // 异常行号
column, // 异常列号
stack // 异常堆栈信息
} = err; // vm为抛出异常的 Vue 实例 // info为 Vue 特定的错误信息,比如错误所在的生命周期钩子}
在实际项目中,是这么调用的
// 全局捕获错误
const errorHandler = (error, vm) => {
reportError(error)
// reportError 是我封装的上传报错信息的内容
}
Vue.config.errorHandler = errorHandler
Vue.prototype.$throw = (error, msg) => errorHandler(error, this)
-----------
在实际的项目运用上,除了这种window全局错误捕获,还要考虑promise下发生的异步报错——这种是刚刚我们用的方法难以捕捉的。我们经常用try catch来捕获错误,这边也会遇到不能捕获的问题。
不过我们知道,promise有专门的catch来捕获错误,但是对于已经成熟的大项目来说,一个个去查找catch,去添加我们的上传错误信息的repotrError是不显示的。
在参考了别人的写法以后,于是考虑到了去 xxoo promise ——对promise进行一次侵犯。
// 如果浏览支持Promise,捕获promise里面then的报错,因为promise里面的错误onerror和try-catch都无法捕获
if (Promise && Promise.prototype.then) {
var promiseThen = Promise.prototype.then
/* eslint-disable */
Promise.prototype.then = function(resolve, reject) {
return promiseThen.call(this, _wrapPromiseFunction(resolve), _wrapPromiseFunction(reject))
}
/* eslint-enable */
}
// 异步报错统一捕捉
var _wrapPromiseFunction = (fn) => {
// 如果fn是个函数,则直接放到try-catch中运行,否则要将类的方法包裹起来,promise中的fn要返回null,不能返回空函数
if (typeof fn !== 'function') {
return null
}
return function () {
try {
return fn.apply(this, arguments)
} catch (error) {
reportError(error)
throw (error)
}
}
}
针对线上环境,如果支持promise,那么我们就会对promise的prototype(见我之前的文章内容)的taen方法进行一次重新改造处理,具体的改造方法是返回一个封装好的try catch函数,相当于是自带了try catch。如果正常,那么返回正常的函数调用方法。这边有一个小问题——我忘记了为何要再写个throw方法来抛出异常了,在捕获了异常以后(reportError),我还需要throw(error)吗?回头思考以后编辑下。
通过上面的方法,我们可以捕获最常见的两种报错,基本上满足了我们的需求。
--------------------------------------
存储错误——与source map 愉快地玩耍
前端拿到错误的时候,并不像我想的那么顺利,在我这里,我不能够直接拿到具体的行、具体的列,只能够通过错误栈找到
说起来,如果是开发环境中,用webpack开启的服务器直接捕获的到的错误栈信息如下
SyntaxError: Unexpected token s in JSON at position 4
at JSON.parse (<anonymous>)
at eval (webpack-internal:///1440:455:52)
at eval (webpack-internal:///465:109:15)
而如果是线上打包环境的话,
拿到的错误栈信息如下
SyntaxError: Unexpected token s in JSON at position 4
at JSON.parse (<anonymous>)
at 【马赛克】11.4e17da6131b2c0b1d277.js:21:9826
at 【马赛克】app.bc205a634089f0bd3803.js:1:46844
根据这个错误栈信息,我们知道这个错误涉及到两个js文件的名字,对应的行以及对应的列——但是这样对我们解决bug基本上没有什么帮助。
不过有了对应的js文件的名字,错误的行和列以及足够了。
开始做存储错误处理的操作;
第一步:生成打包后的js文件对应的map文件
如果你是webpack,可以这么配置
productionSourceMap: true
然后你就会看到在每一个.js文件下都会有对应的同名的.map文件,这是一一对应的,所以我们很方便就可以联系起来

第二步:选择适合自己的source map解析方式,我没有直接自己去解析map文件,虽然参考了一些网上的案例,最后还是直接用了sourceMap 这个npm包进行解析处理
const sourceMap = require('source-map'); // *核心内容,解析source-map的内容
第三步:解析我们打包的报错代码
我使用的是node服务器,已知
SyntaxError: Unexpected token s in JSON at position 4
at JSON.parse (<anonymous>)
at 【马赛克】11.4e17da6131b2c0b1d277.js:21:9826
at 【马赛克】app.bc205a634089f0bd3803.js:1:46844
如上的错误信息,我只要获得错误的文件名【找到对应的map文件】 ,行数,列数这三个数据即可。
核心代码如下:
const fs = require('fs'); // 读取文件
const path = require('path'); // 读取文件路径
const readline = require('readline'); // 按行读取文件
const sourceMap = require('source-map'); // *核心内容,解析source-map的内容
/*
* 按行读取文件内容
* 返回:字符串数组
* 参数:fReadName:文件名路径
* callback:回调函数
* */
/*
* 按行读取文件内容
* 返回:字符串数组
* 参数:fReadName:文件名路径
* callback:回调函数
* */
async function readFileToArr(fReadName, callback){
var fRead = fs.createReadStream(fReadName);
var objReadline = readline.createInterface({
input:fRead
});
var arr = new Array();
objReadline.on('line',function (line) {
arr.push(line);
});
await new Promise((resolve, reject) => {
objReadline.on('close',function () {
callback(arr)
resolve('done!')
});
})
}
/**
* ctx 内容
* @param ret.name // 报错对应的名称
* @param ret.source // 报错文件路径
* @param ret.line // 报错文件行号
* @param ret.lcolumn // 报错文件列号
*/
let {
userAgent,
pathname,
name,
error,
} = ctx.request.body
// 下面的解析错误栈的方式,是因为我不知道为何不能直接拿到报错文件路径、行号、列号所做的hack,如果你能直接拿到,可以不这么做。直接看核心代码即可
// 得到js地址的正则表达式
var geJsMap = /(?<=js\/).*?(?=:)/
// 存储了js链接,不一定只有一个
const jsList = []
// 存储了每个js文件报错对应的行
const lineList = []
// 存储了每个js文件报错对应的列
const columnList = []
// 存储了得到的真正的资源文件的内容
const sourceret = []
// 取得js文件、报错的行和列
error.split('at').forEach(element => {
if (element.match(geJsMap)) {
// 取得js文件
jsList.push(element.match(geJsMap)[0])
}
// 取得报错的行和列
if (element.split(':').length > 2) {
lineList.push(element.split(':')[1])
columnList.push(element.split(':')[2].split('\\n')[0])
}
});
// 遍历显示异常的js文件
for (let i in jsList) {
// 得到js文件对应的.map文件
let fileUrl = `${jsList[i]}.map`;
// 得到解析了map文件的smc对象 核心内容
let smc = new sourceMap.SourceMapConsumer(fs.readFileSync(resolve(`../js/${fileUrl}`), 'utf8')); // 返回一个promise对象
// 通过originalPositionFor获得result文件的内容
await smc.then(function (result) {
// 核心内容
let ret = result.originalPositionFor({
line: parseInt(lineList[i]), // 压缩后的行号
column: parseInt(columnList[i])// 压缩后的列号
// 到了这一步,你可以获得下面的内容

});
/**
* 解析原始报错数据
* @param ret.name // 报错对应的名称
* @param ret.source // 报错文件路径
* @param ret.line // 报错文件行号
* @param ret.lcolumn // 报错文件列号
*/
sourceret[i] = ret
})
}
// 下面是锦上添花的存储错误日志内容,大家看看就行
// 打印错误
const errorTime = `${new Date().toLocaleString()}\r\n`
const errorUserAgent = `${userAgent}\r\n`
const errorPath = `${pathname}\r\n`
const errorName = `${name}\r\n`
let errorStack = ''
for (let i in sourceret) {
errorStack += `${sourceret[i].name}\r\n${sourceret[i].source.slice(sourceret[i].source.indexOf('src'))}:${sourceret[i].line}:${sourceret[i].column}\r\n`
await readFileToArr(sourceret[i].source.slice(sourceret[i].source.indexOf('src')), (arr) => {
let leftSpace = ''
for (let j = 0; j < arr[sourceret[i].line - 1].trim().indexOf(sourceret[i].name); j++) {
leftSpace += '-'
}
leftSpace += '△\r\n'
errorStack += arr[sourceret[i].line - 1].trim() + '\r\n' + leftSpace
})
}
let data = errorTime + errorUserAgent + errorPath + errorName + errorStack + '\r\n'
// 当前时间
const nowLocalDate = `${new Date().getFullYear()}-${new Date().getMonth() + 1}-${new Date().getDate()}`
/** 打印错误内容到{当前时间}_error.log */
fs.appendFile(`${nowLocalDate}_error.log`, data, function (err) {
if (err) {
console.log(err)
}
})
最后
这个错误日志的存储最终暂时还是搁置了,因为还涉及到埋点等的问题,需要我们后期进一步的研究。希望这个文章能够给想要进行线上前端报错存储的你带来一些帮助。