阅读 2119

Webpack 源码研究

Webpack运行流程图

将我的研究成果,画一个简要的流程图,如果您有兴趣看完,回头再看看这个流程图。

image

图片里的方块中文字的序号,就是运行的顺序。

研究Webpack源码的好处

  1. 理解Webpack的运行流程
  2. 理解源码是理解Webpack插件的基础
  3. 学习Webpack源码中的技巧和思想

编写一个插件的思考

写一个插件很简单,如下:

class TestPlugin {
  apply(compiler) {
    console.log('compiler');
    compiler.hooks.compilation.tap('TestPlugin', function (compilation) {
      console.log('compilation', compilation);
    })
  }
}

// 导出 Plugin
module.exports = TestPlugin;
复制代码

通过我们以往对tapable的了解,知道可以通过钩子来监听Tapable类相应的事件,我们做相应的处理就行了。

tapable是了解Webpack源码的前置条件,可以阅读《Webpack tapable 使用研究》《Webpack tapable源码研究》学习。

写插件关键的问题不是注册钩子,而是compiler和compilation是啥,钩子给我们暴露了这两个对象,让我们任意操作它们。但这两个对象有什么变量,方法,它们的运行机制,我们尚不清楚,搞懂了这些,才能写出插件。

总结,想搞懂插件,必须搞懂源码,了解Webpack运行流程。

从入口开始

把Webpack比作一个魔术师的箱子,我们放进去(输入)的是什么?拿出来(输出)的又是什么?

输入的是配置文件+源代码,输出的是bundle文件。

Webpack的入口是一个webpack的函数,我们来一下,我将不主要的全部省略,只留下主流程和主要代码:

webpack = (options) => {
    // 1,第一步就是整合options
    // options就是配置,有我们配置文件中的配置,加上Webpack默认的配置。这些配置指导webpack后续如何运行,比如从哪里开始读取源代码文件,bundle文件输出到哪里,如何进行代码分割等等等。
    ...
    
    // 2, 第二步实例化compiler对象
    compiler = new Compiler(options.context);
    ...
    
    // 3,实例化所有的(内置的和我们配置的)Webpack插件。调用它们的apply方法。


    // 4, 返回compiler对象
    return compiler;
}
复制代码
// 使用,这里模仿webpack-cli中的代码,相当于在命令行里输入webpack。
const options = require("./webpack.config.js");
const compiler = webpack(options);

compiler.run();
复制代码

webpack函数的主要逻辑大致如此。核心是生成compiler,返回它。然后外部得到compiler实例后,run它。

webpack函数内部,分四步。这里说说第三步,实例化插件。插件被不被实例化,配置是可以控制的。请看下面的代码:

// WebpackOptionsApply.js
if (options.optimization.removeAvailableModules) {
	const RemoveParentModulesPlugin = require("./optimize/RemoveParentModulesPlugin");
	new RemoveParentModulesPlugin().apply(compiler);
}
复制代码

如果我们的配置中optimization.removeAvailableModules不是true,那就不会实例化RemoveParentModulesPlugin插件。

但是,有一个插件,是必须要实例化的,就是NodeEnvironmentPlugin,源码在 compiler = new Compiler(options.context);后就直接写着new NodeEnvironmentPlugin().apply(compiler);

我们来看一下这个插件的代码:

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.hooks.beforeRun.tap("NodeEnvironmentPlugin", compiler => {
			if (compiler.inputFileSystem === inputFileSystem) inputFileSystem.purge();
		});
	}
}
module.exports = NodeEnvironmentPlugin;
复制代码

不用关注这里面每行的意思,我们了解它的功能是:封装了文件存取的api。

显然这个插件赋予了compiler对象文件存取的能力,这是Webpack必须要有的能力啊,不存取文件没法打包了。所以NodeEnvironmentPlugin是必须要用的插件。

总结就是插件分两种,一种是锦上添花的,譬如各种优化插件。一种是Webpack流程中必须要用的,比如这个NodeEnvironmentPlugin,还有后面要提到的EntryOptionPlugin,都是流程中的一部分。我们查看流程,需要了解这些必须的插件。

webpack函数的职责

总结:读取配置,实例compiler,实例化和挂载插件。

compiler

了解了webpack函数的逻辑,接下来就看compiler中的逻辑了。我们知道它被实例化后,被调用了run方法。直接看run方法:

// Compiler.js
class Compiler extends Tapable {
    constructor(context) {
        this.hooks = {
            ...
            beforeRun,
            run
        }
    }
    ...
    run(callback) {
        ...
    	this.hooks.beforeRun.callAsync(this, err => {
    		this.hooks.run.callAsync(this, err => {
                ...
				this.compile(onCompiled);
    		});
    	});
    }
}
复制代码

run方法的主要逻辑就是,先调用beforeRun钩子,这个钩子我们知道,上面的NodeEnvironmentPlugin注册了此钩子。为compiler添加了文件存取功能。

接下里是调用run钩子。run钩子调用完,会调用compile方法。

// Compiler.js
class Compiler extends Tapable {
    ...
	createCompilation() {
		return new Compilation(this);
	}

	newCompilation(params) {
		const compilation = this.createCompilation();
        ...
		this.hooks.thisCompilation.call(compilation, params);
		this.hooks.compilation.call(compilation, params);
		return compilation;
	}
    
	compile(callback) {
		this.hooks.beforeCompile.callAsync(params, err => {

			this.hooks.compile.call(params);

			const compilation = this.newCompilation(params);

			this.hooks.make.callAsync(compilation, err => {
				compilation.finish(err => {
					compilation.seal(err => {
						this.hooks.afterCompile.callAsync(compilation, err => {
							return callback(null, compilation);
						});
					});
				});
			});
		});
	}
}
复制代码

可以看到compile函数的核心逻辑就是调用beforeCompile钩子,然后调用compile钩子,然后实例化compilation对象,在实例化的过程中,调用了thisCompilation和compilation两个钩子。然后执行make钩子。make执行结束后,模块的转换工作结束了,要开始seal封装了。seal结束后,调用afterCompile钩子,从这个afterCompile的语义可以分析出,到这就编译结束了。

我们再看回run方法,在编译结束之后,回调用onCompiled的一个回调函数,这个回调函数掌管的是输出的事,代码如下:

const onCompiled = (err, compilation) => {
	if (this.hooks.shouldEmit.call(compilation) === false) {
	    ...
		this.hooks.done.callAsync(stats, err => {
			...
		});
		return;
	}

	this.emitAssets(compilation, err => {
		this.hooks.done.callAsync(stats, err => {
            ...
		});
		return;
	});
};
复制代码

先调用shouldEmit钩子,编译不成功直接调用done钩子,表示结束。编译成功则调用emitAssets方法,emitAssets内部调用emit钩子,执行文件输出,最后调用done,成功完成一次输出。

compiler对象的职责

总结:启动编译和管理输出。实例化compilation对象并利用make钩子的插件,让其开始工作。模块的编译工作是compilation对象做的。

梳理一下compiler对象的调用栈:run->compile->onCompiled

  • run函数中触发的钩子:beforeRun,run
  • compile函数中触发的钩子:beforeCompile,compile,thisCompilation,compilation,make,afterCompile
  • onCompiled函数中触发的钩子: should-emit,emit,done。

compilation

了解了compiler对象,接下来看compilation对象。

compilation的研究相对耗时一点,因为webpack函数和compiler关联的东西很少,webpack只关联了compiler,compiler只关联了compilation,所以比较容易梳理清楚。

但想弄懂compilation,需要弄清楚compilation,moduleFactory,module,三种对象之间的关系,逻辑上也更复杂一点,接下来一起看看吧。

此图是在compilation构建模块链阶段,主要使用的对象和它们的职责示意图。

compilation编译模块的入口

要从compiler的make钩子看起,从上面的compile的方法内看到,实例化compilation对象后,并没有对它做什么操作,而是直接调用了make钩子,在钩子挂载的入口相关的插件中,操作了compilation,我们来看一下:

class SingleEntryPlugin {
	constructor(context, entry, name) {
		this.context = context;
		this.entry = entry;
		this.name = name;
	}

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

	static createDependency(entry, name) {
		const dep = new SingleEntryDependency(entry);
		dep.loc = { name };
		return dep;
	}
}
复制代码

这里使用SingleEntryPlugin作为例子,配置单入口时会使用此插件,插件往make钩子会挂载了回调函数。它不但挂载了make钩子,还挂载了compilation钩子,这个钩子先于make钩子调用,为compilation对象的dependencyFactories中添加了值,这值是一个key-value对,key是SingleEntryDependency,值是normalModuleFactory,normalModuleFactory就是一种modulefactory,我们后面在构建模块中用到。

Webpack处理异步都是使用callback

再看回make钩子的回调,回调有两个形参数,compilation和callback。这里要注意的一点是,对于异步问题,我日常都喜欢写promise和await,但Webpack中,机会全是callback,习惯看这种表达,才容易看懂源码,如:

// 对于异步,我们会写
const { err,module } = await moduleFactory.create(params);

// 但源码中都是这样的,在回调中拿到调用的返回值
moduleFactory.create(params,(err,module)=> { ... })
复制代码

编译模块的主要过程

从make钩子的插件开始,插件可以操作compilation,调用了compilation的addEntry。编译工作就从入口文件开始了。参数dep就是为了在compilation.dependencyFactories找到normalModuleFactory。

addEntry方法做的事

  1. 调用addEntry钩子。
  2. 调用在_addModuleChain方法,调用完毕后执行addEntry的callback,通知make钩子的插件编译工作完成。

_addModuleChain方法做的事

  1. 从名字可以看出,这个方法是添加模块链的。就是它执行完,模块都构建好了,并且形成了链。
  2. 根据dep找到moduleFactory,我们找到的是normalModuleFactory。
  3. 调用moduleFactory.create方法。在返回值中拿到module,这里的module是个NormalModule类型的对象。
  4. 得到moudle对象后,调用buildModule方法。此方法内调用buildModule钩子,然后调用moudle自身的build方法。build方法就是调用loader,构建模块,获取依赖的逻辑。
  5. 得到编译后的moudle,调用afterBuild方法。在afterBuild判断模块依赖了哪些模块,递归的用模块工厂创建它们,重复3,4,5流程。直到所有关联的模块构建完毕,我们就拿到模块链了。

NormalModule中build

我们来细看一下上面4步的build方法,内部调用了doBuild,doBuild中调用了runLoaders,从方法名可以看出,此处就是模块构建中使用loader的地方了。

模块构建结束

也就是addEntry执行结束、SingleEntryPlugin执行结束、make钩子调用结束。该执行make钩子的回调函数了。

Chunk构建和打包的优化

make钩子回调中调用了compilation的seal方法。开始了Chunk的构建和打包的优化过程。可以说看到这里,我们对Webpack的执行流程,了得的八九不离十了。

seal方法做的事。

  1. 调用seal钩子,seal方法中钩子可谓大展威力,seal方法只是调用各种钩子,真正的构建和优化工作,都是插件做的。
  2. 循环调用optimizeDependenciesBasic、optimizeDependencies、optimizeDependenciesAdvanced钩子。
  3. 调用afterOptimizeDependencies钩子。
  4. 调用beforeChunks钩子。从名字也可以看出上面做的依赖方面的优化,此处开始构建Chunk了。
  5. 调用afterChunks钩子。
  6. 调用optimize钩子。
  7. 调用optimizeModulesBasic、optimizeModules、optimizeModulesAdvanced、afterOptimizeModules钩子。此处优化module。,参数是this.modules。
  8. 调用optimizeChunksBasic、optimizeChunks、optimizeChunksAdvanced、afterOptimizeChunks。此处优化Chunk,参数是this.chunkGroups。
  9. 一系列钩子调用不再赘述,有需要的时候,我们再研究。

总之seal方法完成了Chunk的构建和依赖、Chunk、module等各方面的优化。

回到compiler

seal方法执行完毕,生成好了Chunks对象,compilation的工作告一段落,控制权又还给compiler,此时compiler的compile方法就执行完毕了。该执行compile的回调函数onCompiled了。上面我们也贴出来onCompiled的简要代码。

onCompiled方法做的事

onCompiled完成了最后的输出阶段。将我们生成的Chunk输出到磁盘上。调用了done钩子之后,一次构建就此完成。

compilation对象的职责

总结:构建模块和Chunk,并利用插件优化构建过程

改业务代码的再编译过程

思考一下,当我们改动业务代码时,webpack-cli会直接再次调用compiler.run()。重新编译我们的代码。所以有了这张图片:

image

wepack函数不会被调用了吧,因为不必在重复初始化。内存中已经有compiler对象了,直接run它就重新编译了。

参考文章

从Webpack源码探究打包流程,萌新也能看懂~

webpack原理

我是边看他们的文章,边研究源码学习的,想研究源码的同学,也可以使用这种方式,我认为光看文章还是很难弄清楚源码的,还是得调试、实践。

结束语

本章只是简单梳理了Webpack的基本流程,期待后续继续研究Webpack的各类插件,结合插件更好的熟悉Webpack,同大家一起学习。

关注下面的标签,发现更多相似文章
评论