MuYunyun/blog

探寻 webpack 插件机制

MuYunyun opened this issue · 2 comments

webpack 可谓是让人欣喜又让人忧,功能强大但需要一定的学习成本。在探寻 webpack 插件机制前,首先需要了解一件有意思的事情,webpack 插件机制是整个 webpack 工具的骨架,而 webpack 本身也是利用这套插件机制构建出来的。因此在深入认识 webpack 插件机制后,再来进行项目的相关优化,想必会大有裨益。

webpack 插件

先来瞅瞅 webpack 插件在项目中的运用

const MyPlugin = require('myplugin')
const webpack = require('webpack')

webpack({
  ...,
  plugins: [new MyPlugin()]
  ...,
})

那么符合什么样的条件能作为 webpack 插件呢?一般来说,webpack 插件有以下特点:

  1. 独立的 JS 模块,暴露相应的函数

  2. 函数原型上的 apply 方法会注入 compiler 对象

  3. compiler 对象上挂载了相应的 webpack 事件钩子

  4. 事件钩子的回调函数里能拿到编译后的 compilation 对象,如果是异步钩子还能拿到相应的 callback

下面结合代码来看看:

function MyPlugin(options) {}
// 2.函数原型上的 apply 方法会注入 compiler 对象
MyPlugin.prototype.apply = function(compiler) {
  // 3.compiler 对象上挂载了相应的 webpack 事件钩子 4.事件钩子的回调函数里能拿到编译后的 compilation 对象
  compiler.plugin('emit', (compilation, callback) => {
    ...
  })
}
// 1.独立的 JS 模块,暴露相应的函数
module.exports = MyPlugin

这样子,webpack 插件的基本轮廓就勾勒出来了,此时疑问点有几点,

  1. 疑问 1:函数的原型上为什么要定义 apply 方法?阅读源码后发现源码中是通过 plugin.apply() 调用插件的。
const webpack = (options, callback) => {
  ...
  for (const plugin of options.plugins) {
    plugin.apply(compiler);
  }
  ...
}
  1. 疑问 2:compiler 对象是什么呢?

  2. 疑问 3:compiler 对象上的事件钩子是怎样的?

  3. 疑问 4:事件钩子的回调函数里能拿到的 compilation 对象又是什么呢?

这些疑问也是本文的线索,让我们一个个探索。

compiler 对象

compiler 即 webpack 的编辑器对象,在调用 webpack 时,会自动初始化 compiler 对象,源码如下:

// webpack/lib/webpack.js
const Compiler = require("./Compiler")

const webpack = (options, callback) => {
  ...
  options = new WebpackOptionsDefaulter().process(options) // 初始化 webpack 各配置参数
  let compiler = new Compiler(options.context)             // 初始化 compiler 对象,这里 options.context 为 process.cwd()
  compiler.options = options                               // 往 compiler 添加初始化参数
  new NodeEnvironmentPlugin().apply(compiler)              // 往 compiler 添加 Node 环境相关方法
  for (const plugin of options.plugins) {
    plugin.apply(compiler);
  }
  ...
}

终上,compiler 对象中包含了所有 webpack 可配置的内容,开发插件时,我们可以从 compiler 对象中拿到所有和 webpack 主环境相关的内容。

compilation 对象

compilation 对象代表了一次单一的版本构建和生成资源。当运行 webpack 时,每当检测到一个文件变化,一次新的编译将被创建,从而生成一组新的编译资源。一个编译对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。

结合源码来理解下上面这段话,首先 webpack 在每次执行时会调用 compiler.run() (源码位置),接着追踪 onCompiled 函数传入的 compilation 参数,可以发现 compilation 来自构造函数 Compilation。

// webpack/lib/Compiler.js
const Compilation = require("./Compilation");

newCompilation(params) {
  const compilation = new Compilation(this);
  ...
  return compilation;
}

不得不提的 tapable 库

再介绍完 compiler 对象和 compilation 对象后,不得不提的是 tapable 这个库,这个库暴露了所有和事件相关的 pub/sub 的方法。而且函数 Compiler 以及函数 Compilation 都继承自 Tapable。

事件钩子

事件钩子其实就是类似 MVVM 框架的生命周期函数,在特定阶段能做特殊的逻辑处理。了解一些常见的事件钩子是写 webpack 插件的前置条件,下面列举些常见的事件钩子以及作用:

钩子 作用 参数 类型
after-plugins 设置完一组初始化插件之后 compiler sync
after-resolvers 设置完 resolvers 之后 compiler sync
run 在读取记录之前 compiler async
compile 在创建新 compilation 之前 compilationParams sync
compilation compilation 创建完成 compilation sync
emit 在生成资源并输出到目录之前 compilation async
after-emit 在生成资源并输出到目录之后 compilation async
done 完成编译 stats sync

完整地请参阅官方文档手册,同时浏览相关源码 也能比较清晰地看到各个事件钩子的定义。

插件流程浅析

拿 emit 钩子为例,下面分析下插件调用源码:

compiler.plugin('emit', (compilation, callback) => {
  // 在生成资源并输出到目录之前完成某些逻辑
})

此处调用的 plugin 函数源自上文提到的 tapable 库,其最终调用栈指向了 hook.tapAsync(),其作用类似于 EventEmitter 的 on,源码如下:

// Tapable.js
options => {
  ...
  if(hook !== undefined) {
    const tapOpt = {
      name: options.fn.name || "unnamed compat plugin",
      stage: options.stage || 0
    };
    if(options.async)
      hook.tapAsync(tapOpt, options.fn); // 将插件中异步钩子的回调函数注入
    else
      hook.tap(tapOpt, options.fn);
    return true;
  }
};

有注入必有触发的地方,源码中通过 callAsync 方法触发之前注入的异步事件,callAsync 类似 EventEmitter 的 emit,相关源码如下:

this.hooks.emit.callAsync(compilation, err => {
	if (err) return callback(err);
	outputPath = compilation.getPath(this.outputPath);
	this.outputFileSystem.mkdirp(outputPath, emitFiles);
});

一些深入细节这里就不展开了,说下关于阅读比较大型项目的源码的两点体会,

  • 要抓住一条主线索去读,忽视细节。否则会浪费很多时间而且会有挫败感;

  • 结合调试工具来分析,很多点不用调试工具的话很容易顾此失彼;

动手实现个 webpack 插件

结合上述知识点的分析,不难写出自己的 webpack 插件,关键在于想法。为了统计项目中 webpack 各包的有效使用情况,在 fork webpack-visualizer 的基础上对代码升级了一番,项目地址。效果如下:

插件核心代码正是基于上文提到的 emit 钩子,以及 compiler 和 compilation 对象。代码如下:

class AnalyzeWebpackPlugin {
  constructor(opts = { filename: 'analyze.html' }) {
    this.opts = opts
  }

  apply(compiler) {
    const self = this
    compiler.plugin("emit", function (compilation, callback) {
      let stats = compilation.getStats().toJson({ chunkModules: true }) // 获取各个模块的状态
      let stringifiedStats = JSON.stringify(stats)
      // 服务端渲染
      let html = `<!doctype html>
          <meta charset="UTF-8">
          <title>AnalyzeWebpackPlugin</title>
          <style>${cssString}</style>
          <div id="App"></div>
          <script>window.stats = ${stringifiedStats};</script>
          <script>${jsString}</script>
      `
      compilation.assets[`${self.opts.filename}`] = { // 生成文件路径
        source: () => html,
        size: () => html.length
      }
      callback()
    })
  }
}

参考资料

看清楚真正的 Webpack 插件

webpack 官网

new webpack.IgnorePlugin(/^./locale$/, /moment$/), // 实验减少了 47.68 kb

https://juejin.im/post/5aba35246fb9a028c812e1f0

阅读 webpack 相关源码所留下的草稿记录

webpack(webpackConfig) => compiler = new Compiler() => 
 this.hooks = {emit: new AsyncSeriesHook(["compilation"])} =>

compiler.plugin('emit', function(compilation, callback) {
}) // plugin.js

=>

Tapable.prototype.plugin = function plugin(name, fn) {
   const result = this._pluginCompat.call({
      name: name,
      fn: fn,
      names: new Set([name])
   }) 
} // Tapable.js

=>

const lazyCompileHook = (...args) => {
	this[name] = this._createCall(type);
	return this[name](...args); // { name: "emit", fn: fn, names: Set(1) }
};
return lazyCompileHook; // Hook.js

=> 

_createCall(type) {
	return this.compile({
		taps: this.taps,
		interceptors: this.interceptors,
		args: this._args,
		type: type
	});
} // Hook.js

=>

compile(options) {
	factory.setup(this, options);
	return factory.create(options);
} // SyncBailHook.js

=>

setup(instance, options) {
	instance._x = options.taps.map(t => t.fn); // 将 taps 的方法赋值到 instance._x 上
} // HoocCodeFactory.js

create(options) {
	this.init(options);
	switch(this.options.type) {
		case "sync":
			return new Function('options', "\"use strict\";\n" + this.header() + this.content({
				onError: err => `throw ${err};\n`,
				onResult: result => `return ${result};\n`,
				onDone: () => "",
				rethrowIfPossible: true
			}));
	}
} // HoocCodeFactory.js

// 生成的函数如下:
function (options) {
	var _context;
	var _x = this._x;
	var _fn0 = _x[0];
	var _result0 = _fn0(options);
	if(_result0 !== undefined) {
	return _result0;
	;
	} else {
	var _fn1 = _x[1];
	var _result1 = _fn1(options);
	if(_result1 !== undefined) {
	return _result1;
	;
	} else {
	var _fn2 = _x[2];
	var _result2 = _fn2(options);
	if(_result2 !== undefined) {  // true !== undefined
	return _result2;
	;
	} else {}}}
}

// async 函数
function anonymous(compilation, _callback) {
	"use strict";
	var _context;
	var _x = this._x;
	var _fn0 = _x[0];
	_fn0(compilation, _err0 => {
		if(_err0) {
		_callback(_err0);
		} else {
		_callback();
		}
	});
}

// _x 怎么来的
=> options.taps.map(t => t.fn) // HoocCodeFactory.js

_insert(item) {
	this._resetCompilation();
	let before;
	if(typeof item.before === "string")
		before = new Set([item.before]);
	else if(Array.isArray(item.before)) {
		before = new Set(item.before);
	}
	let stage = 0;
	if(typeof item.stage === "number")
		stage = item.stage;
	let i = this.taps.length;
	while(i > 0) {
		i--;
		const x = this.taps[i];
		this.taps[i+1] = x;
		const xStage = x.stage || 0;
		if(before) {
			if(before.has(x.name)) {
				before.delete(x.name);
				continue;
			}
			if(before.size > 0) {
				continue;
			}
		}
		if(xStage > stage) {
			continue;
		}
		i++;
		break;
	}
	this.taps[i] = item;
} // Hook.js

=>

// _x[0]
options => {
	if(/^before-/.test(options.name)) {
		options.name = options.name.substr(7);
		options.stage = -10;
	} else if(/^after-/.test(options.name)) {
		options.name = options.name.substr(6);
		options.stage = 10;
	}
}

=> // Tapable.js 订阅 compilation 的事件

// _x[2]
this._pluginCompat.tap({
	name: "Tapable this.hooks",
	stage: 200
}, options => {
	let hook;
	for(const name of options.names) {
		hook = this.hooks[name];
		if(hook !== undefined) {
			break;
		}
	}
	if(hook !== undefined) {
		const tapOpt = {
			name: options.fn.name || "unnamed compat plugin",
			stage: options.stage || 0
		};
		if(options.async)
			hook.tapAsync(tapOpt, options.fn);
		else
			hook.tap(tapOpt, options.fn);
		return true;
	}
});

=> 触发在什么时候?

emitAssets(compilation, callback) {
	...
	this.hooks.emit.callAsync(compilation, err => {
		if (err) return callback(err);
		outputPath = compilation.getPath(this.outputPath);
		this.outputFileSystem.mkdirp(outputPath, emitFiles);
	}); // Compiler.js
}

=>

run(callback) {
	...
	const onCompiled = (err, compilation) => {
		...
		this.emitAssets(compilation, err => {
			if (err) return finalCallback(err);

			if (compilation.hooks.needAdditionalPass.call()) {
				compilation.needAdditionalPass = true;

				const stats = new Stats(compilation);
				stats.startTime = startTime;
				stats.endTime = Date.now();
				this.hooks.done.callAsync(stats, err => {
					if (err) return finalCallback(err);

					this.hooks.additionalPass.callAsync(err => {
						if (err) return finalCallback(err);
						this.compile(onCompiled);
					});
				});
				return;
			}

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

				const stats = new Stats(compilation);
				stats.startTime = startTime;
				stats.endTime = Date.now();
				this.hooks.done.callAsync(stats, err => {
					if (err) return finalCallback(err);
					return finalCallback(null, stats);
				});
			});
		});
	};
} // Compiler.js

=> 

if (callback) {
	if (typeof callback !== "function")
		throw new Error("Invalid argument: callback");
	if (
		options.watch === true ||
		(Array.isArray(options) && options.some(o => o.watch))
	) {
		const watchOptions = Array.isArray(options)
			? options.map(o => o.watchOptions || {})
			: options.watchOptions || {};
		return compiler.watch(watchOptions, callback);
	}
	compiler.run(callback); // webpack.js
}

// 如何把 plugin 和 emit 有关的回调和下面代码串联起来
this.hooks.emit.callAsync(compilation, err => {
	if (err) return callback(err);
	outputPath = compilation.getPath(this.outputPath);
	this.outputFileSystem.mkdirp(outputPath, emitFiles);
});