webpack打包流程浅析
之前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
对应的文件最终会被转换成NormalModule
contextModuleFactory
常用来处理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是最简单的了😄
~~水平有限,文中有错误之处,还望各位大佬指正
转载自:https://juejin.cn/post/7124938898190368775