基础概念
webpack 本质上是一种基于事件流的编程范例,其实就是一系列的插件运行
主要使用 Compiler 和 Compilation 两个类来控制 webpack 的整个生命周期。它们都继承了 Tapabel 并且通过 Tapabel 来注册了生命周期中的每一个流程需要触发的事件。
Tapabel
Tapabel 是一个类似于 Node.js 的 EventEmitter 的库,主要是控制钩子函数的发布与订阅,是 Webpack 插件系统的大管家。
Tapabel 提供的钩子及示例
Tapable 库为插件提供了很多 Hook 以便挂载。
const {
SyncHook, // 同步钩子
SyncBailHook, // 同步熔断钩子
SyncWaterfallHook, // 同步流水钩子
SyncLoopHook, // 同步循环钩子
AsyncParalleHook, // 异步并发钩子
AsyncParallelBailHook, // 异步并发熔断钩子
AsyncSeriesHook, // 异步串行钩子
AsyncSeriesBailHook, // 异步串行熔断钩子
AsyncSeriesWaterfallHook // 异步串行流水钩子
} = require("tapable");
Tabpack 提供了同步&异步绑定钩子的方法,方法如下所示:
| Async | Sync |
|---|---|
| 绑定:tapAsync / tapPromise / tap | 绑定:tap |
| 执行:callAsync / promise | 执行:call |
Tabpack 简单示例
const demohook = new SyncHook(["arg1", "arg2", "arg3"]);
// 绑定事件到webpack事件流
demohook.tap("hook1",(arg1, arg2, arg3) => console.log(arg1, arg2, arg3)) // 1 2 3
// 执行绑定的事件
demohook.call(1,2,3)
Compiler
Compiler 是 Webpack 的核心对象之一,代表整个 Webpack 的编译器。它负责读取和解析配置文件,启动编译过程,并维护整个编译过程中的状态。Compiler 实例在 Webpack 启动时创建,通常只有一个。
以下是 Compiler 的一些主要功能:
- 读取配置:Compiler 负责读取项目中的 Webpack 配置文件(通常是 webpack.config.js),获取配置选项和插件。
- 监听文件变化:Compiler 监听项目中的文件变化,以便在文件发生改变时重新编译应用程序。
- 解析模块:Compiler 使用内置的模块解析器(Module Parser)解析应用程序的模块及其依赖关系。它会根据配置中的规则(rules)和加载器(loaders)对模块进行加载和转换。
- 应用插件:Compiler 通过调用配置中定义的插件,执行各种与编译过程相关的任务。插件可以修改编译过程中的各个阶段,如资源解析、模块转换、代码生成等。
- 生成编译结果:最终,Compiler 会生成包含所有模块处理后的编译结果。这些结果可以是一个或多个输出文件,具体形式和位置由配置中的输出选项指定。
Compilation
Compilation 则是 Compiler 在每次编译过程中创建的对象,代表一次具体的编译过程。每次编译都会生成一个新的 Compilation 实例。在每次编译过程中,Compiler 会创建一个 Compilation 实例,并将当前编译的状态传递给 Compilation。
以下是 Compilation 的一些主要功能:
- 模块构建:Compilation 负责处理和构建所有的模块,包括加载模块、应用加载器转换、解析依赖关系等。
- 资源解析:Compilation 使用解析器(Parser)将模块中的资源(如模块依赖、文件路径等)解析为可使用的数据结构。
- 生成资源:Compilation 将处理后的模块转换为实际的输出资源,如 JavaScript 文件、CSS 文件、图片文件等。
- 错误和警告处理:如果在编译过程中遇到错误或警告,Compilation 负责收集和处理这些信息,并将其报告给开发者。
- 构建生命周期钩子:Webpack 提供了一系列构建生命周期钩子(hooks),Compilation 会触发这些钩子,使插件能够在特定的编译阶段执行自定义逻辑。
webpack Cli
其作用就是将 CLI 参数和 Webpack 配置文件中的配置整合,得到一个完整的配置对象。(这部分操作在 webpack-cli 的入口文件 bin/cli.js 中)
首先,Webpack CLI 会通过 yargs(opens new window) 模块解析 CLI 参数,所谓 CLI 参数指的就是在运行 webpack 命令时通过命令行传入的参数,例如 --mode=production,具体位置如下:

紧接着后面,调用了 bin/utils/convert-argv.js 模块,将得到的命令行参数转换为 Webpack 的配置选项对象,具体操作如下:

在 convert-argv.js 工作过程中,首先为传递过来的命令行参数设置了默认值,然后判断了命令行参数中是否指定了一个具体的配置文件路径,如果指定了就加载指定配置文件,反之则需要根据默认配置文件加载规则找到配置文件,具体代码如下:

找到配置文件过后,将配置文件中的配置和 CLI 参数中的配置合并,如果出现重复的情况,会优先使用 CLI 参数,最终得到一个完整的配置选项。
有了配置选项过后,开始载入 Webpack 核心模块,传入配置选项,创建 Compiler 对象,这个 Compiler 对象就是整个 Webpack 工作过程中最核心的对象了,负责完成整个项目的构建工作。
创建 Compiler 对象
随着 Webpack CLI 载入 Webpack 核心模块,整个执行过程就到了 Webpack 模块中,所以这一部分的代码需要回到 Webpack 模块中,这里分析的是 v4.43.0 版本的 Webpack,可参考这个版本的源代码的固定链接(opens new window)。
同样,这里需要找到这个模块的入口文件,也就是 lib/webpack.js 文件。这个文件导出的是一个用于创建 Compiler 的函数,具体如下:

在这个函数中,首先校验了外部传递过来的 options 参数是否符合要求,紧接着判断了 options 的类型。
如果 options 传入的是普通对象,那么 webpack 会按照最常规的方式创建一个 Compiler 对象,进行单线打包;如果传入的是一个数组,那么 webpack 内部会创建一个 MultiCompiler 开启多路打包,数组中的每一个成员(项)都是一个独立的配置选项;

顺着主线接着往下看,如下图所示:在创建了 Compiler 对象过后,Webpack 在进入生命周期之前, 开始注册配置中的每一个插件,确保插件中的每一个钩子都能被命中。

开始构建
完成 Compiler 对象的创建过后,开始判断配置选项中是否启用了监视模式:

- 如果是监视模式就调用 Compiler 对象的 watch 方法,以监视模式启动构建,但这不是我们主要关心的主线。
- 如果不是监视模式就调用 Compiler 对象的 run 方法,开始构建整个应用。
这个 run 方法定义在 Compiler 类型中,具体文件在 webpack 模块下的 lib/Compiler.js 中,代码位置如下:

这个方法内部就是先触发了 beforeRun 和 run 两个钩子,然后调用了当前对象的 compile 方法,真正开始编译整个项目:

compile 方法内部主要就是创建了一个 Compilation 对象,这个对象我们在插件机制(opens new window)中有提到,Compilation 字面意思是 "合集",实际上,你就可以理解为一次构建过程中的上下文对象,里面包含了这次构建中全部的资源和信息。

创建完 Compilation 对象过后,紧接着触发了一个叫作 make 的钩子,进入整个构建过程最核心的 make 阶段。
源码解读
初始化启动之 Webpack 的入口文件
- 第一步要先找到 Webpack 的入口文件。
- 当通过命令行启动 Webpack 后,npm 会让命令行工具进入 node_modules.bin 目录。
- 然后查找是否存在 webpack.sh 或者 webpack.cmd 文件,如果存在,就执行它们,不存在就会抛出错误。
- 实际的入口文件是:node_modules/webpack/bin/webpack.js,里面的核心函数如下
// node_modules/webpack/bin/webpack.js
// 正常执行返回
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);
// 根据安装数量进行处理
if (installedClis.length === 0) {...} else if
(installedClis.length === 1) {...} else {...}
启动后,Webpack 最终会找到 webpack-cli /webpack-command 的 npm 包,并且 执行 CLI
1. webpack-cli
执行 CLI 以后,webpack-cli 会继续做以下内容
- 引入 yargs,对命令行参数进行分析与转换,组成编译配置项、生成配置选项参数 options,根据参数实例化 webpack 对象,然后执行编译和构建
- 除此之外,回到 node_modules/webpack/lib/webpack.js 里来看一下 Webpack 还做了哪些准备工作。
// node_modules/webpack/lib/webpack.js
const webpack = (options, callback) => {
...
// 将传入的options对象进行处理和默认值设置。
// 它会对options进行合并、转换和验证,确保Webpack选项的正确性和完整性
options = new WebpackOptionsDefaulter().process(options);
// 这一行代码创建了一个Webpack编译器实例,并传入了 context 作为编译的上下文(根目录)。
// 编译器是Webpack的核心组件,负责将输入的资源文件转换为输出的文件束。
compiler = new Compiler(options.context);
// NodeEnvironmentPlugin是一种Webpack插件
// 这一行代码创建了一个NodeEnvironmentPlugin实例(听了beforeRun钩子,它的作用是清除缓存),并将其应用于编译器。
// 它在编译过程中为Webpack提供了Node.js环境相关的功能和特性。
new NodeEnvironmentPlugin().apply(compiler);
...
// 这一行代码通过创建WebpackOptionsApply实例,并调用其process方法,对编译器的选项进行进一步处理和应用。
// 它会根据传入的options和编译器实例,对选项进行合并、转换和扩展,确保选项在编译过程中得到正确的应用。
compiler.options = new WebpackOptionsApply().process(options, compiler);
...
// 以下三行代码都是将变量赋值给 webpack 上对应的属性,方便后续在其他地方使用
webpack.WebpackOptionsDefaulter = WebpackOptionsDefaulter;
webpack.WebpackOptionsApply = WebpackOptionsApply;
...
webpack.NodeEnvironmentPlugin = NodeEnvironmentPlugin;
}
2. WebpackOptionsApply
WebpackOptionsApply 会将所有的配置 options 参数转换成 webpack 内部插件。
使用默认插件列表
- output.library -> LibraryTemplatePlugin
- externals -> ExternalsPlugin
- devtool -> EvalDevtoolModulePlugin, SourceMapDevToolPlugin
- AMDPlugin, CommonJsPlugin
- RemoveEmptyChunksPlugin
// node_modules/webpack/lib/WebpackOptionsApply.js
new EntryOptionPlugin().apply(compiler);
compiler.hooks.entryOption.call(options.context, options.entry);
插件最后都会变成 compiler 对象上的实例。
3. EntryOptionPlugin
进入 EntryOptionPlugin 插件,看看它做了哪些事儿。
// node_modules/webpack/lib/EntryOptionPlugin.js
module.exports = class EntryOptionPlugin {
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;
});
}
};
- 如果是数组,则转换成多个 entry 来处理,如果是对象则转换成一个个 entry 来处理。
- compiler 实例化是 在 node_modules/webpack/lib/webpack.js 里完成的。通过 EntryOptionPlugin 插件进行参数校验。通过 WebpackOptionsDefaulter 将传入的参数和默认参数进行合并成为新的 options,创建 compiler,以及相关 plugin,最后通过
- WebpackOptionsApply 将所有的配置 options 参数转换成 Webpack 内部插件。
- 再次来到我们的 node_modules/webpack/lib/webpack.js 中
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 后会根据 options 的 watch 判断是否启动了 watch,如果启动 watch 了就调用 compiler.watch 来监控构建文件,否则启动 compiler.run 来构建文件。
监听文件变化和不监听文件变化的区别在于 Webpack 是否会实时检测文件的变化并自动重新编译。
当 Webpack 处于监听模式(watch mode)时,它会持续地监视文件系统中的文件变化。当文件发生修改、添加或删除时,Webpack 会自动重新编译受影响的模块和打包输出。这样可以实现实时的开发反馈,即在修改代码后,Webpack 会立即重新构建项目,使得开发人员可以快速查看修改的结果,无需手动运行构建命令。
相比之下,如果不启用监听模式,Webpack 只会在构建命令被执行时进行一次编译,之后就不会再检测文件的变化。这意味着,在文件发生变化后,你需要手动重新运行构建命令才能更新打包输出。
因此,启用监听模式可以提高开发效率,减少手动操作的频率,特别是在开发阶段需要频繁修改和测试代码时。然而,在生产环境中,通常不需要启用监听模式,因为生产环境下的构建通常只需要运行一次,不需要实时监测文件变化并重新编译。
编译构建
compile
首先会实例化 NormalModuleFactory 和 ContextModuleFactory 。然后进入到 run 方法。
// node_modules/webpack/lib/Compiler.js
run(callback) {
...
// beforeRun 如上文NodeEnvironmentPlugin插件清除缓存
this.hooks.beforeRun.callAsync(this, err => {
if (err) return finalCallback(err);
// 执行run Hook开始编译
this.hooks.run.callAsync(this, err => {
if (err) return finalCallback(err);
this.readRecords(err => {
if (err) return finalCallback(err);
// 执行compile
this.compile(onCompiled);
});
});
});
}
在执行 this.hooks.compile 之前会执行 this.hooks.beforeCompile,来对编译之前需要处理的插件进行执行。紧接着 this.hooks.compile 执行后会实例化 Compilation 对象
// node_modules/webpack/lib/compiler.js
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
if (err) return callback(err);
// 进入compile阶段
this.hooks.compile.call(params);
const compilation = this.newCompilation(params);
// 进入make阶段
this.hooks.make.callAsync(compilation, err => {
if (err) return callback(err);
compilation.finish(err => {
if (err) return callback(err);
// 进入seal阶段
compilation.seal(err => {
if (err) return callback(err);
this.hooks.afterCompile.callAsync(compilation, err => {
if (err) return callback(err);
return callback(null, compilation);
})
})
})
})
})
}
1. make
- 通过入口文件(entry)进行模块解析,并生成对应的依赖关系图
- 使用 loader 对模块进行转换与处理,将源码转换成可执行的 js 代码或者其他目标格式
- loader 处理后进行依赖相关内容的更新:添加新模块、更新依赖关系、处理模块间的循环依赖等等
- 一个新的 Compilation 创建完毕,将从 Entry 开始读取文件,根据文件类型和配置的 Loader 对文件进行编译,编译完成后再找出该文件依赖的文件,递归的编译和解析。
- make 钩子被监听的地方:addEntry 是 make 构建阶段真正开始的标志
// node_modules/webpack/lib/SingleEntryPlugin.js
compiler.hooks.make.tapAsync(
"SingleEntryPlugin",
(compilation, callback) => {
const { entry, name, context } = this;
cosnt dep = SingleEntryPlugin.createDependency(entry, name);
// make构建阶段开始标志
compilation.addEntry(context, dep, name, callback);
}
)
addEntry 实际上调用了 _addModuleChain 方法,_addModuleChain 方法将模块添加到依赖列表中去,同时进行模块构建。构建时会执行如下函数
// node_modules/webpack/lib/Compilation.js
// addEntry -> addModuleChain
_addModuleChain(context, dependency, onModule, callback) {
...
this.buildModule(module, false, null, null, err => {
...
})
...
}
如果模块构建完成,会触发 finishModules。
// node_modules/webpack/lib/Compilation.js
finish(callback) {
const modules = this.modules;
this.hooks.finishModules.callAsync(modules, err => {
if (err) return callback(err);
for (let index = 0; index < modules.length; index++) {
const module = modules[index];
this.reportDependencyErrorsAndWarnings(module, [module]);
}
callback();
})
}
1. Module
- Module 包括 NormalModule(普通模块)、ContextModule(./src/a ./src/b)、ExternalModule(module.exports=jQuery)、DelegatedModule(manifest) 以及 MultiModule(entry:['a', 'b'])。
- 本文以 NormalModule(普通模块) 为例子,看一下构建(Compilation)的过程。
使用 loader-runner 运行 loadersLoader 转换完后,使用 acorn 解析生成 AST 使用 ParserPlugins 添加依赖
2. loader-runner
// node_modules/webpack/lib/NormalModule.js
const { getContext, runLoaders } = require("loader-runner");
doBuild(){
...
runLoaders(
...
)
...
}
...
try {
const result = this.parser.parse()
}
doBuild 会去加载资源,doBuild 中会传入资源路径和插件资源去调用 loader-runner 插件的 runLoaders 方法去加载和执行 loader
3. acorn
// node_modules/webpack/lib/Parser.js
const acorn = require("acorn");
使用 acorn 解析转换后的内容,输出对应的抽象语法树(AST)。
// node_modules/webpack/lib/Compilation.js
this.hooks.buildModule.call(module);
...
if (error) {
this.hooks.failedModule.call(module, error);
return callback(error);
}
this.hooks.succeedModule.call(module);
return callback();
- 成功就触发 succeedModule,失败就触发 failedModule。
- 最终将上述阶段生成的产物存放到 Compilation.js 的 this.modules = [] ;上。
完成后就到了 seal 阶段。
4. Chunk 生成算法
- webpack 首先会将 entry 中对应的 module 都生成一个新的 chunk。
- 遍历 module 的依赖列表,将依赖的 module 也加入到 chunk 中。
- 如果一个依赖 module 是动态引入的模块,会根据这个 module 创建一个新的 chunk,继续遍历依赖。
- 重复上面的过程,直至得到所有的 chunk。
2. seal
loader 转换完成后,根据依赖关系生成 chunk,并在过程中做了大量优化工作
- 所有模块及其依赖的模块都通过 Loader 转换完成,根据依赖关系开始生成 Chunk。
- seal 阶段也做了大量的的优化工作,进行了 hash 的创建以及对内容进行生成(createModuleAssets)。
// node_modules/webpack/lib/Compilation.jsthis.createHash();
this.modifyHash();
this.createModuleAssets();
// node_modules/webpack/lib/Compilation.js
createModuleAssets(){
for (let i = 0; i < this.modules.length; i++) {
const module = this.modules[i];
if (module.buildInfo.assets) {
for (const assetName of Object.keys(module.buildInfo.assets)) {
const fileName = this.getPath(assetName);
this.assets[fileName] = module.buildInfo.assets[assetName];
this.hooks.moduleAsset.call(module, fileName);
}
}
}
}
seal 阶段经历了很多的优化,比如 tree shaking 就是在这个阶段执行。最终生成的代码会存放在 Compilation 的 assets 属性上
3. emit
将输出的内容输出到磁盘,创建目录生成文件,文件生成阶段结束。
// node_modules/webpack/lib/compiler.js
this.hooks.emit.callAsync(compilation, err => {
if (err) return callback(err);
outputPath = compilation.getPath(this.outputPath);
this.outputFileSystem.mkdirp(outputPath, emitFiles);
})
总结
- 首先 webpack Cli 会通过 yargs 模块进行 CLI 参数的解析,调用 convert-argv 模块获取配置文件,将配置文件中的配置项与 CLI 参数合并,再将参数转换成完整 webpack 配置选项对象;
- 载入 webpack 核心模块,首先校验 options 参数的合法性以及类型,通过类型来选择进行单线打包还是多路打包,然后创建 Compiler 对象,接着开始注册配置中的每一个插件(在生命周期开始之前)
- 开始构建,首先判断是否启用了监视模式来选择是调用 Compiler 对象的 watch 方法(是)还是 run 方法(否)
- run 方法中,先触发 beforeRun 和 run 两个钩子,然后调用当前对象的 compile 方法,开始对整个项目进行编译
- 创建一个 Compilation 对象(一次构建过程中的上下文对象,包含了这次构建中的全部资源和信息),接着触发 make 的钩子,进入核心阶段。
- make 阶段,根据 entry 配置找到入口模块,递归出所有依赖并生成依赖关系树,将不同的模块交给不同的 loader 进行处理,然后使用 acorn 解析转换后的内容,输出对应的抽象语法树(AST),最后将生成的产物存放到 Compilation 的 modules 上;
- 进入 seal 阶段,根据依赖关系生成 chunk,并在其中做了 tree shaking 等优化工作,将最终生成的代码存放在 Compilation 的 assets 属性上
- 进入 emit 阶段,进行目录的创建以及文件生成
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于