webpack 之 Compiler 、Compilation 和 Tapable

官方解释:【Compiler and Compilation】以及 【Writing a Plugin】,翻译并展开如下:

开发插件时最重要的两个概念是 compiler 编译器和 compilation 编译对象。理解它们的角色是扩展 webpack 引擎重要的第一步。

一个 webpack 插件需要具备:

  • 是一个JavaScript命名函数或者JavaScript类
  • 定义一个名为 apply 的原型方法(插件最核心的部分
  • 指定要钩入/监听的事件 hook
  • 处理webpack内部实例特定的数据
  • 功能完成后,调用webpack提供的回调

传入的 compiler 是 webpack 初始化时创建的 Compiler 实例,它上面的 hooks 属性,用于将插件注册到 compiler 生命周期中的各种钩子事件上。

// 一个插件类基本结构示例:
class HelloCompilationPlugin {
  constructor(options = {}) {
    // 省略构造器部分
  }
  // 调用原型方法 apply 并传入 compiler 对象
  apply(compiler) {
    // 给 compilation 钩子注册 'HelloCompilationPlugin',回调会在 compilation 对象创建之后触发
    compiler.hooks.compilation.tap('HelloCompilationPlugin', (compilation) => {
      // 回调参数是 compilation 对象,因此这里可以使用各种可用的 compilation hooks(钩子)
      compilation.hooks.optimize.tap('HelloCompilationPlugin', () => {
        // 优化阶段开始时触发
        console.log('资源正在优化');
      });
    });
    // 在 emit 阶段,即输出 asset 到 output 目录之前触发回调函数
    compiler.hooks.emit.tapAsync(
      'HelloCompilationPlugin',
      (compilation, callback) => {
        console.log('This is an example plugin!');
        console.log(
          '`compilation`对象,即资源的一个单一版本的构建',
          compilation
        );
        // 使用webpack提供的 plugin API 操作构建
        compilation.addModule(/* ... */);
        callback();
      }
    );
  }
}

module.exports = HelloCompilationPlugin;

在 webpack 中以new XxxPlugin的方式配置插件,用于在 Compiler 实例对象上注册

// webpack.config.js
const HelloCompilationPlugin = require('./HelloCompilationPlugin.js');

module.exports = {
  plugins: [
    //... 其他插件
    // 创建插件实例
    new HelloCompilationPlugin({...options})
  ]
}

Compiler 模块是 webpack 的主要引擎,它扩展自 Tapable 类。在执行 webpack 构建的准备阶段,会创建一个Compiler的实例,然后配置项传递的实例化插件以及webpack内置插件都会在该 compiler 对象上注册。具体就是依次调用插件的 apply 方法,并将 compiler 对象 (包含webpack的各种配置信息) 传进去供 plugin 使用,compiler 包含整个构建流程的全部钩子,通过它可以把控整个 webpack 构建周期。在运行期间 compiler 会根据 webpack 不同阶段触发的各种事件钩子,执行插件附加/绑定在 hook 上的函数。 compiler 只是负责维持生命周期运行的功能,所有的加载、打包和写入工作,都被委托到注册过的插件上了。webpack 使用 WebpackOptionsDefaulterWebpackOptionsApply 来配置 Compiler 实例以及所有内置插件。
Compiler 类实例化并注册 plugins 后,若 webpack 函数接收了回调callback,会执行compiler.run()方法,webpack即刻开启编译之旅。如果未指定callback回调,则需要用户自己调用run方法来启动编译。

上面说的 Compiler 实例化、插件注册及启动编译 (run or watch方法),详见下面/lib/webpack.js源码[简化版]

// /lib/webpack.js
const webpack = (options, callback) => {
// 创建 Compiler 类的实例
  const compiler = new Compiler(options.context);
  compiler.options = options;
  // 注册所有自定义插件
  if (Array.isArray(options.plugins)) {
    // 遍历传入的 webpack 配置中的实例化插件数组
    for (const plugin of options.plugins) {
      if (typeof plugin === "function") {
        // 在compiler对象的作用域下调用plugin构造函数,即this指向compiler;同时把compiler对象当作参数传过去。并且compiler对象会继承plugin的所有属性、方法
        plugin.call(compiler, compiler);
      } else {
        // 如果 plugin 是其他类型,就执行plugin对象的apply方法。
        // plugins 数组的内容一般都是一个个插件实例化对象,也就是 object。
        // 
        plugin.apply(compiler);
      }
     }
  }
  applyWebpackOptionsDefaults(options);
  // 触发 compiler 的 两个 hook: environment,afterEnvironment
  compiler.hooks.environment.call();
  compiler.hooks.afterEnvironment.call();
  // 根据 options 的配置不同,注册激活一些默认自带的插件和 resolverFactory.hooks
  // 大部分插件的作用是往 compiler.hooks:compilation,thisCompilation 里注册一些事件
  new WebpackOptionsApply().process(options, compiler);
  compiler.hooks.initialize.call();
  // 获取是否以watch监听模式启动的 webpack 以及 监听相关配置
  let watch = options.watch || false;
  let watchOptions = options.watchOptions || {};
  if (callback) { // 如果传递了回调
    if (watch) { // 配置传了 watch 则调用监听模式启动 webpack
      compiler.watch(watchOptions, callback);
    } else { // 启动 compiler.run,即开启编译工作, webpack 的核心构建流程
      compiler.run((err, stats) => { 
      // stats 对象是编译过程中的有用信息, 包括:
      //*   错误和警告(如果有的话)
      //*   计时信息
      //*   module 和 chunk 信息
      // webpack CLI 正是基于这些信息在控制台 展示友好的格式输出。
        compiler.close(err2 => {
          callback(err || err2, stats);
        });
      });
    }
    return compiler;
  } else {
    if (watch) {
      util.deprecate(() => {}, "watch模式必须提供callback回调函数!", "DEP_WEBPACK_WATCH_WITHOUT_CALLBACK")();
    }
    return compiler;
  }
}

module.exports = webpack;

Tapable 是什么?GitHub文档
它是一个管理钩子事件监听与触发的小型库,export 了许多Hook类(hook 构造函数),如 SyncHook、SyncBailHook 等,可以用来为插件创建 hooks。除此之外,还暴露了 tap,tapAsynctapPromise 这些绑定 hook 的方法(除此之外还有 intercept -钩子拦截器),用来向 webpack 注入自定义构建的步骤。整体类似发布订阅模式

tap: (name: string | Tap, fn: (context?, ...args) => Result) => void
tapAsync: (name: string | Tap, fn: (context?, ...args, callback: (err, result: Result) => void) => void) => void
tapPromise: (name: string | Tap, fn: (context?, ...args) => Promise<Result>) => void
第一个参数 name 或 Tap 对象:name是绑定事件名,通常用来识别插件,因此一般就传插件名称;Tab对象可以添加一些具体的信息。
第二个参数是回调函数,即该事件被触发时,需要做的事情。回调的 context参数是可选值,表示上下文对象,且需要第一个参数传Tab对象:{ name: 'myPluginxxx', context: true, ... }
整体和事件侦听器的机制类似。

其中Tab参数的接口:

interface Tap {
    name: string,
    type: string
    fn: Function,
    stage: number,
    context: boolean,
    before?: string | Array
}

同步 hook 只能使用 tap 方法;而异步 hook 除了 tapAsync 和 tapPromise 这些异步方法,也支持用 tap 方法让 hook 以同步方式运行。

当使用 tapAsync method来访问插件时,需要调用作为函数的最后一个参数提供的回调函数。

class HelloAsyncPlugin {
    compiler.hooks.emit.tapAsync(
      'HelloAsyncPlugin',
      (compilation, callback) => {
        // Do something async...
        console.log('以异步方式触及运行钩子');
        setTimeout(function () {
          console.log('Done with async work...');
          callback();
        }, 1000);
      }
    );
  }
}

module.exports = HelloAsyncPlugin;

当我们使用 tapPromise method来访问插件时,则需要返回一个promise,它可以在异步任务完成时解决。

class HelloAsyncPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapPromise('HelloAsyncPlugin', (compilation) => {
      // return a Promise that resolves when we are done...
      return new Promise((resolve, reject) => {
        setTimeout(function () {
          console.log('以异步的方式触发具有延迟操作的钩子。');
          resolve();
        }, 1000);
      });
    });
    // 在回调用上 async await
    compiler.hooks.run.tapPromise('MyPlugin', async (source, target, routesList) => {
        await new Promise((resolve) => setTimeout(resolve, 1000));
        console.log('以异步的方式触发具有延迟操作的钩子。');
      }
    );
  }
}

module.exports = HelloAsyncPlugin;

我们可以有多种方式 hook 到 compiler 中,hook将编译一个方法,可以让各种插件都以最合适有效的方式去运行。

创建钩子的方法:const hook = new SyncHook(["arg1", "arg2", "arg3"])
在类的 constructor 的 this.hooks 内用生成 Hook 类实例的方式定义 hooks,表明它们的类型并传入参数名称数组;
在 webpack 运行特定阶段(比如 compiler.run 和 compile)通过callcallAsyncpromise调用这些 hook,声明注册在这个 hook 上的插件 (内的方法) 触发的时机。类实似于发布-订阅模式中的发布事件
插件通过taptapAsync等方法订阅 Compiler 或 Compilation 实例的 hook,方法内可以调用 api 进行一些处理。webpack 在运行过程中,当这些特定的 hook 事件被广播时,订阅了该 hook 的插件在监听到后就会执行绑定的逻辑。
掌握 webpack 流程中 compiler、compilation 对象事件钩子触发的时机,就是掌握如何写一个插件的关键。

具体让我们看Compiler类源码:

// /lib/Compiler.js
const {
    SyncHook,
    SyncBailHook,
    AsyncParallelHook,
    AsyncSeriesHook
} = require("tapable");

class Compiler {
  constructor(context) {
    // 定义一堆hook,done,beforeRun,run,emit等等
    this.hooks = Object.freeze({
      /** @type {SyncBailHook<[Compilation], boolean>} */       
      run: new AsyncSeriesHook(["compiler"]), // 在开始读取records之前调用
      /** @type {SyncHook<[Compilation, CompilationParams]>} */
      thisCompilation: new SyncHook(["compilation", "params"]), // 初始化 compilation 时调用,在触发 compilation 事件之前调用
      /** @type {AsyncSeriesHook<[Compilation]>} */
      emit: new AsyncSeriesHook(["compilation"]), // 输出 asset 到 output 目录之前执行
      /** @type {AsyncSeriesHook<[Compilation]>} */
      afterEmit: new AsyncSeriesHook(["compilation"]), // 输出 asset 到 output 目录之后执行
      /** @type {AsyncSeriesHook<[Stats]>} */
      done: new AsyncSeriesHook(["stats"]), // 在 compilation 完成时执行
    })
  }
  watch(watchOptions, handler) {} // 以监听模式执行 webpack 打包的方法
  run(callback) { // run 即为执行 webpack 打包的主流程函数
    const onCompiled = (err, compilation) => {})
    const run = () => { 
      this.hooks.beforeRun.callAsync(this, err => {
        this.hooks.run.callAsync(this, err => {
          if (err) return finalCallback(err);
          this.readRecords(err => { // 读取之前的 records
            if (err) return finalCallback(err);
            this.compile(onCompiled); //  在 compile 过程后调用 onCompiled,主要用于输出构建资源
          });
        });
      });
    };
  } 
  compile(callback) { // compile 是真正进行编译的方法,最终会把所有原始资源编译为目标资源。
    const params = this.newCompilationParams();
    this.hooks.beforeCompile.callAsync(params, err => {
      if (err) return callback(err);
      this.hooks.compile.call(params);
      // createCompilation方法主要就是清除之前的compilation,重新实例化一个Compilation
      const compilation = this.createCompilation();
      compilation.name = this.name;
      compilation.records = this.records;
      // 触发compiler.hooks:thisCompilation 和 compilation
      // 注册plugins阶段在这两个钩子注册的事件在拿到compilation对象后开始执行
      this.hooks.thisCompilation.call(compilation, params);
      this.hooks.compilation.call(compilation, params);
      return compilation;
    }
  }
}

Compiler 有几个重要的方法,watchrun是启动/执行 webpack 构建的函数,而compile是负责编译的。 它触发 compile 钩子并实例化了一个 compilation,再触发自己的 make 钩子把 compilation 对象作为参数传过去。在后续编译过程中会触发 Compilation 的一系列海量 hooks:如buildModulesucceedModulesucceedEntrysealchunk,实现模块的加载、封闭、优化、分块,哈希和重建等等,并将当前模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态等相关信息保存到 compilation 对象。Compilation.js 类的源码很长,具体编译流程也会涉及到 webpack 的许许多多其他模块,就不在这篇说了。

compiler 钩子触发时机和 webpack 执行流程对照

总结:

  • Compiler类(./lib/Compiler.js):webpack的主要引擎,扩展自Tapable。webpack 从执行到结束,Compiler只会实例化一次。生成的 compiler 对象记录了 webpack 当前运行环境的完整的信息,该对象是全局唯一的,插件可以通过它获取到 webpack config 信息,如entry、output、loaders等配置。

  • Compilation类(./lib/Compilation.js):扩展自Tapable,也提供了很多关键点回调供插件做自定义处理时选择使用拓展。一个 compilation 对象代表了一次单一的版本构建和生成资源,它储存了当前的模块资源、编译生成的资源、变化的文件、以及被跟踪依赖的状态信息。简单来说,Compilation的职责就是对所有 require 图(graph)中对象的字面上的编译,构建 module 和 chunk,并利用插件优化构建过程,同时把本次打包编译的内容全存到内存里。compilation 编译可以多次执行,如在watch模式下启动 webpack,每次监测到源文件发生变化,都会重新实例化一个compilation对象,从而生成一组新的编译资源。这个对象可以访问所有的模块和它们的依赖(大部分是循环依赖)。

Compiler 和 Compilation 的区别

compiler 对象代表的是构建过程中不变的 webpack 环境,整个 webpack 从启动到关闭的生命周期。针对的是webpack。
compilation 对象只代表一次新的编译,只要项目文件有改动,compilation 就会被重新创建。针对的是随时可变的项目文件。

关于 compiler hookscompilation hooks 以及其他重要对象上可用的 hooks 列表,请参阅 plugins API 文档。
参考:webpack插件编写及原理解析

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,904评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,581评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,527评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,463评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,546评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,572评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,582评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,330评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,776评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,087评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,257评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,923评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,571评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,192评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,436评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,145评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,127评论 2 352

推荐阅读更多精彩内容