一个为了让console.log写起来更偷懒的webpack-plugin

3,168 阅读7分钟

作为一个敲码5分钟,调试两小时的bug大叔,每天和console.log打的交道自然不少,人到中年,越来越懒,于是想把console.log('bug: ', bug)变成log.bug来让我的懒癌病发得更加彻底。于是硬着头皮看了下webpack插件的写法,在此记录一下webpack系统的学习笔记。

跟着webpack源码摸石过河

去吧!皮卡丘!!!

  1. webpack初始化
// webpack.config.js

const webpack = require('webpack');
const WebpackPlugin = require('webpack-plugin');

const options = {
  entry: 'index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'index.bundle.js'
  },
  module: {
    rules: []
  },
  plugins: [
    new WebpackPlugin()
  ]
  // ...
};

webpack(options);
// webpack.js

// 引入Compiler类
const Compiler = require("./Compiler");

// webpack入口函数
const webpack = (options, callback) => {

  // 创建一个Compiler实例
  compiler = new Compiler(options.context);
  
  // 实例保存webpack的配置对象
  compiler.options = options
  
  // 依次调用webpack插件的apply方法,并传入compiler引用
  for (const plugin of options.plugins) {
    plugin.apply(compiler);
  }
  
  //执行实例的run方法
  compiler.run(callback)
  
  //返回实例
  return compiler
}

module.exports = webpack
  • 最开始,无论我们在控制台输入webpack指令还是使用Node.js的API,都是调用了webpack函数(源码),并传入了webpack的配置选项,创建了一个 compiler 实例。
  • compiler 是什么?—— 明显发现compiler保存了完整的webpack的配置参数options。所以官方说:

compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。可以使用 compiler 来访问 webpack 的主环境。

  • 所有 webpack-plugin 也在这里通过提供一个叫 apply 的方法给webpack调用,以完成初始化的工作,并且接收到刚创建的 compiler 的引用。
  1. compiler、compilation与tapable
// Compiler.js

const {
  Tapable,
  SyncHook,
  SyncBailHook,
  AsyncParallelHook,
  AsyncSeriesHook
} = require("tapable");

const Compilation = require("./Compilation");

class Compiler extends Tapable {

  hooks = [
    hook1: new SyncHook(),
    hook2: new AsyncSeriesHook(),
    hook3: new AsyncParallelHook()
  ]

  run () {
    // ...
    // 触发钩子回调执行
    this.hooks[hook1].call()
    this.hooks[hook2].callAsync(() => {
      this.hooks[hook4].call()
    })

    // 进入编译流程
    this.compile()
    // ...
    this.hooks[hook3].promise(() => {})
  }

  compile () {
    // 创建一个Compilation实例
    const compilation = new Compilation(this)
  }
}
  • 研究下Compiler.js这个文件,它引入了一个 tapable 类(源码)的库,这个库提供了一些 Hook 类,内部实现了类似的 事件订阅/发布系统

  • 哪里用到 Hook 类?—— 在 Compiler 类(Compiler.js源码) 里拥有很多有意思的hook,这些hook代表了整个编译过程的各个关键事件节点,它们都是继承于 Hook 类 ,所以都支持 监听/订阅,只要我们的插件提前做好事件订阅,那么编译流程进行到该事件点时,就会执行我们提供的 事件回调 ,做我们想做的事情了。

  • 如何进行订阅呢?——在上文中每个webpack-plugin中都有一个apply方法。其实注册的代码就藏在里面,通过以下类似的代码实现。任何的webpack插件都可以订阅hook1,因此hook1维护了一个taps数组,保存着所有的callback。

    compiler.hooks.hook1.tap(name, callback) // 注册/订阅

    compiler.hooks.hook1.call() // 触发/发布

  • 准备工作做好了之后,当 run() 方法开始被调用时,编译就正式开始了。在该方法里,执行 call/callAsync/promise 这些事件时(他们由webpack内部包括一些官方使用的webpack插件进行触发的管理,无需开发者操心),相应的hook就会把自己的taps里的函数均执行一遍。大概的逻辑如下所示。

  • 其中,hooks的执行是按照编译的流程顺序来的,hooks之间有彼此依赖的关系。

  • compilation 实例也在这里创建了,它代表了一次资源版本构建。每当检测到文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。而且compilation也和compiler类似,拥有很多hooks(Compilation.js源码),因此同样提供了很多事件节点给予我们订阅使用。

  1. webpack插件体系的使用套路
class MyPlugin {
  apply(compiler) {
    // 设置回调来访问 compilation 对象:
    compiler.hooks.compilation.tap('myPlugin', (compilation) => {
      // 现在,设置回调来访问 compilation 中的任务点:
      compilation.hooks.optimize.tap('myPlugin', () => {
        console.log('Hello compilation!');
      });
    });
  }
}

module.exports = MyPlugin;
  • 订阅 compilercomplation 的事件节点都在webpack-plugin中的 apply 方法里书写,具体的演示如上。当然你想拿到编译过程中的什么资源,首先得要找出能提供该资源引用的对应的compiler事件节点进行订阅(上帝先创造了亚当,再有夏娃)。每个compiler时间节点能提供什么参数,在hook的实例化时已经做了说明(如下),更多可查看源码
this.hooks = {
  // 拿到compilation和params
  compilation: new SyncHook(["compilation", "params"]),
  // 拿到stats
  done: new AsyncSeriesHook(["stats"]),
  // 拿到compiler
  beforeRun: new AsyncSeriesHook(["compiler"])
}

梳理webpack流程

经过以上的初步探索,写webpack插件需要了解的几个知识点应该有了大概的掌握:

  1. 插件提供 apply 方法供 webpack 调用进行初始化
  2. 使用 tap 注册方式钩入 compilercompilation 的编译流程
  3. 使用 webpack 提供的 api 进行资源的个性化处理。

写插件的套路已经知道了,现在还剩如何找出合适的钩子,修改资源这件事。在webpack系统里,钩子即流程,是编译构建工作的生命周期 。当然,想要了解所有 tapable 实例对象的钩子的具体作用,需要探索webpack所有的内部插件如何使用这些钩子,做了什么工作来进行总结,想想就复杂,所以只能抽取重要流程做思路概括,借用淘宝的一张经典图示。![webpack_flow.jpg](file:///Users/Ando/Documents/webpack-plugin/webpack_flow.jpg) 整个编译流程大概分成三个阶段,现在重新整理一下:

  1. 准备阶段 ,webpack的初始化
  • webpack依次调用开发者自定义的插件的 apply 方法,让插件们做好事件注册。
  • WebpackOptionsApply 接收组合了 命令行webpack.config.js 的配置参数,负责webpack内部基础流程使用的插件和compiler上下文环境的初始化工作。(*Plugin均为webpack内部使用的插件)
      // webpack.js
      // 开发者自定义的插件初始化
      if (options.plugins && Array.isArray(options.plugins)) {
        for (const plugin of options.plugins) {
          if (typeof plugin === "function") {
            plugin.call(compiler, compiler)
          } else {
            plugin.apply(compiler)
          }
        }
      }
      // ...
      // webpack内部使用的插件初始化
      compiler.options = new WebpackOptionsApply().process(options, compiler)
    
      // WebpackOptionsApply.js
      class WebpackOptionsApply extends OptionsApply {
        process (options, compiler) {
          // ...
          new WebAssemblyModulesPlugin({
              mangleImports: options.optimization.mangleWasmImports
          }).apply(compiler);
    
          new EntryOptionPlugin().apply(compiler);
          compiler.hooks.entryOption.call(options.context, options.entry);
    
          new CompatibilityPlugin().apply(compiler);
          new HarmonyModulesPlugin(options.module).apply(compiler);
          new AMDPlugin(options.module, options.amd || {}).apply(compiler);
          // ...
        }
      }
    
  • 执行 run / watch(一次打包/监听打包模式)触发 编译 阶段
  1. 编译阶段,生成modulechunk资源。

    runcompile 编译 → 创建 compilation 对象。compilation 的创建是一次编译的起步,即将对所有模块加载(load)封存(seal)优化(optimiz)分块(chunk)哈希(hash)重新创建(restore)

      module.exports = class Compiler extends Tapable {
    
        run () {
          // 声明编译结束回调
          function onCompiled () {}
          // 触发run钩子
          this.hooks.run.callAsync(this, err => {
            this.compile(onCompiled)
          })
        }
    
        compile(callback) {
          // ...
          // 编译开始前,触发beforeCompile钩子
          this.hooks.beforeCompile.callAsync(params, err => {
            // 编译开始,触发compile钩子
            this.hooks.compile.call(params);
            // 创建compilation实例
            const compilation = this.newCompilation(params);
            // 触发make钩子
            this.hooks.make.callAsync(compilation, err => {
              // 模块解析完毕,执行compilation的finish方法
              compilation.finish();
              // 资源封存,执行seal方法
              compilation.seal(err => {
                // 编译结束,执行afterCompile钩子
                this.hooks.afterCompile.callAsync(compilation, err => {
    
                  // ...
                });
              });
            });
          });
        }
      }
    
  • 加载模块make钩子触发 → DllEntryPlugin 内部插件调用compilation.addEntrycompilation维护了一些资源生成工厂方法 compilation.dependencyFactories ,负责把入口文件及其(循环)依赖转换成 modulemodule 的解析过程会应用匹配的 loader )。每个 module 的解析过程提供 buildModule / succeedModule 等钩子, 所有 module 解析完成后触发 finishModules 钩子。
  • 封存seal 方法包含了 优化/分块/哈希 , 编译停止接收新模块,开始生成chunks。此阶段依赖了一些webpack内部插件对module进行优化,为本次构建生成的chunk加入hash等。createChunkAssets()会根据chunk类型使用不同的模板进行渲染。此阶段执行完毕后就代表一次编译完成,触发 afterCompile钩子
    • 优化BannerPlugin
        compilation.hooks.optimizeChunkAssets.tapAsync('MyPlugin', (chunks, callback) => {
          chunks.forEach(chunk => {
        	chunk.files.forEach(file => {
        	  compilation.assets[file] = new ConcatSource(
        	    '\/**Sweet Banner**\/',
        	    '\n',
        	    compilation.assets[file]
        	  );
        	});
          });
      
          callback();
        });
      
    • 分块:用来分割chunk的 SplitChunksPlugin 插件监听了optimizeChunksAdvanced钩子
    • 哈希createHash
  1. 文件生成阶段
  • 编译完成后,触发 emit ,遍历 compilation.assets 生成所有文件。

写一个增强console.log调试体验的webpack插件 simple-log-webpack-plugin

一张效果图先上为敬。(对照图片)只需写 log.a ,通过自己的webpack插件自动补全字段标识 a字段: ,加入 文件路径 ,轻松支持 打印颜色 效果,相同文件的日志信息 可折叠 ,给你一个简洁方便的调试环境。

以下为源码,欢迎测试反馈。github npm

const ColorHash = require('color-hash')
const colorHash = new ColorHash()

const Dependency = require('webpack/lib/Dependency');

class LogDependency extends Dependency {

  constructor(module) {
    super();
    this.module = module;
  }
}

LogDependency.Template = class {
  apply(dep, source) {

    const before = `;console.group('${source._source._name}');`
    const after = `;console.groupEnd();`
    const _size = source.size()

    source.insert(0, before)

    source.replace(_size, _size, after)

  }
};


module.exports = class LogPlugin {

  constructor (opts) {
    this.options = {
      expression: /\blog\.(\w+)\b/ig,
      ...opts
    }
    this.plugin = { name: 'LogPlugin' }
    
  }

  doLog (module) {

    if (!module._source) return
    let _val = module._source.source(),
        _name = module.resource;

    const filedColor = colorHash.hex(module._buildHash)

    // 判断是否需要加入
    if (this.options.expression.test(_val)) {
      module.addDependency(new LogDependency(module));
    }

    _val = _val.replace(
      this.options.expression,
      `console.log('%c$1字段:%c%o, %c%s', 'color: ${filedColor}', 'color: red', $1, 'color: pink', '${_name}')`
    )

    return _val

  }

  apply (compiler) {

    compiler.hooks.compilation.tap(this.plugin, (compilation) => {

      // 注册自定义依赖模板
      compilation.dependencyTemplates.set(
        LogDependency,
        new LogDependency.Template()
      );

      // modlue解析完毕钩子
      compilation.hooks.succeedModule.tap(this.plugin, module => {
        // 修改模块的代码
        module._source._value = this.doLog(module)
        
      })
    })

  }
}