本系列按照主线:webpack执行入口、构建前准备阶段、编译构建阶段、构建后优化阶段、文件输出阶段这五部分来分享,承接上篇讲解到的webpack构建过程中的关键节点钩子,本章的重点是分析webpack的核心编译构建过程,也就是从run -> make -> buildModule -> normal-module-loader -> program。其中webpack的整体构建过程可以参考下图。
如果还没有看之前的两篇文章的小伙伴,建议先简单过一遍之前的内容,最接地气的webpack源码解析(二)、最接地气的webpack源码解析(一)
webpack/lib/webpack.js
const Compiler = require("./Compiler");
const webpack = (options, callback) => {
options = new WebpackOptionsDefaulter().process(options);
compiler = new Compiler(options.context);
compiler.options = options;
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging
}).apply(compiler);
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);
}
}
}
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
compiler.options = new WebpackOptionsApply().process(options, compiler);
return compiler;
}
exports = module.exports = webpack;
复制代码
可以看到webpack在构建前准备工作完成之后,就返回了实例化后的compiler对象,所以想要运行compiler.run()来启动编译构建过程,就一定要在Compiler类里定义一个run方法,接下来我们就去研究Compiler类是如何定义的,当然定义的文件位置可以从const Compiler = require("./Compiler")
中找到。
webpack/lib/Compiler.js
const {
Tapable,
SyncHook,
SyncBailHook,
AsyncParallelHook,
AsyncSeriesHook
} = require("tapable");
const Compilation = require("./Compilation");
class Compiler extends Tapable {
constructor(context) {
super();
this.hooks = {};
}
run(callback) {
const onCompiled = (err, compilation) => {}
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) {}
}
复制代码
可以看出来Compiler类中主要做了两件事情:
- 定义各种钩子供插件使用;
- 定义run()函数,用来启动编译构建过程。
接下来看看run()函数内都做了哪些事情?先定义编译构建完成时的回调函数onCompiled
,按照之前提到的流程主线,可以看到onCompiled内主要是文件输出阶段相关的处理,这部分在后续的章节会再分析。然后继续看源码在定义了onCompiled
之后,就开始触发各个钩子,从beforeRun -> run,随着触发过run钩子就开始了编译的过程,执行compile
函数,这个函数也是本章的第一个重点函数。
webpack/lib/Compiler.js-compile()
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);
});
});
});
});
});
}
复制代码
毕竟是在compiler.js文件中定义的compile函数,看函数命名就能猜出来它是重点了,在这个函数里确实也做了很多重要的处理。首先触发编译相关的两个钩子beforeCompile -> compile,然后创建compilation对象,重点又来了!注意看这个引入compilation的地方,其实在webpack整个编译构建过程中有两个特别重要的对象,一个是之前碰到的compiler(可以理解为编译器),一个就是compilation(可以理解为编译过程)。
compliation看代码是从const compilation = this.newCompilation(params)
这里得到的,但是真正的来源是从webpack/lib/Compilation.js文件中引入的。由于这个对象过于重要,就花点功夫讲清楚它是怎么被实现的,我们把关注点放到newCompilation()
这个函数。
const Compilation = require("./Compilation");
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;
}
复制代码
其实看到这个代码的实现过程,就可以很清楚得发现compilation这个对象,是通过实例化Compilation.js中定义的类来实现的。
回到上面提到的compile()函数内的操作,在生成compilation对象之后,就触发了make钩子,重点又来了!注意看这个make钩子就是流程图里面触发run之后的下一个钩子,看源码是触发了make钩子之后紧接着就开始compilation.finish()
、 compilation.seal()
,最后触发afterCompile
钩子代表编译构建以及优化过程结束,触发回调进行文件输出,最后结束构建。构建后的优化阶段和文件输出阶段是后面两篇文章分享的内容,那本章的重点就放在compilation.finish()这里。
其实源码读到这里,主流程代码已经都分析完毕了,但我当时心里有一个疑问,为什么源码中一触发make钩子,紧接着就compilation.finish(),中间的核心构建过程去哪里了呢?这里又是一个黑盒。。。不过也可以猜测出来,既然有触发make钩子,则有果必有因,我们一起去查找webpack在哪里提前注册了make钩子的监听,以及在监听到make钩子触发之后又做了哪些处理。(这里分享个小彩蛋,webpack其实是在构建前的准备阶段提前注册的这些钩子,有兴趣的小伙伴可以回过头看一下第二篇的内容,里面webpack在实例化Compiler之后,分别先遍历注册了用户自定义的插件数组,然后又注册了webpack内置的插件,compiler.options = new WebpackOptionsApply().process(options, compiler)
,也就是在这里注册了对make钩子的监听和回调函数)
如果大家读到这里感觉需要消化一下前面的内容,可以先暂停进度,后面要讲的是从make钩子触发到构建完成(compilation.finish)这之间的一系列过程,前后两部分内容被webpack很好的解耦开,大家也可以先收藏,后面根据需要再分配阅读时间。
下面为了分析webpack中从make钩子触发到编译完成这个阶段做的事情,我们在webpack文件夹中搜索.hooks.make
,然后会搜出7个文件内有使用,其中看钩子的注册代码,我们主要看SingleEntryPlugin.js和MutiEntryPlugin.js这两个文件,这两个文件分别代表单入口打包和多入口打包。其实webpack的整个模块打包思想也就是从入口开始,先对入口模块进行编译构建,然后分析入口模块对应的依赖模块,然后再编译这些入口模块的依赖模块,接着不断循环直到所有使用到的模块都被编译构建,最后得到一张完整的依赖图。
我们从SingleEntryPlugin.js入手,因为多入口打包也就是在单入口打包的基础上,循环每个入口分别进行单入口打包的操作。
webpack/lib/SingleEntryPlugin.js
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);
}
);
}
}
复制代码
可以看到SingleEntryPlugin.js中make钩子触发之后,会将入口模块传递给compilation.addEntry()
,所以我们接下来看看compilation.addEntry()做了什么事情,需要到webpack/lib/Compilation.js文件一探究竟。
webpack/lib/Compilation.js
class Compilation extends Tapable {
constructor(compiler) {
super();
this.hooks = {};
this.compiler = compiler;
this.options = options;
this.entries = [];
this.modules = [];
}
buildModule(module, optional, origin, dependencies, thisCallback) {}
_addModuleChain(context, dependency, onModule, callback) {}
addEntry(context, entry, name, callback) {}
}
复制代码
这里我先提供了Compilation这个类的几个重要函数和内置参数,其实compilation也提供了很多钩子可以供插件使用,方便开发人员对编译构建过程进行定制化,而且可以通过compiler属性拿到compiler,通过options获取到webpack配置,entries可以拿到构建开始的入口模块,modules可以获取到所有编译构建后的模块,极大程度得支持了开发人员去自定义插件。
现在开始进入正题,开始编译构建的时候,addEntry()
函数内都做了什么处理?
webpack/lib/Compilation.js-addEntry()
addEntry(context, entry, name, callback) {
this.hooks.addEntry.call(entry, name);
this._addModuleChain(
context,
entry,
module => {
this.entries.push(module);
},
(err, module) => {
this.hooks.succeedEntry.call(entry, name, module);
return callback(null, module);
}
)
}
复制代码
addEntry()
内主要做了两件事情,把entry模块放到this.entries数组内,然后调用this._addModuleChain
,看函数命名的话可以猜到_addModuleChain会链式递归构建模块。
webpack/lib/Compilation.js-_addModuleChain()
_addModuleChain(context, dependency, onModule, callback) {
this.semaphore.acquire(() => {
moduleFactory.create({}, (err, module) => {
const addModuleResult = this.addModule(module);
module = addModuleResult.module;
const afterBuild = () => {
if (addModuleResult.dependencies) {
this.processModuleDependencies(module, err => {
if (err) return callback(err);
callback(null, module);
});
} else {
return callback(null, module);
}
};
if (addModuleResult.build) {
this.buildModule(module, false, null, null, err => {
afterBuild();
});
}
})
})
}
复制代码
_addModuleChain
就是真正递归遍历构建模块的部分,首先构建当前模块,调用this.buildModule()
,当前模块构建完成之后再调用afterBuild()
,通过this.processModuleDependencies()
递归得处理当前模块所依赖的模块。至于究竟是怎样构建当前模块的呢?
先看一下this.buildModule
的定义:
webpack/lib/Compilation.js-buildModule()
buildModule(module, optional, origin, dependencies, thisCallback) {
this.hooks.buildModule.call(module);
module.build();
}
复制代码
this.buildModule
内触发了另一个关键的钩子buildModule,到这里完成了从run -> make -> buildModule的分析。buildModule()
主要是调用了module.build()
方法,这里的build()方法是module模块内部提供的。
为了搞清楚module.build
是在哪里定义的,需要先扩展一下webpack中的模块定义,webpack的模块一共分为五种(NormalModule ContextModule ExternalModule DelegatedModule MutiModule),我们通常遇到的模块基本都是NormalModule(第三方依赖模块)和ContextModule(上下文模块-用户自定义的模块),下面我们以NormalModule为例子,去看该模块提供的build
方法。
webpack/lib/NormalModule.js
build(options, compilation, resolver, fs, callback) {
return this.doBuild(options, compilation, resolver, fs, err => {
try {
const result = this.parser.parse(
this._ast || this._source.source(),
{
current: this,
module: this,
compilation: compilation,
options: options
},
(err, result) => {}
}
}
}
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)
}, () => {})
}
createLoaderContext() {
compilation.hooks.normalModuleLoader.call(loaderContext, this);
}
复制代码
module.build()执行时会再调用this.doBuild()
,到doBuild
这里,我们才真正揭开webpack如何构建模块的神秘面纱,原来也是通过loader-runner提供的runLoaders来使用开发者定义的loaders去处理文件模块,然后把编译后的结果传递给const result = this.parser.parse(this._ast || this._source.source())
,使用parser方法去解析编译后的代码。在执行runloader方法之前需要先得到loaderContext
这个必传参数,也正是在获取这个参数的过程中会触发normalModuleLoader这个钩子,完成了从make -> buildModule -> normalModuleLoader的分析。然后我们回到使用parser解析编译结果的过程,这个this.parser
最终是从webpack/lib/parser.js中引入的。
webpack/lib/parser.js
parse(source, initialState) {
if (typeof source === "object" && source !== null) {
ast = source;
comments = source.comments;
} else {
comments = [];
ast = Parser.parse(source, {
sourceType: this.sourceType,
onComment: comments
});
}
if (this.hooks.program.call(ast, comments) === undefined) {
this.detectMode(ast.body);
this.prewalkStatements(ast.body);
this.blockPrewalkStatements(ast.body);
this.walkStatements(ast.body);
}
}
prewalkStatements(statements) {
for (let index = 0, len = statements.length; index < len; index++) {
const statement = statements[index];
this.prewalkStatement(statement);
}
}
prewalkStatement(statement) {
case "ImportDeclaration":
this.prewalkImportDeclaration(statement);
break;
}
prewalkImportDeclaration(statement) {
for (const specifier of statement.specifiers) {
switch (specifier.type) {
case "ImportDefaultSpecifier":
this.hooks.importSpecifier.call(statement, source, "default", name);
break;
case "ImportSpecifier":
this.hooks.importSpecifier.call(
statement,
source,
specifier.imported.name,
name
);
break;
case "ImportNamespaceSpecifier":
this.hooks.importSpecifier.call(statement, source, null, name);
break;
}
}
}
复制代码
parser解析的过程,会先触发program钩子,实现了构建子过程的最后一个钩子,从make -> buildModule -> normalModuleLoader -> program的分析。到program触发其实就完成了模块的编译构建,接下来就是构建之后的优化阶段和文件输出阶段,这两部分将在后面的两篇文章中分享。
这里parser的解析过程是很值得借鉴的,尤其是在开发人员自定义loader的时候,都需要先将源代码转化成AST,webpack是借助acorn提供的parser方法去生成AST,然后遍历AST去判断语法做特殊处理。这里我截选了对ImportDeclaration
-import声明语法的处理,因为当parser遇到import语句的时候需要进行依赖收集,当前模块编译构建完成之后还需要递归编译依赖模块,直到所有模块编译完成。
下一章将会分享所有模块编译完成后的优化阶段。