webpack4源码主流程分析

610 阅读8分钟

1.webpack命令入口解析

  当开始断点调试后,webpack就会开始执行node_modules/webpack/bin/webpack.js文件,文件如下,其中做了一些注释:

#!/usr/bin/env node
// 正常执行返回
process.exitCode = 0;
// 运行某个命令 
const runCommand = (command, args) => {
  ...
};
// 判断某个包是否安装
const isInstalled = packageName => {
  ...
};
// webpack可用的CLI:webpacl-cli和webpack-command
const CLIs = [
  ...
];
// 判断是否两个CLI是否安装了
const installedClis = CLIs.filter(cli => cli.installed);
// 根据CLI安装数量进行处理
if (installedClis.length === 0) {
	...
} else if (installedClis.length === 1) {
	const path = require("path");
	const pkgPath = require.resolve(`${installedClis[0].package}/package.json`);
	// eslint-disable-next-line node/no-missing-require
	const pkg = require(pkgPath);
	// eslint-disable-next-line node/no-missing-require
	require(path.resolve(
		path.dirname(pkgPath),
		pkg.bin[installedClis[0].binName]
	));
} else {
	...
}

  如果正常安装了一个CLI,会走到if语句的第二个分支中,进而执行了安装好的CLI的package.json中的bin属性中的相应的CLI命令,在webpack-cli中是:

"bin": {
    "webpack-cli": "bin/cli.js"
  }

  下面来到了node_modules/webpack-cli/bin/cli.js文件中,其中主要执行了一个立即执行函数,函数精简后的主干逻辑为:

  这里通过引入的node_modules/webpack/lib/webpack.js生成了一个compiler实例,然后执行compiler.run(),run是一个重要的入口后文会详细分析。   我们来看一下这个compiler对象在webpack函数中的生成过程,有些注释没看懂没关系,下面有讲解:

const webpack = (options, callback) => {

        //将webpackOptionsSchema和options传入验证配置有效性
	const webpackOptionsValidationErrors = validateSchema(
		webpackOptionsSchema,
		options
	);
	if (webpackOptionsValidationErrors.length) {
		throw new WebpackOptionsValidationError(webpackOptionsValidationErrors);
	}
	let compiler;
	if (Array.isArray(options)) {
		compiler = new MultiCompiler(
			Array.from(options).map(options => webpack(options))
		);
	} else if (typeof options === "object") {
	
	        //在options中补充默认配置
		options = new WebpackOptionsDefaulter().process(options);
		
		//demo文件夹目录传入Compiler生成compiler实例
		compiler = new Compiler(options.context);
		compiler.options = options;
		
		//把NodeEnvironmentPlugin实例通过apply方法加入到compiler对象中
		new NodeEnvironmentPlugin({
			infrastructureLogging: options.infrastructureLogging
		}).apply(compiler);
		
		//对options对象中的plugins迭代执行apply安装插件
		if (options.plugins && Array.isArray(options.plugins)) {
			for (const plugin of options.plugins) {
				if (typeof plugin === "function") {
					plugin.call(compiler, compiler);
				} else {
					plugin.apply(compiler);
				}
			}
		}
		
		//执行environment钩子
		compiler.hooks.environment.call();
		
		//执行afterEnvironment钩子
		compiler.hooks.afterEnvironment.call();
		compiler.options = new
		
		//对options.target/optimization/performance相关的插件进行apply()
		WebpackOptionsApply().process(options, compiler);
	} else {
		throw new Error("Invalid argument: options");
	}
	
	//如果传入callback,在这里直接执行compiler.run
	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);
	}
	//返回compiler实例
	return compiler;
};

  注释中我们看到了NodeEnvironmentPlugin实例通过apply方法加入到compiler对象中,我们再继续看下NodeEnvironmentPlugin类的apply方法,其中比较关键的一句:

compiler.hooks.beforeRun.tap("NodeEnvironmentPlugin", compiler => {
    if (compiler.inputFileSystem === inputFileSystem) inputFileSystem.purge();
});

  是通过compiler.hooks.beforeRun.tap方法把回调函数挂载到beforeRun钩子上,webpack函数后面又通过钩子对象的call方法来执行了之前挂载的插件。相关原理可以阅读webpack4.0源码分析之Tapable,此文详细地分析了tapable的机制。
  tap是在apply函数中实现的。其实,webpack中的插件都是通过apply的这种方式,来添加到compiler对象中的。而对options对象中的plugins数组也是迭代执行apply来安装插件到compiler中的。
  这里我们还要注意的一点是在WebpackOptionsApply().process()中执行了:

new EntryOptionPlugin().apply(compiler);
compiler.hooks.entryOption.call(options.context, options.entry);

  再来看下EntryOptionPlugin的源码:

//node_modules/webpack/lib/EntryOptionPlugin.js
const itemToPlugin = (context, item, name) => {
	if (Array.isArray(item)) {
		return new MultiEntryPlugin(context, item, name);
	}
	return new SingleEntryPlugin(context, item, name);
};

module.exports = class EntryOptionPlugin {
	/**
	 * @param {Compiler} compiler the compiler instance one is tapping into
	 * @returns {void}
	 */
	apply(compiler) {
		compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => {
			if (typeof entry === "string" || Array.isArray(entry)) {
				itemToPlugin(context, entry, "main").apply(compiler);
			} else if (typeof entry === "object") {
				for (const name of Object.keys(entry)) {
					itemToPlugin(context, entry[name], name).apply(compiler);
				}
			} else if (typeof entry === "function") {
				new DynamicEntryPlugin(context, entry).apply(compiler);
			}
			return true;
		});
	}
};

  通过apply方法安装插件,挂载到EntryOptionPlugin钩子中,回调函数中判断entry分别为字符串或数组、对象、函数的三种情况,本文简单分析,默认entry为一个字符串。可以分析出,最后挂载了SingleEntryPlugin插件。我们再来看下SingleEntryPlugin源码:

//node_modules/webpack/lib/SingleEntryPlugin.js
apply(compiler) {
		compiler.hooks.compilation.tap(
			"SingleEntryPlugin",
			(compilation, { normalModuleFactory }) => {
				compilation.dependencyFactories.set(
					SingleEntryDependency,
					normalModuleFactory
				);
			}
		);

		compiler.hooks.make.tapAsync(
			"SingleEntryPlugin",
			(compilation, callback) => {
				const { entry, name, context } = this;

				const dep = SingleEntryPlugin.createDependency(entry, name);
				compilation.addEntry(context, dep, name, callback);
			}
		);
	}

  其中挂载了两处钩子,分别是compilation和make。我们主要看make这一处,回调函数里面执行了compilation.addEntry()方法,这点一会用到。

2.webpack生成compiler实例

  上文中我们看到了插件通过apply方法把自己挂载到了compiler的钩子上。
  Compiler类继承自Tapable,并且在构造函数中初始化了很多tapable的钩子函数,run函数中通过call(不是JavaScript原生的call)也调用了一开始定义的钩子函数。我们再看一下compiler的构造函数,其中摘出了Compiler类的关键逻辑:

//node_modules/webpack/lib/Compiler.js
class Compiler extends Tapable {
	constructor(context) {
		super();
		
		//compiler对象的一系列钩子
		this.hooks = {
                        ...
			beforeRun: new AsyncSeriesHook(["compiler"]),
			run: new AsyncSeriesHook(["compiler"]),
			emit: new AsyncSeriesHook(["compilation"]),
			assetEmitted: new AsyncSeriesHook(["file", "content"]),
			afterEmit: new AsyncSeriesHook(["compilation"]),

			thisCompilation: new SyncHook(["compilation", "params"]),
			compilation: new SyncHook(["compilation", "params"]),
			normalModuleFactory: new SyncHook(["normalModuleFactory"]),
			contextModuleFactory: new SyncHook(["contextModulefactory"]),

			beforeCompile: new AsyncSeriesHook(["params"]),
			compile: new SyncHook(["params"]),
			make: new AsyncParallelHook(["compilation"]),
			afterCompile: new AsyncSeriesHook(["compilation"]),
                        ...
		};

		//compiler对象的一系列属性
		...
	}

	run(callback) {
		const startTime = Date.now();
		this.running = true;

		this.hooks.beforeRun.callAsync(this, err => {
			if (err) return finalCallback(err);
			this.hooks.run.callAsync(this, err => {
				if (err) return finalCallback(err);
				this.readRecords(err => {
					if (err) return finalCallback(err);
					this.compile(onCompiled);
				});
			});
		});
	}

	compile(callback) {
		const params = this.newCompilationParams();
		this.hooks.beforeCompile.callAsync(params, err => {
			if (err) return callback(err);
			this.hooks.compile.call(params);
			const compilation = this.newCompilation(params);
			this.hooks.make.callAsync(compilation, err => {
				if (err) return callback(err);
				compilation.finish(err => {
					if (err) return callback(err);
					compilation.seal(err => {
						if (err) return callback(err);
						this.hooks.afterCompile.callAsync(compilation, err => {
							if (err) return callback(err);
							return callback(null, compilation);
						});
					});
				});
			});
		});
	}
}

  webpack-cli中执行了这里的run(),在run()中执行了beforeRun和run这两个钩子,之后run钩子的回调中,执行了compile方法。

3.执行make钩子

  在webpack返回了compiler实例以后,webpack-cli就执行了compiler.run(),run()触发compile(),而在compile()中又发生了什么,上面的代码中可以看到compile()中执行的钩子如下:

beforeCompile->compile->make->afterCompile

  这几个就是整个打包流程中最主要的钩子节点。而其中最主要的打包流程就是在make之中,因为上文分析过,为打包流程添加入口其实就是在make这个钩子通过SingleEntryPlugin插件来实现的。
  而在make之前,执行了newCompilation()返回了一个compilation实例:

//node_modules/webpack/lib/Compiler.js
createCompilation() {
    return new Compilation(this);
}
newCompilation(params) {
    const compilation
    this.createCompilation();
    compilation.fileTimestamps = this.fileTimestamps;
    compilation.contextTimestamps = this.contextTimestamps;
    compilation.name = this.name;
    compilation.records = this.records;
    compilation.compilationDependencies = params.compilationDependencies;
    this.hooks.thisCompilation.call(compilation, params);
    this.hooks.compilation.call(compilation, params);
    return compilation;
}

  在这里再说明一下compiler和compilation之间的关系,compilation是在compiler.compile中实例化的一个对象。在webpack打包的过程中只会生成一个compile类,但是每次打包的操作都需要一个compilation来具体完成。字面意义上理解,compiler是一个打包者,而compilation是这个打包者发出的一个动作。
  下面来到了make钩子,上文分析过make会触发compilation.addEntry():

//node_modules/webpack/lib/Compilation.js
addEntry(context, entry, name, callback) {
	...
	this._addModuleChain(
		...
	);
}

  其中主要调用了_addModuleChain():

//node_modules/webpack/lib/Compilation.js
_addModuleChain(context, dependency, onModule, callback) {
        ...
	this.semaphore.acquire(() => {
		moduleFactory.create(
			{
				contextInfo: {
					issuer: "",
					compiler: this.compiler.name
				},
				context: context,
				dependencies: [dependency]
			},
			(err, module) => {
				...
				const afterBuild = () => {
					if (addModuleResult.dependencies) {
						this.processModuleDependencies(module, err => {
							if (err) return callback(err);
							callback(null, module);
						});
					} else {
						return callback(null, module);
					}
				};
                                ...
				this.buildModule(module, false, null, null, err => {
					if (err) {
						this.semaphore.release();
						return errorAndCallback(err);
					}

					if (currentProfile) {
						const afterBuilding = Date.now();
						currentProfile.building = afterBuilding - afterFactory;
					}

					this.semaphore.release();
					afterBuild();
				});
			}
		);
	});
}

  _addModuleChain()将模块添加到依赖列表中,同时进行模块构建。构建时执行buildModule(),回调是afterBuild()。再看下buildModule():

//node_modules/webpack/lib/Compilation.js
buildModule(module, optional, origin, dependencies, thisCallback) {
	...
	module.build(
		this.options,
		this,
		this.resolverFactory.get("normal", module.resolveOptions),
		this.inputFileSystem,
		error => {
			...
		}
	);
}

  buildModule()中执行了module.build,这里就涉及到了webpack中模块的种类,在源码目录中我们可以看到有:MultiModule、NormalModule、ExternalModule、DelegatedModule。笔者其实也还没有深入分析这几种模块都是什么情况下引入的,我们先拿最简单的NormalModule分析在build中都做了什么:

// node_modules/webpack/lib/Parser.js
const acorn = require("acorn");

//node_modules/webpack/lib/NormalModule.js
const { getContext, runLoaders } = require("loader-runner");
...
build(options, compilation, resolver, fs, callback) {
	return this.doBuild(options, compilation, resolver, fs, err => {
                ...
		try {
		        //分析AST语义树
			const result = this.parser.parse(
				this._ast || this._source.source(),
				{
					current: this,
					module: this,
					compilation: compilation,
					options: options
				},
				(err, result) => {
					if (err) {
						handleParseError(err);
					} else {
						handleParseResult(result);
					}
				}
			);
			if (result !== undefined) {
				// parse is sync
				handleParseResult(result);
			}
		} catch (e) {
			handleParseError(e);
		}
	});
}

doBuild(options, compilation, resolver, fs, callback) {
		const loaderContext = this.createLoaderContext(
			resolver,
			options,
			compilation,
			fs
		);
		runLoaders(
			{
				resource: this.resource,
				loaders: this.loaders,
				context: loaderContext,
				readResource: fs.readFile.bind(fs)
			},
			(err, result) => {
				...
				this._ast =
					typeof extraInfo === "object" &&
					extraInfo !== null &&
					extraInfo.webpackAST !== undefined
						? extraInfo.webpackAST
						: null;
				return callback();
			}
		);
	}

  在normalModule.build方法中会先调用自身doBuild()。doBuild()通过loader-runner库中引入的runLoaders方法选用合适的loader去加载resource,目的是为了将这份resource转换为JS模块。而在doBuild的回调函数中又会看到this.parser.parse,主要是通过相应的loader加载完模块以后,来分析模块的依赖关系。用到了一个第三方库acorn提供的parse方法对JS源代码进行语法解析。
  至此就可以执行buildModule()的回调afterBuild(),摘了一个网上的图,总结的比较好:

4.封装和输出

  至此,webpack收集完整了该模块的信息和依赖项,接下来就是如何进一步打包封装模块了。compilation.seal的步骤比较多,先封闭模块,生成资源,这些资源保存在compilation.assets, compilation.chunks。然后调用compilation.createChunkAssets方法把所有依赖项通过对应的模板 render出一个拼接好的字符串:

//node_modules/webpack/lib/Compilation.js
createChunkAssets() {
	...
	for (let i = 0; i < this.chunks.length; i++) {
		const chunk = this.chunks[i];
		chunk.files = [];
		let source;
		let file;
		let filenameTemplate;
		try {
			const template = chunk.hasRuntime()
				? this.mainTemplate
				: this.chunkTemplate;
			// manifest是数组结构,每个manifest元素都提供了 `render` 方法,提供后续的源码字符串生成服务。至于render方法何时初始化的,在`./lib/MainTemplate.js`中
			const manifest = template.getRenderManifest({
				chunk,
				hash: this.hash,
				fullHash: this.fullHash,
				outputOptions,
				moduleTemplates: this.moduleTemplates,
				dependencyTemplates: this.dependencyTemplates
			}); 
			for (const fileManifest of manifest) {
				...
				this.emitAsset(file, source, assetInfo);
				chunk.files.push(file);
				this.hooks.chunkAsset.call(chunk, file);
				alreadyWrittenFiles.set(file, {
					hash: usedHash,
					source,
					chunk
				});
			}
		}
	}
}

  在seal执行后,关于模块所有信息以及打包后源码信息都存在内存中,是时候将它们输出为文件了。然后,compile()会执行onCompiled()回调:

//node_modules/webpack/lib/Compiler.js
const onCompiled = (err, compilation) => {
        ...
	process.nextTick(() => {
                ...
		this.emitAssets(compilation, err => {
			...
			this.emitRecords(err => {
	        	this.hooks.done.callAsync(stats, err => {
				    ...
				});
			});
		});
	});
};

this.cache.endIdle(err => {
	if (err) return finalCallback(err);
	this.hooks.beforeRun.callAsync(this, err => {
		this.hooks.run.callAsync(this, err => {
			this.readRecords(err => {
				this.compile(onCompiled);
			});
		});
	});
});

  在onCompiled()中,会执行compiler.emitAssets(),在compiler.emitAssets中会先调用this.hooks.emit生命周期,之后根据webpack config文件的output配置的path属性,将文件输出到指定的文件夹。至此,你就可以在./debug/dist中查看到调试代码打包后的文件了。

参考:
《Webpack源码解读:理清编译主流程》juejin.cn/post/684490…
《「搞点硬货」从源码窥探Webpack4.x原理》zhuanlan.zhihu.com/p/102424286
《webpack4源码分析》juejin.cn/post/684490…
《从Webpack源码探究打包流程,萌新也能看懂~》juejin.cn/post/684490…
《webpack-源码分析》echizen.github.io/tech/2019/0…
《Webpack揭秘——走向高阶前端的必经之路》imweb.io/topic/5baca…