webpack 自定义 loader
需求来源
需要在客户端中写入日志,并且需要知道报错日志的原文件路径和行号,不采用 sentry 的方式(需要额外部署)
思路
想法是在 logger 的最后两个参数中加入原始文件名和行号,所以这一步在 webpack 加载文件的时候就需要去解析文件,默认添加参数
自定义 loader
const path = require('path')
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default
const parser = require('@babel/parser')
const t = require('@babel/types')
/**
* 自己封装的方法
* 使用方式:logger.log(...args)
*/
const loggers = [
'log',
'error',
'warn',
'info',
'debug'
]
function loggerLoader(content) {
// 得到源文件的路径
const filename = path.relative(this.rootContext, this.resourcePath)
// 解析 AST
const ast = parser.parse(content, {
sourceType: 'module'
})
// 便利 AST
traverse(ast, {
// https://astexplorer.net/ 这里可以在这个网站中看见,遍历对应的 Expression
CallExpression(path) {
const memberExpression = path.get('MemberExpression')
// 这里可以通过 debug 的方式一步步寻找自己想要的节点
if (memberExpression && memberExpression.container && memberExpression.container.callee) {
const callee = memberExpression.container.callee
if (callee.object && callee.property) {
// 验证 logger.log
if (t.isIdentifier(callee.object, {name: 'logger'}) &&
loggers.find(v => t.isIdentifier(callee.property, {name: v}))) {
// 构造一个 string ,传入文件路径
const filenameNode = t.stringLiteral(filename)
// 构造一个 number ,传入开始行号
const lineNumStart = t.numericLiteral(path.node.loc.start.line)
// 构造一个 number ,传入结束行号
const lineNumEnd = t.numericLiteral(path.node.loc.end.line)
// 放入到参数末尾中
path.node.arguments.push(filenameNode)
path.node.arguments.push(lineNumStart)
path.node.arguments.push(lineNumEnd)
}
}
}
}
})
// 重新生成代码,则所有的 logger.log 在最后都加上了三个参数 (...args, filename, lineNumStart, lineNumEnd)
return generate(ast, {}).code
}
// 默认导出
module.exports = loggerLoader
封装 logger
采用了 winston 进行日志写入
const {
createLogger,
format,
transports
} = require('winston')
require('winston-daily-rotate-file')
const os = require('os')
const path = require('path')
// 构造 Symbol
const loggerSymbol = Symbol('loggerSymbol')
// winston 中需要的方法名,其他可以自行加入
const LOGGERS = [
'error',
'warn',
'info',
'debug'
]
class Logger {
// winston 实例
constructor(logger) {
this.logger = logger
}
log(...args) {
this.info(...args)
}
info(...args) {
this[loggerSymbol]('log', ...args)
this.logger.info(...args)
}
warn(...args) {
this[loggerSymbol]('warn', ...args)
this.logger.warn(...args)
}
error(...args) {
this[loggerSymbol]('error', ...args)
this.logger.error(...args)
}
debug(...args) {
this[loggerSymbol]('debug', ...args)
this.logger.debug(...args)
}
[loggerSymbol](type, ...args) {
// 获取最后三个变量,则是原始文件路径,开始行号和结束行号
const fileInfoArgs = args.splice(args.length - 3)
if (process.env.NODE_ENV === 'development') {
// 按需答应
console[type](
`[${fileInfoArgs[0]}] [${fileInfoArgs[1]}] [${fileInfoArgs[2]}]`,
...args
)
}
}
}
const customFormat = format.combine(
// format.label({ label: 'render' }),
format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss'
}),
format.align(),
// 构造打印日志的格式
format.printf((info) => {
info.message = info.message.substring(1)
// webpack 编译传入的参数对象
const symbol = Object.getOwnPropertySymbols(info).find(symbol => symbol.description === 'splat')
let message
if (symbol) {
// 通过 webpack loader 会传入文件所在的位置
const args = info[symbol]
const len = args.length
if (len >= 3) {
// 自定义日志格式,加入源文件路径,开始行号和结束行号
message = `[${[info.timestamp]}] [${args[len - 3]}] [${args[len - 2]}] [${args[len - 1]}] [${info.level}] - ${info.message}`
}
}
if (!message) {
message = `[${[info.timestamp]}] [${info.level}] - ${info.message}`
}
return message
})
)
const defaultOptions = {
format: customFormat,
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
// maxSize: '1m',
maxFiles: '14d'
}
let filename = 'logs/%DATE%.log'
filename = process.env.NODE_ENV === 'development' ? filename : path.join(os.tmpdir(), filename)
const logger = createLogger({
// format: customFormat,
transports: [
new transports.DailyRotateFile({
filename,
level: 'debug',
...defaultOptions
})
]
})
const toMessage = (arg) => `${arg} `
// 重写 winston 实例,对路径和行号参数进行处理,把原有的参数变为字符串
LOGGERS.forEach(v => {
const fn = logger[v]
if (fn) {
logger[v] = function (...args) {
let message = ''
const fileInfoArgs = args.splice(args.length - 3)
args.forEach(arg => {
if (typeof arg === 'object') {
message += toMessage(JSON.stringify(arg))
} else {
message += toMessage(arg)
}
})
fn(message, ...fileInfoArgs)
}
}
})
const hxLogger = new Logger(logger)
module.exports = {
logger: hxLogger,
loggerFilePath: filename
}
webpack 配置
// webpack 配置
module.exports = {
module: {
rules: [{
test: /\.js$/,
// 制定自定义 loader 的路径
use: [path.join(__dirname, './console-loader.js')]
}]
}
}
// 代码使用
logger.info('test')
logger.error('test')
logger.debug('test')
logger.warn('test')
logger.log('test')
日志样例
[2022-10-25 13:11:59] [src/renderer/App.vue] [17] [17] [info] - IPV4 111.111.111.111
[2022-10-25 13:11:59] [src/renderer/App.vue] [18] [18] [info] - mac 地址 11:11:11:11:11:11
[2022-10-25 13:11:59] [src/renderer/App.vue] [19] [19] [info] - 磁盘序列号 1111111111
[2022-10-25 13:11:59] [src/renderer/App.vue] [20] [20] [info] - 系统序列号 111111111
[2022-10-25 13:11:59] [src/renderer/App.vue] [21] [21] [info] - 系统驱动版本 12.6
[2022-10-25 13:11:59] [src/renderer/App.vue] [22] [22] [info] - 用户名 11111111
不足
vue 文件中的行号不是对应 script 标签中的行号,需要减去前面 template 占有的行号,基本可以满足对日志定位的需求