之前webpack一直停留在用的阶段,最近刚好有时间学习了一下webpack的源码,通过读源码以加深对webpack的了解。
源码版本5.73.0
下面逐步分析一下webpack的编译流程
一、初始化
首先回顾一下平时我们是如何使用webpack的。常见如下两种场景
- 配置好webpack.config.js文件后, 在package.json文件中配置对应的
script如:npx webpack --config webpack.config.js - 或者做一些定制化,通过node执行自定义的文件比如
node build.js// build.js const webpack = require('webpack') const config = require('./webpack.config.js') ... webpack(config, () => { ... })
针对第一种场景, 执行命令时webpack会校验是否安装webpack-cli,webpack-cli会校验命令行参数以及config文件,最终也是执行webpack(config, callback)和场景2类似。 基于以上两种场景,最终都是执行webpack()函数,因此我们需要找到该函数
根据webpack的package.json信息找到对应的入口文件lib/index.js=>lib/webpack.js找到对应的webpack函数如下。(为了方便解释,代码精简了一部分)
const webpack = (options, callback) => {
const create = () => {
let compiler;
...
compiler = createCompiler(webpackOptions);
return { compiler }
}
if (callback) {
const { compiler } = create()
compiler.run((err, stats) => {
compiler.close(err2 => {
callback(err || err2, stats);
});
});
return compiler
} else {
const { compiler } = create()
return compiler
}
}
可以看到默认导出的函数只是做了进一步封装, 重头戏在createCompiler中
const createCompiler = rawOptions => {
const options = getNormalizedWebpackOptions(rawOptions);
applyWebpackOptionsBaseDefaults(options);
const compiler = new Compiler(options.context, options);
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging
}).apply(compiler);
if (Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === "function") {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
}
applyWebpackOptionsDefaults(options);
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
new WebpackOptionsApply().process(options, compiler);
compiler.hooks.initialize.call();
return compiler;
};
逐步分析createCompiler的逻辑
- 首先通过
getNormalizedWebpackOptions组装、合并、转化默认参数,比如entry: './index.js'会被转化成{main:{import: ['./index.js']}}, 最终生成一个完整的配置信息options - 接着通过
applyWebpackOptionsBaseDefaults设置context的值和logger的配置 - 然后创建
compiler对象 - 接着通过
NodeEnvironmentPlugin初始化编译需要的inputFileSystem、outputFileSystem、watchFileSystem等信息到compiler对象上 - 因为
compiler对象已创建,所以可以初始化使用者自定义配置的plugins - 然后通过
applyWebpackOptionsDefaults给第一步生成的完整options赋值默认值, 可以和上一张图做下对比。 - 然后通过
new WebpackOptionsApply().process基于合并后的options初始化对应的内置插件。比如:基于mode初始化DefinePlugin生成process.env.NODE_ENV变量、基于optimization.splitChunks初始化SplitChunksPlugin等;这里重点讲一下EntryOptionPlugin, 会基于options的entry配置初始化EntryOptionPlugin插件进而初始化EntryPlugin,在EntryPlugin内部会注册compiler的hooks.make事件// WebpackOptionsApply.js class WebpackOptionsApply extends OptionsApply { process(options, compiler) { ... // 注意这里,下面重点分析 new EntryOptionPlugin().apply(compiler); compiler.hooks.entryOption.call(options.context, options.entry); ... } } // EntryPlugin.js class EntryPlugin { apply(compiler) { compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => { // dep即为EntryDependency的实例,里面包含entry配置的信息 compilation.addEntry(context, dep, options, err => { callback(err); }); }); } }
经过以上步骤创建好compiler对象,通过初始化一系列插件注册了很多事件, 需要一个时机来触发对应的动作。 万事具备,只欠东风
二、编译阶段1 -- make阶段
该compiler.run登场了, 进入编译阶段
先看一下run方法
class Compiler {
run() {
...
this.hooks.run.callAsync(this, err => {
if (err) return finalCallback(err);
this.readRecords(err => {
if (err) return finalCallback(err);
this.compile(onCompiled);
});
});
}
compile() {
const params = this.newCompilationParams();
...
const compilation = this.newCompilation(params);
const logger = compilation.getLogger("webpack.Compiler");
logger.time("make hook");
// 触发make事件
this.hooks.make.callAsync(compilation, err => {
logger.timeEnd("make hook");
if (err) return callback(err);
}
}
newCompilationParams() {
// 注意这里
const params = {
normalModuleFactory: this.createNormalModuleFactory(),
contextModuleFactory: this.createContextModuleFactory()
};
return params;
}
}
可以看到,通过run方法调用compile,在compile内部创建了compilation对象, 同时触发compiler.hooks.make事件。还记得我们上面讲的初始化内置插件时,通过EntryPlugin注册了compiler.hooks.make事件,在这里触发了make事件执行compilation.addEntry方法
整个流程如下, 流程中第1步对应上面讲的第一章节, 第2步对应上面讲的make阶段
(流程中红色字体代表hooks的调用, 蓝色字体代表hooks注册,圆形嵌套代表函数嵌套调用😄)
接下来继续讲compilation.addEntry。先大概说一下addEntry的整体流程, 然后根据流程图进一步分析。在该过程首先分析入口文件, 根据入口文件AST找到其依赖的文件,然后再分析其引入文件将依赖文件的依赖进一步分析编译,执行完所有相关文件的编译。有点绕,看下流程图
经过addEntry一系列调用最终通过factory.create将入口文件转换成Module对象,并根据文件的格式匹配对应的loader,记录在Module对象上。
那factory是什么? 常见的factory有两种,分别为normalModuleFactory、contextModuleFacotry, 这两个factory在创建compilation时被赋值到compilation对象。
normalModuleFactory常用来处理import { A } from './a'这种导入语句的文件,a.js对应的文件最终会被转换成NormalModulecontextModuleFactory常用来处理const locale = require('./locale/' + name)这种带变量的导入语句, 在编译阶段webpack没办法识别真正运行时该用哪个文件,因此会将locale目录下的所有文件都进行编译;在转成Module时,Module对象上会带有一个正则reg: /*/用来匹配所有文件。也正因如此,webpack提供了ContextReplacementPlugin插件来替换默认的正则以及路径,提前告诉webpack编译指定的文件。
经过normalModuleFactory、contextModuleFactory处理后的文件,我们暂且统称为Module,在编译过程中都是对Module做相应转换与存储。在compilation内部只会通过compilation.modules记录所有Module,以及通过moduleGraph记录Module之前的依赖关系,真正执行build、codeGeneration时都是调用Module自身模块的对应方法。上面流程图中只画了NormalModuleFactory的处理, ContextModuleFactory的处理逻辑也一样,区别在于每个Module的内部实现不一样。
在normalModuleFactory、contextModuleFactory解析和build之前,都可以调用factory对应的hooks来忽略该文件的处理,比如beforeResolve、factorized、resolve等,注意这些hooks为ModuleFacotry的并不是compilation的,可以通过compilation.normalModuleFacotry.hooks或者compilation.contextModuleFacotry.hooks来注册;webpack.IgnorePlugin插件就是利用了这个特性
在build时,会根据该文件匹配到的loader,执行runLoaders转换文件内容。
在Module被build之后,会在Module对象上记录对应的Dependencies, 然后轮询调用applyFactoryResultDependencies再对Dependencies以及Dependencies的Dependencies进行build,这样所有依赖相关的文件都会被编译并记录到compilation.modules中, 模块间的依赖关系会被收集到moduleGraph中。
可以看看编译后的moduleGraph
moduleGraph
-
exports中记录了该模块导出的语句(用于tree-shaking、splitChunk分析等); -
incomingConnections中维护了该模块被其他模块引入的关系 -
outgoingConnections中维护了该模块与其引入别的模块的对应关系
经过以上的处理,make阶段基本结束,然后进入seal阶段
三、编译阶段二--seal阶段
在make阶段,根据loader已经build所有文件,那在seal阶段做哪些工作呢?
首先回想一下, 在正常情况下,我们在webpack.config.js中配置几个entry,最终就会生成几个bundle。 没错seal阶段就是根据entry配置找到对应的依赖Module,组装整合到一起, 生成对应的chunk;正常情况,有几个entry就会对应几个chunk,但是也有特殊情况,比如不同chunk
之间会存在重复引用某些文件的情况,以及当我们引用一些较大的library时导致最终打出的bundle文件较大时,就需要做一些优化;因此出现了SplitChunksPlugin, 通过webpack.optimization配置以优化上述两种场景,除了生成正常的chunk之外,还会生成额外的chunk。
那么每个chunk中应该包含哪些模块的代码呢? 在addEntry方法中除了编译、收集Module外,还会将entry对应的信息记录到compilation.entries中; 在seal阶段,根据compilation.entries分析dependencies, 将与该入口文件相关的Module都与chunk绑定上关系,该关系最终维护在chunkGraph中,看下其结构
在依赖都聚合、组装、提取之后,根据chunk调用compilation.codeGeneration,找到与该chunk有关系的Module,调用Module自身的codeGeneration生成对应的代码,代码合并之后记录到compilation.assets字段上,最后的最后再调用compiler.emitAssets写到对应的output目录中去。
因此如果想通过插件优化modules或者改变Module与chunk的固有关系,就需要在seal阶段的codeGeneration之前处理。比如SplitChunksPlugin
整体流程如下
至此整个编译流程结束。
我是如何debug源码的
首先clone下来源码,装上对应的依赖,执行编译的examples的命令,还是会报错;命令最终通过webpack-cli去调用webpack,又因为webpack-cli还是在node_modules依赖中,其内部通过require('webpack')方式引入webpack,在node_modules中是没有webpack的,所以最简单的就是把require('webpack')的方式改成相对依赖,引入webpack的lib/index.js就可以了。 然后就可以通过配置vscode debug,单独调试某个example了。个人认为相对于调试其他库,调试webpack是最简单的了😄
~~水平有限,文中有错误之处,还望各位大佬指正