前端工程化-实现前端代码扫描的框架搭建

1,605 阅读5分钟

一、项目背景

前端项目的开发,由于工程化的不断加深,编写的源代码与打包生成的线上代码之间,存在了越来越多的转义、压缩、polyfill等修改,导致代码的编写与目标产出的距离越来越远,es6、es7、ts等语法的广泛使用,更是让目标代码更加的模糊。

代码的编写规范以及eslint的强制校验,这些约束虽然有效,但依旧无法避免问题代码的出现,我们需要一个扫描系统,该系统可以全局扫描源代码以及打包之后的目标代码,主动的找出触碰问题规则的代码,降低线上事故隐患。

二、具体要做出个啥东西嘞?

一个npm包,或者说是一个js脚本文件,使用方只需要做一些简单的引入,便可以通过npm/yarn 命令的方式,得到相关的扫描结果

  • 扫描范围:包含打包前的src下方的主要代码,以及打包之后的代码

问:打包的时候,webpack有众多插件,babel也做了代码转换,为啥还要扫描打包后的代码呢?

答:请不要盲目的去相信自己的打包配置,你源码写的再怎么漂亮,最后上线的毕竟是build之后的代码,多一点谨慎,多一步查验,总是有必要的,查不出什么最好,万一查出来了呢?(我遇到过不止一次,由于打包之后的代码中带有箭头函数,导致不小的线上事故)

三、框架设计

由于我所在的公司,主要c端项目,使用的是vue-cli创建的项目,我便以改类型的项目作为扫描的目标项目

1、涉及技术点

  1. webpack
  2. rollup
  3. babel
  4. 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

四、部分代码解析

  1. 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()
  1. 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
          }
        }
      }
    ]
  }
 }
  1. 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)
        }
      }
    }
  }
}

  1. 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;

  1. 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

  1. 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)
  })
}

源码地址

github.com/lcl6659/cod…