一、项目背景
前端项目的开发,由于工程化的不断加深,编写的源代码与打包生成的线上代码之间,存在了越来越多的转义、压缩、polyfill等修改,导致代码的编写与目标产出的距离越来越远,es6、es7、ts等语法的广泛使用,更是让目标代码更加的模糊。
代码的编写规范以及eslint的强制校验,这些约束虽然有效,但依旧无法避免问题代码的出现,我们需要一个扫描系统,该系统可以全局扫描源代码以及打包之后的目标代码,主动的找出触碰问题规则的代码,降低线上事故隐患。
二、具体要做出个啥东西嘞?
一个npm包,或者说是一个js脚本文件,使用方只需要做一些简单的引入,便可以通过npm/yarn 命令的方式,得到相关的扫描结果
- 扫描范围:包含打包前的src下方的主要代码,以及打包之后的代码
问:打包的时候,webpack有众多插件,babel也做了代码转换,为啥还要扫描打包后的代码呢?
答:请不要盲目的去相信自己的打包配置,你源码写的再怎么漂亮,最后上线的毕竟是build之后的代码,多一点谨慎,多一步查验,总是有必要的,查不出什么最好,万一查出来了呢?(我遇到过不止一次,由于打包之后的代码中带有箭头函数,导致不小的线上事故)
三、框架设计
由于我所在的公司,主要c端项目,使用的是vue-cli创建的项目,我便以改类型的项目作为扫描的目标项目
1、涉及技术点
- webpack
- rollup
- babel
- source-map
2、框架搭建前的一些重点思考
1. 如何做代码的扫描?
答:使用AST(抽象语法树)
2. 怎么使用AST呢?
答:使用babel,@babel/parser 暴露出的parser,课使用parse方法将源码转成AST(抽象语法树),这样我们便可以遍历语法树,精准的查找我们需要扫描到的代码
3. 扫描源代码,可babel没法解析.vue等文件,怎么办?
答:使用webpack的babel-loader,通过babel插件的方式,注入自己编写的扫描代码
4. 使用webpack触发打包,怎么确定打包配置?
答:使用目标项目的打包配置,vue-cli生成的项目,可以从@vue/cli-service/webpack.config
const baseWebpackConfig = require('@vue/cli-service/webpack.config')
5、如何将扫描代码的babel插件注入到目标代码的代码之中?
答:自定义babel-loader的配置,使用'webpack-merge'合并
6、如何知道webpack打包结束,改执行扫描打包后的代码了?
答:使用wbpack插件,使用done这个钩子,监听打包结束
7、扫描打包后的代码,如何定位到源码?
答:webpack打包的时候,开启sourceMap,这样便可以安装source-map这个包,使用SourceMapConsumer定位源码位置
3、目录结构
- src
- babelPlugins --> babel相关的插件
- customPlugins.js --> 一个自定义的babel插件,实现一个简单的扫描功能
- webpackPlugins --> webpack相关的插件
- webpackRunDonePlugin.js --> 用于监听weback打包开始和结束的插件
- config --> 扩展的webpack配置
- merge.webpack.config.js --> 扩展webpack打包配置
- scanBuildCode --> 扫描打包后的代码
- index.js --> 实现扫描打包后的代码
- lib --> 一些工具方法
- createLog.js --> 主要是使用fs,生成日志
- util.js --> 通用的一些工具方法
- index.js --> 主入口js
- rollup.config.js --> rollup打包相关的配置文件
- babel.config.js --> rollup打包使用的babel插件的相关配置
- package.json
- README.md
四、部分代码解析
- index.js 主入口js
const fs = require('fs')
const webpack = require('webpack')
const { merge } = require('webpack-merge')
const util = require('./lib/util')
const baseWebpackConfig = require('@vue/cli-service/webpack.config')
const mergeWebpackConfig = require('./config/merge.webpack.config')
const WebpackRunDonePlugin = require('./webpackPlugins/webpackRunDonePlugin')
const createLog = require('./lib/createLog')
const webpackConfig = merge(baseWebpackConfig, mergeWebpackConfig)
class CodeScanner {
run (basePath) {
global.basePath = basePath
// 删除babel缓存,不然babel插件只会在第一次完好执行
const babelLoaderCachePath = `${basePath}/node_modules/.cache/babel-loader`
if (fs.existsSync(babelLoaderCachePath)) {
util.removeDir(babelLoaderCachePath)
}
// 创建扫描日志目录
createLog.createDir(basePath)
// 传入配置,生成webpack编译实例
const compiler = webpack(webpackConfig)
// 添加wbpack插件,监听开始和结束
new WebpackRunDonePlugin({
basePath: basePath,
buildOutPath: webpackConfig.output.path // 打包后的输出文件夹
}).apply(compiler)
compiler.run((err, stats) => {
// ...主要是打印一些报错信息
})
}
}
module.exports = new CodeScanner()
- merge.webpack.config.js 扩展webpack打包配置
const customPlugin = require('../babelPlugins/customPlugin')
module.exports = {
mode: 'development',
devtool: 'source-map', // 开启sourcemap,不然扫描打包后的代码,没法定位源码
module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
plugins: [customPlugin], // 加入自定义的babel扫描插件
cacheDirectory: false,
cacheCompression: false
}
}
}
]
}
}
- customPlugins.js 扫描打包前的代码的bebel插件
const createLog = require('../lib/createLog')
module.exports = function (babel) {
console.log('babel插件开始执行')
var logs = '['
const t = babel.types
return {
name: 'custom-babel-plugin',
visitor: {
CallExpression(path, state) {
const obj = path.node.callee.object
const prop = path.node.callee.property
if (t.isIdentifier(obj) && t.isIdentifier(prop) && obj.name === 'console' && prop.name === 'log') {
console.log('写入日志文件')
logs = logs + '\n' + JSON.stringify({
line: path.node.loc.start.line, // 代码所在行数
column:path.node.loc.start.column, // 代码所在列数
state: state.filename // 代码所在的源文件
}) + ','
createLog.stringWriteToJsFile(logs + '\n]', global.basePath)
}
}
}
}
}
- webpackRunDonePlugin.js
const BabelSanner = require('../scanBuildCode/index')
const pluginName = 'webpackRunDonePlugin'
class WebpackRunDonePlugin {
constructor({basePath, buildOutPath}){
// 传入的参数挂载在这个类的实例上.
this.basePath = basePath
this.buildOutPath = buildOutPath
}
apply(compiler) {
compiler.hooks.run.tap(pluginName, (compilation) => {
console.log('Webpack 构建过程开始');
})
compiler.hooks.done.tap(pluginName, (stats) => {
console.log('Webpack 构建结束')
setTimeout(() => {
// 开启扫描打包后的代码
const bs = new BabelSanner({
buildOutPath: this.buildOutPath
})
bs.run()
}, 3000)
})
}
}
module.exports = WebpackRunDonePlugin;
- scanBuildCode/index.js 实现扫描打包后的代码
const parser = require('@babel/parser')
const traverse = require('@babel/traverse')
const types = require('@babel/types')
const fs = require('fs')
const util = require('../lib/util')
class BabelSanner {
constructor (data) {
this.buildOutPath = data.buildOutPath
}
// 运行扫描
run () {
console.log("-------开始执行扫描打包后的js-------")
const fileObj = util.findJsAndMap(this.buildOutPath, {
jsObj: {},
mapObj: {}
})
const jsNames = Object.keys(fileObj.jsObj)
jsNames.forEach(jsName => {
const jsPath = fileObj.jsObj[jsName]
const jsMapPath = fileObj.mapObj[jsName + '.map']
this.parseAndTraverseJsFile(jsPath, jsMapPath)
})
// console.log(JSON.stringify(fileObj))
}
// 解析js文件
parseAndTraverseJsFile (jsPath, jsMapPath) {
console.log('----解析js文件---')
const sourceCode = fs.readFileSync(jsPath, {
encoding: 'utf-8'
})
const sourceMapCode = fs.readFileSync(jsMapPath, {
encoding: 'utf-8'
})
// 第一步:生成AST
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous' // 根据内容是否有 import 和 export 来确定是否解析 es module 语法
})
// 第二步:遍历 AST
traverse(ast, {
CallExpression(path, state) {
const obj = path.node.callee.object
const prop = path.node.callee.property
if (types.isIdentifier(obj) && types.isIdentifier(prop) && obj.name === 'console' && prop.name === 'log') {
util.getBuildCodeLocationInSourceCode(sourceMapCode, path.node.loc.start.line, path.node.loc.start.column, (originCodeInfo) => {
const location = `---out: line ${path.node.loc.start.line}, column ${path.node.loc.start.column}, ${jsPath}---`;
console.log(location) // 扫描代码位置
console.log('---originCodeInfo---:', JSON.stringify(originCodeInfo)) // 所在源码位置
})
}
}
})
}
}
module.exports = BabelSanner
- util.getBuildCodeLocationInSourceCode方法
const { SourceMapConsumer } = require('source-map')
// 找到打包后的代码在源码中的位置
function getBuildCodeLocationInSourceCode (sourceMapCode, line, column, cb) {
SourceMapConsumer.with(sourceMapCode, null, consumer => {
// 目标代码位置查询源码位置
const p = consumer.originalPositionFor({
line: line,
column: column
})
cb && cb(p)
})
}