Beace Lee

Beace Blog

Written by Beace Lee who lives and works in China building useful things. You should follow him on Twitter

解析webpack plugin的生命周期,书写自己的第一个plugin

January 18, 2018

引子

想要了解webpack plugin如何编写,首先要了解其应用场景和作用。

可以先浏览这三篇文章

how-to-write-a-plugin

compiler API

plugins API

除此之外,在这里我和webpack loader进行了简单的对比。

plugin & loader

plugin

顾名思义,webpack plugin是作为webpack的一个插件机制存在,将webpack提供的处理方法暴露给第三方(开发者)来开发。在整个项目架构中,往往起宏观上的作用。例如HtmlWebpackPlugin,修改一些文件,inject一些用户的资源,这些资源往往是经过loader处理过的资源,比如jsx文件,css文件。

loader

而loader用于对开发者源代码的转换,功能而言,跟webpack本身并没有强耦合的关系。例如,强大的babel-loader可以使用浏览器暂不支持的JavaScript语法(糖),css-loaderstyles-loader用来处理你的css

总之,Loader的这些工作不需要开发者去干涉,只需相应配置全权交个loader去处理。而plugin往往需要用户先预备好已经有的资源,再去对资源进行宏观上的操作,并不会在内容细节上处理。

场景的明确

我们需要明确一些plugin场景来进行实际开发的模拟。比如,抽离公共模块(CommonsChunkPlugin),控制模块的输出方式,或者输出内容(这里可能体现比较直观的是UglifyJsPlugin),复制一些为经过webpack处理的静态文件(copyWebpackPlugin)。

Compiler and Compilation

在了解生命周期之前,必须要了解Compiler and Compilation两个概念,我通常会翻译成编译器编译集合

Compiler(编译器)

翻译为编译器,是因为往往编译器在开发者的眼中是整个源代码所处的编译环境(预设环境),是一个静态场景。webpack通过Compiler提供了webpack配置内容的所有配置项和插件相关的调用函数,在这里,你可以随意获得你想要的某个配置,并且根据相应的配置书写相应的plugin代码逻辑。下面展示了compiler中用到的一些生命周期和有关webpack配置的代码。

_plugins: { 'before-run': [ [Function] ], done: [ [Function] ] },
options:
   { entry: './index.js',
     output:
      { path: '/Users/beace/Documents/beace/github/webpack/custom-plugins/first-plugin',
        filename: 'bundle.js',
        chunkFilename: '[id].bundle.js',
        library: '',
        hotUpdateFunction: 'webpackHotUpdate',
        jsonpFunction: 'webpackJsonp',
        libraryTarget: 'var',
        sourceMapFilename: '[file].map[query]',
        hotUpdateChunkFilename: '[id].[hash].hot-update.js',
        hotUpdateMainFilename: '[hash].hot-update.json',
        crossOriginLoading: false,
        chunkLoadTimeout: 120000,
        hashFunction: 'md5',
        hashDigest: 'hex',
        hashDigestLength: 20,
        devtoolLineToLine: false,
        strictModuleExceptionHandling: false },
     plugins: [ HelloWorldPlugin {}, MyPlugin {} ],
     context: '/Users/beace/Documents/beace/github/webpack/custom-plugins/first-plugin',
     devtool: false,
     cache: true,
     target: 'web',
     module:
      { unknownContextRequest: '.',
        unknownContextRegExp: false,
        unknownContextRecursive: true,
        unknownContextCritical: true,
        exprContextRequest: '.',
        exprContextRegExp: false,
        exprContextRecursive: true,
        exprContextCritical: true,
        wrappedContextRegExp: /.*/,
        wrappedContextRecursive: true,
        wrappedContextCritical: false,
        strictExportPresence: false,
        strictThisContextOnImports: false,
        unsafeCache: true },
     node:
      { console: false,
        process: true,
        global: true,
        Buffer: true,
        setImmediate: true,
        __filename: 'mock',
        __dirname: 'mock' },
     performance: { maxAssetSize: 250000, maxEntrypointSize: 250000, hints: false },
     resolve:
      { unsafeCache: true,
        modules: [Array],
        extensions: [Array],
        mainFiles: [Array],
        aliasFields: [Array],
        mainFields: [Array],
        cacheWithContext: false },
     resolveLoader:
      { unsafeCache: true,
        mainFields: [Array],
        extensions: [Array],
        mainFiles: [Array],
        cacheWithContext: false } },
  context: '/Users/beace/Documents/beace/github/webpack/custom-plugins/first-plugin',
}

Compilation(编译集合)

Compilation虽然继承自Compiler,但是对于本身作用来讲,因为他包含了chunks,modules,cache,assets,是动态的资源集合。动态的原因是,在某个编译阶段,产生的编译资源是不相同的。

编译会显示有关模块资源,编译资源,更改的文件以及监视的依赖项当前状态的信息。编译还提供了许多插件可以选择执行自定义操作的回调点。

每一个版本执行的编辑逻辑(开发者),决定了上述特点。下面选取了部分关于chunks和assets中的内容

chunks:
   [ Chunk {
       id: 0,
       ids: [Array],
       debugId: 1000,
       name: 'main',
       _modules: [SortableSet],
       entrypoints: [Array],
       chunks: [],
       parents: [],
       blocks: [],
       origins: [Array],
       files: [Array],
       rendered: true,
       entryModule: [NormalModule],
       hash: 'bfe5f97a4642c50a5286f6a28486186a',
       renderedHash: 'bfe5f97a4642c50a5286' } ],

{ 'bundle.js':
   CachedSource {
     _source: ConcatSource { children: [Array] },
     _cachedSource: undefined,
     _cachedSize: undefined,
     _cachedMaps: {},
     node: [Function],
     listMap: [Function] } }

生命周期

简历一个简单的项目

通过以下简单的配置,我将一个index.js简单的进行webpack打包,输出bundle.js。并在根目录下创建my-plugin.js文件,作为即将开发的插件。代码如下。

const path = require('path');
const webpack = require('webpack');
const MyPlugin = require('./my-plugin');

module.exports = {
  entry: './index.js',
  output: {
    path: path.resolve(__dirname),
    filename: 'bundle.js',
  },
  plugins: [
    new MyPlugin({ options: true }),
  ]
}

根据webpack的要求,插件必须要在其原型上创建apply对象。

因为当webpack命令执行时,插件将被创建,而webpack将通过调用apply来安装插件,并将引用传递给webpack编译对象。

反观表现,通过创建apply对象以及apply的参数,可以调用webpack底层的方法。在my-plugin.js中写入

function MyPlugin(options) {}

MyPlugin.prototype.apply = function(compiler) {}

从执行顺序看生命周期

如果非常粗暴的将plugin的几个关键的生命周期输出出来,执行顺序是将会是这样的

	// 1
  compiler.plugin("compile", function(params) {
    console.log("The compile is starting to compile...", params);
  });
  // 2
  compiler.plugin("compilation", function(compilation, params) {
    console.log("The compile is starting a new compilation...");
    // 4
    compilation.plugin("optimize", function() {
      console.log("The compilation is starting to optimize file...");
    });
  });
  // 3
  compiler.plugin("make", function(compiler, callback){
    console.log("the compile is making file...");
    callback();
  });
  // 5
  compiler.plugin("after-compile", function(compilation) {
    console.log("The compile has aleardy compiled");
  });
	// 6
	compiler.plugin("emit", function(compilation, callback) {
    console.log("The compilation is going to emit files...");
    callback();
  });
	// 7
	compiler.plugin('after-emit', function(compilation) {
    console.log('The compliation has aleardy emitted');
  })

代码的注释,代表了执行的顺序,可以看下命令行中的输出

webpack

从上述代码的执行顺序来看,plugin的生命周期如下:

  1. Compile 开始进入编译环境,开始编译
  2. Compilation 即将产生第一个版本
  3. make任务开始
  4. optimize作为Compilation的回调方法,优化编译,在Compilation回调函数中可以为每一个新的编译绑定回调。
  5. after-compile编译完成
  6. emit准备生成文件,开始释放生成的资源,最后一次添加资源到资源集合的机会
  7. after-emit文件生成之后,编译器释放资源

从源码中看生命周期

咦,好像漏了两条,当编译完成时,可以看到命令行里面并没有文件的输出,回去查看项目中的代码,也并没有bundle.js文件。6、7步到底执行了么?

答案当然是没有执行。因为没有看到资源释放的结果。

让我们在源码中一探究竟。找到Compile所在的源码。

compile(callback) {
		const params = this.newCompilationParams();
		this.applyPluginsAsync("before-compile", params, err => {
			if(err) return callback(err);
			// 1
			this.applyPlugins("compile", params);
			// 2
			const compilation = this.newCompilation(params);
			// 3
			this.applyPluginsParallel("make", compilation, err => {
				if(err) return callback(err);

				compilation.finish();
				// 4
				compilation.seal(err => {
					if(err) return callback(err);
					// 5
					this.applyPluginsAsync("after-compile", compilation, err => {
						if(err) return callback(err);

						return callback(null, compilation);
					});
				});
			});
		});
	}

很明显,当编译完成时,webpack Seal 资源完毕后直接将callback return,所以当我们在调用after-compile没有进行任何处理,阻止了接下来的return。将my-plugin.js中的代码注释掉after-compile这一步骤或者添加新的参数callback并执行。

// my-plugin.js
compiler.plugin("after-compile", function(compilation, callback) {
    console.log("The compile has aleardy compiled");
    callback();
  });

这时再运行webpack,命令行中可以看到输出了The compilation is going to emit files,并且输出了bundle.js

webpack

编写自己的插件

上面截图可以看到,Hash上面的一行输出All compilers have done.,其实这也是在webpack plugin的生命周期的范围,done是所有工作结束后,会执行的最后一个步骤。并且,当webpack plugin watch到某个过程出错的时候,也会执行done。如以下源代码,可以看到每次执行错误之后,都会走done 流程。

this.compiler.applyPluginsAsync("watch-run", this, err => {
			if(err) return this._done(err);
			const onCompiled = (err, compilation) => {
				if(err) return this._done(err);
				if(this.invalid) return this._done();

				if(this.compiler.applyPluginsBailResult("should-emit", compilation) === false) {
					return this._done(null, compilation);
				}

				this.compiler.emitAssets(compilation, err => {
					if(err) return this._done(err);
					if(this.invalid) return this._done();

					this.compiler.emitRecords(err => {
						if(err) return this._done(err);

						if(compilation.applyPluginsBailResult("need-additional-pass")) {
							compilation.needAdditionalPass = true;

							const stats = new Stats(compilation);
							stats.startTime = this.startTime;
							stats.endTime = Date.now();
							this.compiler.applyPlugins("done", stats);

							this.compiler.applyPluginsAsync("additional-pass", err => {
								if(err) return this._done(err);
								this.compiler.compile(onCompiled);
							});
							return;
						}
						return this._done(null, compilation);
					});
				});
			};
			this.compiler.compile(onCompiled);
		});

因此,为了简单而言,我们此次编写的插件也是基于done来进行。

编写plugin

接下来将要编写一个在生成bundle.js文件之后,在第一行添加时间注释,在最后一行添加自己姓名注释,并重新输出bundle.js

compiler.plugin("done", function(stats) {
    console.log('All compilers have done.');
    const fileData = fs.readFileSync(path.join(path.resolve(__dirname), 'bundle.js'), {encoding: 'utf-8'});
    console.log(fileData);
    const prefix = '/*2018*/';
    const author = '/* ——By Beace Lee */';
    const finalFileData = `${prefix}\n${fileData}\n${author}`;
    fs.writeFileSync(
      path.join(path.resolve(__dirname), 'bundle.js'),
      finalFileData
    );
  })

通过以上代码可以看出,在done这个步骤中,通过读取emitbundle.js文件(因为这个时候资源已经释放,可以直接使用资源),以utf-8的格式读取,读取完毕后在整个字符串的前后添加两行注释并换行,再写到最终文件里。

// bundle.js
/*2018*/
...
/* 0 */
/***/ (function(module, exports) {
...
console.log('this is a entry js file');
...
/***/ })
/* ——By Beace Lee */

总结

此种方式,其实是调用了node的fs的API去实现,看起来除了生命周期之外,并没有和webpack plugin有什么太大关系,我们其实是操作了文件,当有大量文件存在的时候,该插件显得捉襟见肘。

除此之外,前面说过可以操作compiler的assets集合, 暂时写到这里,下回再聊。