阅读 1664

webpack 源码探索之插件机制

最近在一直在为面试做准备,搜了很多大佬记录的面试经验和面试内容,对自己不太熟悉和已经记忆模糊的知识点内容进行复习巩固,争取能够有一个好的状态。这篇文章以我的经验讲述了我是如何从源码的角度了解到 webpack 插件机制,也简单描述了 webpack 编译构建的机制。

使用 vscode 调试功能,运行项目打包程序,一步一步走 webpack(version: 3.10.0)执行代码。

先来看看 webpack 函数源码

function webpack(options, callback) {
	const webpackOptionsValidationErrors = validateSchema(webpackOptionsSchema, options);
	if(webpackOptionsValidationErrors.length) {
		throw new WebpackOptionsValidationError(webpackOptionsValidationErrors);
	}
	let compiler;
	if(Array.isArray(options)) {
		compiler = new MultiCompiler(options.map(options => webpack(options)));
	} else if(typeof options === "object") {
		// TODO webpack 4: process returns options
		new WebpackOptionsDefaulter().process(options);

		compiler = new Compiler();
		compiler.context = options.context;
		compiler.options = options;
		new NodeEnvironmentPlugin().apply(compiler);
		if(options.plugins && Array.isArray(options.plugins)) {
			compiler.apply.apply(compiler, options.plugins);
		}
		compiler.applyPlugins("environment");
		compiler.applyPlugins("after-environment");
		compiler.options = new WebpackOptionsApply().process(options, compiler);
	} else {
		throw new Error("Invalid argument: options");
	}
	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);
	}
	return compiler;
}
复制代码

源码分析

首先检查 webpack 配置是否有符合要求

const webpackOptionsValidationErrors = validateSchema(webpackOptionsSchema, options);
if(webpackOptionsValidationErrors.length) {
  throw new WebpackOptionsValidationError(webpackOptionsValidationErrors);
}
复制代码

validateSchema 是一个依赖 ajv(JSON 模式验证器) 插件的对 webpack 配置进行验证的函数,需要提供一份 webpack 配置的 JSON 格式的验证描述 webpackOptionsSchema 和我们项目的配置信息 options,先用 ajv 的 compile 方法编译 webpackOptionsSchema 得到验证器,再用验证器验证 options 并返回验证结果。类似于 React 中使用的 prop-types 验证父组件传给子组件的属性和 Vue 中的 props 自定义验证。

根据项目配置 options 确定 compiler 对象

这里需要先着重介绍下 compiler 对象,它对我们了解 webpack 的构建机制和接下来的讲解至关重要,需要理解它到底是什么,有什么作用。先借用官网的一些介绍:

compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并在所有可操作的设置中被配置,包括原始配置,loader 和插件。当在 webpack 环境中应用一个插件时,插件将收到一个编译器对象的引用。可以使用它来访问 webpack 的主环境。

compiler 对象在 webpack 构建过程中代表着整个 webpack 环境,包含上下文、项目配置信息、执行、监听、统计等等一系列的信息,提供给 loader 和插件使用。它继承于 Tapable(Tapable 是 webpack 的一个底层库,类似于 NodeJS 的 EventEmitter 类),使用事件的发布 compiler.applyPlugins('eventName') 订阅compiler.plugin('eventName', callback) 模式注册 new WebpackPlugin().apply(compiler) 所有插件,插件必须提供 apply 方法给 webpack 完成注册流程,插件在 apply 方法内做一些初始化操作并监听 webpack 构建过程中的生命周期事件,等待构建时生命周期事件的发布。

所有插件都会在构建方法 compiler.run(callback) 之前注册,当 webpack 构建到某个阶段就会发布一个生命周期事件,此时所有订阅了当前发布的生命周期事件的插件会按照注册顺序一个一个执行订阅时提供的回调函数,回调函数的参数是与发布的生命周期事件相对应的参数,比如常用的 compilation 生命周期事件回调函数参数就包含 compilation 对象(此对象也是 webpack 构建机制的重要成员),entry-option 生命周期事件回调函数参数是 context(项目上下文路径)和 entry(项目配置的入口对象)。另外,插件如果需要异步执行编译,则还会提供一个回调函数作为监听回调函数的参数,异步编译完成必须调用回调函数。

简单点说,webpack 的构建包含很多个阶段,每个阶段都会发布对应的生命周期事件,插件需要提供 apply 方法注册并在此方法内监听指定的生命周期事件,事件发布后会顺序执行监听的回调函数并提供相对应的参数。

OK,了解完 compiler 对象后继续看代码。

if(Array.isArray(options)) {
  compiler = new MultiCompiler(options.map(options => webpack(options)));
} else if(typeof options === "object") {
  // ...
  compiler = new Compiler();
  // ...
}
复制代码

这里有一层判断决定 compiler 对象是 MultiCompiler 类还是 Compiler 类的实例,二者的关系其实是一层包装关系,从代码可以看出,MultiCompiler 类的参数是多个 Compiler 实例成员组成的数组,再进入到 MultiCompiler.js 查看源码会发现,在 MultiCompiler 的构造函数中,实例有个 compilers 属性指向这个由多个 Compiler 实例成员组成的数组,以及遍历 compilers 数组给每个 compiler 成员注册(监听) doneinvalid 两个生命周期事件。

对于 outputPath、inputFileSystem、outputFileSystem 这三个属性,会使用取值函数(getter)和存值函数(setter)进行拦截(outputPath 只有取值函数),查看 Compiler 源码 Compiler 类的构造函数,会发现这三个属性都是 Compiler 实例上的属性,再看 MultiCompiler 中这三个属性的存取值函数会发现,都是在遍历 MultiCompiler 实例的 compilers 属性对每一个 compiler 成员做相应的存取值操作(其中 inputFileSystem 和 outputFileSystem 的取值函数是抛出错误),也就是说对 MultiCompiler 实例的 inputFileSystem 和 outputFileSystem 属性赋值其实就是对所有 Compiler 实例的 inputFileSystem 和 outputFileSystem 属性赋值。MultiCompiler 类还覆写了 Compiler 类中的 watch、run、purgeInputFileSystem 三个方法,无一例外也都是遍历 compilers 让每个 compiler 成员执行与之相对应的方法。

一般情况下,我们项目配置的 options 是一个 object,至于什么情况下会使用到数组,我个人看法是一个大的项目中包含多个小的子项目,需要能够单独打包小的子项目,也需要能够一次打包整个大的项目,这时候 Array.isArray(options) === true 才应用到 MultiCompiler。

new WebpackOptionsDefaulter().process(options);
复制代码

我们以 typeof options === "object" 常规项目为例,代码往下执行,首先创建了 WebpackOptionsDefaulter 实例,然后马上执行 process 方法并传入 options 作为参数。查看 WebpackOptionsDefaulter 源码 会发现,它本身只有一个构造函数,构造函数内大规模使用 this.set() 方法初始化 webpack options 默认配置,这个 set 方法是来自它所继承的 OptionsDefaulter 类,执行的 process 方法也是出自 OptionsDefaulter 类,可以说 WebpackOptionsDefaulter 类只是一层外壳,设置所有的 webpack 默认配置信息,借由 procss 方法将 webpack 默认的配置信息与项目配置信息融合,提供出接下来需要使用的 options。

compiler = new Compiler();
compiler.context = options.context;
compiler.options = options;
复制代码

初始化 webpack options 后,创建 compiler 实例并设置 context(项目上下文路径)和 options(项目配置)。

new NodeEnvironmentPlugin().apply(compiler);
复制代码

上文对 compiler 对象的介绍已经清楚的解释了上面一行代码的行为,就是注册一个 NodeEnvironmentPlugin 插件,查看源码发现非常简单,如下:

class NodeEnvironmentPlugin {
	apply(compiler) {
		compiler.inputFileSystem = new CachedInputFileSystem(new NodeJsInputFileSystem(), 60000);
		const inputFileSystem = compiler.inputFileSystem;
		compiler.outputFileSystem = new NodeOutputFileSystem();
		compiler.watchFileSystem = new NodeWatchFileSystem(compiler.inputFileSystem);
		compiler.plugin("before-run", (compiler, callback) => {
			if(compiler.inputFileSystem === inputFileSystem)
				inputFileSystem.purge();
			callback();
		});
	}
}
复制代码

apply 方法执行插件初始化操作修改了 compiler 的 inputFileSystem、outputFileSystem、watchFileSystem 这三个属性的值,然后监听 before-run 事件,顾名思义,这个事件是在 compiler.run(callback) 函数执行会被发布的。查看回调函数内部代码,执行了 callback() 方法,立刻能想到这是一个异步编译回调,之前有一层 inputFileSystem 引用的判断,如果 before-run 事件发布之前 compiler 的 inputFileSystem 被修改重新赋值,则不做任何操作直接执行 callback;如果没有被修改重新赋值,则运行 purge 方法。大意就是如果有其它插件(指项目配置的插件)提供了 inputFileSystem 对象,就用其它插件的,如果没有,那就由我来接管了。

if(options.plugins && Array.isArray(options.plugins)) {
  compiler.apply.apply(compiler, options.plugins);
}
复制代码

首先判断是否有 plugins 属性并且是否为数组,判断语句内的语法稍微有一点绕,咋一眼看上去有些懵,但稍微想一下应该就能明白,就是执行 compiler 的 apply 方法并且绑定 this 为 compiler 对象,再传入 options.plugins(项目配置的所有插件)作为参数。这里使用 apply 方法的目的其实不是为了绑定 this,因为本身 compiler.apply() 方法执行上下文中的 this 指向的就是 compiler,这里主要目的是为了把 options.plugins 解构为一个一个的参数。

Tapable.prototype.apply = function apply() {
	for(var i = 0; i < arguments.length; i++) {
		arguments[i].apply(this);
	}
};
复制代码

因为 Compiler 类继承自 Tapable 类,Compiler 实例上的 apply 方法调用的是 Tapable.prototype.apply,通过上面代码可以清楚的看到,之所以要解构 options.plugins 是因为要遍历 arguments 对象,让 arguments 成员(插件实例)调用自身的 apply 方法(不是 Function.prototype.apply 方法)执行注册流程,传入 this 也就是 compiler 作为参数。

至此,插件注册的流程已经非常清晰明了,对于开始动手写一个 webpack 插件应该没有什么畏惧啦。

compiler.applyPlugins("environment");
compiler.applyPlugins("after-environment");
复制代码

如果已经理解 compiler 对象那上面两行代码是可以略过的,就是发布 compiler.applyPlugins 两个生命周期事件:environment 和 after-environment,如果项目配置的插件中有监听这两个事件的插件则会执行监听回调函数,只提供了默认的 compiler 作为参数,没有任何其他参数。

compiler.options = new WebpackOptionsApply().process(options, compiler);
复制代码

这里是对 compiler.options 重新赋值操作,那意思就很明白了,又要对 options 参数做一系列的操作,为什么说又?因为之前执行 new WebpackOptionsDefaulter().process(options); 已经对 options 做过一次初始化操作,融合了 webpack 的默认配置与项目配置的结果,那这次操作 options 又是为了什么呢?查看 WebpackOptionsApply 源码 发现 WebpackOptionsApply 继承自 OptionsApply,再查看 OptionsApply 源码 发现 OptionsApply 可有可无...,不太清楚为什么要写这么一个空类,或许是为 webpack v4 做准备,这儿我们就先不管,意义不大。再回到 WebpackOptionsApply 类的 process 方法,哗啦啦的一串,将近 300 行,这里我就不贴代码了,我大概描述一下 process 方法内做了哪些事情。

  1. 把一些 options 上的属性赋值给 compiler 对象

  2. 根据 options.target 的值注册相应的 webpack 内部插件

    options.target 配置的意思是告诉 webpack 构建应用于什么环境的代码,它的默认值是 web,另外还有 webworkernodeasync-nodenode-webkitatomelectronelectron-mainelectron-renderer

  3. 根据 options 的配置确定是否要注册一些内部插件

    比如如果配置了 externals 属性需要则注册 ExternalsPlugin 插件

  4. 确定 compiler.resolvers 三个属性 normalcontextloader 的值

  5. 发布三个生命周期事件:entry-optionafter-pluginsafter-resolvers

  6. 返回 options

大致就是在完善 compiler 对象,根据当前项目配置应用一些相对应的内部插件,从这里可以看出,webpack 内部也大量运用插件机制来实现编译构建,插件机制让 webpack 变得灵活而强大。

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);
}
return compiler;
复制代码

接下来,判断是否有 callback 参数,如果没有则直接返回 compiler 对象,注意!此时 webpack 并没有执行构建程序,也不会执行,因为构建程序 compiler.run(callback) 方法只有当有 callback 参数时才会执行,并且 callback 必须为函数。如果开启 watch 模式,则 webpack 会监听文件变化,当文件发生变动则会触发重新编译,像 webpack-dev-server 和 webpack-dev-middleware 里 watch 模式是默认开启的,方便进行开发。

最后执行 compiler.run(callback); 表示开始构建,至此,webpack 构建前的初始化操作已经全部完成,接下来要探索的就是 run 方法是如何执行 webpack 构建的。

本文篇幅已经很长,run 方法内的构建过程不是三言两语能够描述清楚,这里也不打算细说,大概罗列几个关键事件节点:

  1. compile: 开始编译

  2. make: 从入口点分析模块及其依赖的模块并创建这些模块对象

  3. build-module: 构建模块

  4. after-compile: 完成构建

  5. after-compile 完成构建

  6. emit: 把各个chunk输出到结果文件

  7. after-emit: 完成输出

另外 webpack 构建还有一个关键对象 compilation,上文介绍 compiler 时有提到,他们俩是理解和扩展 webpack 引擎的关键,是 webpack 插件必不可缺的组成部分。

compilation 对象代表了一次单一的版本构建和生成资源。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,一次新的编译将被创建,从而生成一组新的编译资源。一个编译对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。编译对象也提供了很多关键点回调供插件做自定义处理时选择使用。

compiler 是 webpack 环境的代表,compilation 则是 webpack 构建内容的代表,它包含了每个构建环节及输出环节所对应的方法,存放着所有 module、chunk、asset 以及用来生成最后打包文件的 template 的信息。

最后,附上一张淘宝 FED 团队在《细说 webpack 之流程篇》 一文中的 webpack 整体流程图:

webpack 整体流程图

文章分类
前端