与 loader 相比,插件可以更加灵活的扩展 Webpack 的功能。借助于插件机制,我们可以访问到 Webpack 的编译器和编译过程,通过各种钩子,我们可以方便的处理各种定制逻辑。Webpack 本身就是一个插件的集合,这些插件都按照 tapableopen in new window 插件接口规范实现。

与 loader 不同,我们没有脱离于 Webpack 运行插件的方法,在插件的开发过程中,我们始终需要通过 Webpack 来运行插件。

Webpack 插件的基本定义

一个 Webpack 插件必须要定义一个 apply(compiler) 函数,同时在构造函数中接受一个 options 参数。我们可以通过 schema-utilsopen in new window 来对参数做一些校验。

当我们将插件集成到 Webpack 配置中后,Webpack 会调用插件的构造函数初始化插件,之后调用 apply(compiler) 函数。compiler 对象提供了 Webpack 的一些插件 API,提供也提供了非常多的钩子。这些钩子可以在官方文档open in new window 中查到。

设置插件的开发环境

为了更方便的开发插件,通常我们会在插件之上套一个壳子。在这个壳子中获取插件的输出,做一些断言等处理。我们可以借助于 memfsopen in new windowcompiler.outputFileSystem 来处理插件中的文件系统输出。

先安装 memfs.

npm add memfs --develop
1

现在实现一个壳子。

plugins/test.js

const webpack = require('webpack');
const { createFsFromVolume, Volume } = require('memfs');

// The compiler helper accepts filenames should be in the output
// so it's possible to assert the output easily.
function compile(config, filenames = []) {
  return new Promise((resolve, reject) => {
    const compiler = webpack(config);
    compiler.outputFileSystem = createFsFromVolume(new Volume());
    const memfs = compiler.outputFileSystem;

    compiler.run((err, stats) => {
      if (err) {
        return reject(err);
      }

      // Now only errors are captured from stats.
      // It's possible to capture more to assert.
      if (stats.hasErrors()) {
        return reject(stats.toString('errors-only'));
      }

      const ret = {};
      filenames.forEach((filename) => {
        // 假设 Webpack 输出在 ./dist.
        ret[filename] = memfs.readFileSync(`./dist/${filename}`, {
          encoding: 'utf-8',
        });
      });
      return resolve(ret);
    });
  });
}

async function test() {
  console.log(
    await compile({
      entry: './test-entry.js',
    })
  );
}

test();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

添加测试文件。

plugins/test-entry.js

console.log('hello from entry');
1

实现插件的功能

插件的核心要处理两件事情,一是提供一个 apply 函数,二是处理构造参数。

plugins/demo-plugin.js

module.exports = class DemoPlugin {
  apply() {
    console.log('applying');
  }
};
1
2
3
4
5

现在测试一下这个插件,我们修改一下 plugins/test.js 的代码。

plugins/test.js

...

const DemoPlugin = require("./demo-plugin");

...

async function test() {
  console.log(
    await compile({
      entry: "./test-entry.js",
      plugins: [new DemoPlugin()],
    })
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

这时候执行 node ./test.js,我们可以看到控制台输出了 applying 这条信息。

给插件传递参数

我们通过构造函数给插件传递参数。

plugins/demo-plugin.js

module.exports = class DemoPlugin {
  constructor(options) {
    this.options = options;
  }
  apply() {
    console.log('apply', this.options);
  }
};
1
2
3
4
5
6
7
8

添加一些参数。

plugins/test.js

async function test() {
  console.log(
    await compile({
      entry: './test-entry.js',
      plugins: [new DemoPlugin({ name: 'demo' })],
    })
  );
}
1
2
3
4
5
6
7
8

此时再执行 node ./test.js,可以看到 apply { name: 'demo' } 的输出。

编译器和编译信息

apply 接受一个 compiler 参数。

plugins/demo-plugin.js

module.exports = class DemoPlugin {
  constructor(options) {
    this.options = options;
  }
  apply(compiler) {
    console.log(compiler);
  }
};
1
2
3
4
5
6
7
8

通过 Webpack 的插件文档open in new window,我们可以看到编译器提供了很多钩子,每一个钩子都与一个编译阶段对应。比如我们需要输出文件,我们可以监听 emit 事件获取文件内容。

修改插件代码,监听 compilation 事件。

plugins/demo-plugin.js

module.exports = class DemoPlugin {
  constructor(options) {
    this.options = options;
  }
  apply(compiler) {
    compiler.hooks.thisCompilation.tap('DemoPlugin', (compilation) =>
      console.log(compilation)
    );
  }
};
1
2
3
4
5
6
7
8
9
10

此时执行 node ./test.js 我们可以看到输出了非常多的信息。这个编译信息里包含了 Webpack 整个的依赖图信息。我们可以通过 compilation 访问 entrychunkmodule 等。

通过编译信息输出文件

compilation 上的 assets 可以用来输出一些资源文件。我们需要借助于 webpack-sourcesopen in new window 这个文件抽象工具来实现。从 Webpack 5 开始,webpack-sources 已经被默认集成到了 webpack 中。

我们通过 webpack-sourcesRawSource 来输出文件。

plugins/demo-plugin.js

const { sources, Compilation } = require('webpack');

module.exports = class DemoPlugin {
  constructor(options) {
    this.options = options;
  }
  apply(compiler) {
    const pluginName = 'DemoPlugin';
    const { name } = this.options;

    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      compilation.hooks.processAssets.tap(
        {
          name: pluginName,
          // See lib/Compilation.js in webpack for more
          stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
        },
        () => compilation.emitAsset(name, new sources.RawSource('hello', true))
      );
    });
  }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

为了确保文件已经输出,我们调整下 test.js 的代码。

plugins/test.js

async function test() {
  console.log(
    await compile(
      {
        entry: './test-entry.js',
        plugins: [new DemoPlugin({ name: 'demo' })],
      },
      ['demo']
    )
  );
}
1
2
3
4
5
6
7
8
9
10
11

执行 node ./test.js,可以看到控制台输出 { demo: 'hello' }

处理 warning 和 error

我们可以通过 throw new Error("Message") 的方式来终止插件的运行。如果我们希望将告警信息或者错误信息提示给用户,我们可以通过 compilation.warningscompilation.errors 来实现。

compilation.warnings.push('warning');
compilation.errors.push('error');
1
2

Webpack 还提供了记录日志的 API。

const logger = compiler.getInfrastructureLogger('Demo Plugin');
logger.log('hello from compiler');
1
2

这个日志 API 与普通的 console.errorconsole.warning 等相似,可以通过官方的日志文档open in new window了解详细信息。

插件中还可以有插件

插件还可以提供自己的钩子函数。html-webpack-pluginopen in new window 就是一个很好的例子。

总结

插件可以截断 Webpack 的执行过程,做一些定制处理逻辑。虽然 loader 也可以实现类似的能力,但是插件比 loader 更加的灵活。

插件可以和 loader 结合使用,比如 MiniCssExtractPlugin

插件可以访问 Webpack 的 compilercompilation。这两个对象都提供了丰富的钩子函数以供调用。

插件可以输出新的资源文件,也可以将已有的资源文件处理后重新输出。

插件可以有自己的插件体系,HtmlWebpackPlugin 就是一个很好的例子。

关注微信公众号,获取最新推送~