Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

探寻 webpack 插件机制 #19

Closed
MuYunyun opened this issue Apr 8, 2018 · 2 comments
Closed

探寻 webpack 插件机制 #19

MuYunyun opened this issue Apr 8, 2018 · 2 comments

Comments

@MuYunyun
Copy link
Owner

MuYunyun commented Apr 8, 2018

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 官网

@MuYunyun MuYunyun changed the title webpack 优化篇 webpack 插件篇源码解读草稿 Apr 18, 2018
@MuYunyun
Copy link
Owner Author

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

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

@MuYunyun MuYunyun changed the title webpack 插件篇源码解读草稿 探寻 webpack 插件机制 Apr 18, 2018
@MuYunyun
Copy link
Owner Author

MuYunyun commented Apr 18, 2018

阅读 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);
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant