Webpack源码解读:理清编译主流程
前言
webpack的熟练使用已成为当代前端工程师必备的生存技能。毋庸置疑,webpack已成为前端构建工具的佼佼者,网络上关于如何使用webpack的技术文档层出不穷。但鲜有能将webpack的构建流程讲清楚的。本文尝试从解读源码以及断点调试的方式,来探究 webpack 是如何一步步的构建资源的。
截至本文发表前,webpack的最新版本为webpack 5.0.0-beta.1
,即本文的源码来自于最新的webpack v5
。
特别说明,本文所列源码均经过精简加工,如果要看具体代码你可以根据我标识的源码文件名访问webpack官方库查看。本文精简部分:
- 删除了模块引入,即
const xxx = require('XXX')
; - 异常兜底代码,虽然异常处理也很重要,但本文主要分析webpack正常工作的主流程,如果异常处理不可忽视,我会特别说明;
如何调试webpack
我一贯认为学习源码并不是硬着头皮去一行行的阅读代码,对于一个成熟的开源项目,必定是存在很多错综复杂的分支走向。试着一步步的调试代码来跟踪程序运行路径,是快速了解一个项目基本架构的最快方式。
VS Code编辑器中完善的Debugger功能是调试Node程序最好利器。
- 首先,为了学习webpack源码,你必须先从webpack库clone一份源码到本地:
git clone https://github.com/webpack/webpack.git
- 安装项目依赖;VS Code打开本地webpack仓库
npm install
cd webpack/
code .
- 为了不污染项目根目录,在根目录下新建
debug
文件夹,用于存放调试代码,debug
文件夹结构如下:
debug-|
|--dist // 打包后输出文件
|--src
|--index.js // 源代码入口文件
|--package.json // debug时需要安装一些loader和plugin
|--start.js // debug启动文件
|--webpack.config.js // webpack配置文件
详细debug代码如下:
//***** debug/src/index.js *****
import is from 'object.is' // 这里引入一个小而美的第三方库,以此观察webpack如何处理第三方包
console.log('很高兴认识你,webpack')
console.log(is(1,1))
//***** debug/start.js *****
const webpack = require('../lib/index.js') // 直接使用源码中的webpack函数
const config = require('./webpack.config')
const compiler = webpack(config)
compiler.run((err, stats)=>{
if(err){
console.error(err)
}else{
console.log(stats)
}
})
//***** debug/webpack.config.js *****
const path = require('path')
module.exports = {
context: __dirname,
mode: 'development',
devtool: 'source-map',
entry: './src/index.js',
output: {
path: path.join(__dirname, './dist'),
},
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
exclude: /node_modules/,
}
]
}
}
- 在VS Code的
Debug
栏添加调试 配置:
{
"configurations": [
{
"type": "node",
"request": "launch",
"name": "启动webpack调试程序",
"program": "${workspaceFolder}/debug/start.js"
}
]
}
配置完成后,试着点击一下 ► (启动) 看看调试程序是否正常运行(如果成功,在debug/dist
中会打包出一个main.js
文件)。
如果你有时间,我希望你能亲手完成一次webpack调试流程,我相信你会有收获的。探索欲是人类的天性。
接下来,通过断点调试,来一步步剖析webpack是如何工作的吧。
源码解读
webpack启动方式
webpack有两种启动方式:
- 通过
webpack-cli
脚手架来启动,即可以在Terminal
终端直接运行;
webpack ./debug/index.js --config ./debug/webpack.config.js
这种方式是最为常用也是最快捷的方式,开箱即用。
- 通过
require('webpack')
引入包的方式执行;
其实第一种方式最终还是会用require
的方式来启动webpack,用兴趣的可以查看./bin/webpack.js
文件。
webpack编译的起点
一切从const compiler = webpack(config)
开始。
webpack函数源码(./lib/webpack.js
):
const webpack = (options, callback) => {
let compiler = createCompiler(options)
// 如果传入callback函数,则自启动
if(callback){
compiler.run((err, states) => {
compiler.close((err2)=>{
callbacl(err || err2, states)
})
})
}
return compiler
}
webpack函数执行后返回compiler
对象,在webpack中存在两个非常重要的核心对象,分别为compiler
和compilation
,它们在整个编译过程中被广泛使用。
- Compiler类(
./lib/Compiler.js
):webpack的主要引擎,在compiler对象记录了完整的webpack环境信息,在webpack从启动到结束,compiler
只会生成一次。你可以在compiler
对象上读取到webpack config
信息,outputPath
等; - Compilation类(
./lib/Compilation.js
):代表了一次单一的版本构建和生成资源。compilation
编译作业可以多次执行,比如webpack工作在watch
模式下,每次监测到源文件发生变化时,都会重新实例化一个compilation
对象。一个compilation
对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。
两者的区别? compiler代表的是不变的webpack环境; compilation代表的是一次编译作业,每一次的编译都可能不同;
举个栗子🌰: compiler就像一条手机生产流水线,通上电后它就可以开始工作,等待生产手机的指令; compliation就像是生产一部手机,生产的过程基本一致,但生产的手机可能是小米手机也可能是魅族手机。物料不同,产出也不同。
Compiler
类在函数createCompiler
中实例化(./lib/index.js
):
const createCompiler = options => {
const compiler = new Compiler(options.context)
// 注册所有的自定义插件
if(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) // process中注册所有webpack内置的插件
return compiler
}
Compiler
类实例化后,如果webpack函数接收了回调callback
,则直接执行compiler.run()
方法,那么webpack自动开启编译之旅。如果未指定callback
回调,需要用户自己调用run
方法来启动编译。
从上面源码中,可以得出一些信息:
-
compiler由
Compiler
实例化,里面的属性和方法后面一节会提到,其中最重要的是compiler.run()
方法; -
遍历
webpack config
中的plugins数组,这里我加粗了plugins数组
,所以配置plugins时不要配成对象了。(事实上,在webpack函数中会对options做object schema
的校验)。 -
plugin
:如果 plugin 是函数,直接调用它;如果 plugin 是其他类型(主要是object类型),执行plugin对象的apply方法。apply函数签名:(compiler) => {}
。webpack非常严格的要求我们plugins数组元素必须是函数,或者一个有apply字段的对象且apply是函数,原因就在于此。
{ plugins: [ new HtmlWebpackPlugin() ] }
-
调用钩子:
compiler.hooks.environment.call()
以及compiler.hooks.afterEnvironment.call()
是源码阅读至此我们最先遇到的钩子调用,在之后的阅读中,你会遇到更多的钩子注册与调用。要理解webpack钩子的应用,需要先了解Tapable
,这是编写插件的基础。关于Tapable,我会”另案处理“它的。 --> 我已经将它记录在案了,快去看看吧 《编写自定义webpack插件从理解Tapable开始》
-
process(options)
:在 webpack config中,除了plugins
还有其他很多的字段呢,那么process(options)
的作用就是一个个的处理这些字段。
至此,我们了解了webpack在初始化阶段做了哪些准备工作。当点燃导火索compiler.run()
时,才是webpack真正强大的时候。”兵马未动,粮草先行“,在此之前,需要先看看new WebpackOptionsApply().process(options, compiler)
做了哪些准备工作,它为后面编译阶段提供了重要的后勤保卫。
process(options, compiler)
WebpackOptionsApply
类的工作就是对webpack options
进行初始化。
打开源码文件lib/WebpackOptionsApply.js
,你会发现前五十行都是各种webpack内置的Plugin
的引入,那么可以猜想process
方法应该是各种各样的new SomePlugin().apply()
的操作,事实就是如此。
精简源码(lib/WebpackOptionsApply.js):
class WebpackOptionsApply extends OptionsApply {
constructor() {
super();
}
process(options, compiler){
// 当传入的配置信息满足要求,处理与配置项相关的逻辑
if(options.target) {
new OnePlugin().apply(compiler)
}
if(options.devtool) {
new AnotherPlugin().apply(compiler)
}
if ...
new JavascriptModulesPlugin().apply(compiler);
new JsonModulesPlugin().apply(compiler);
new ...
compiler.hooks.afterResolvers.call(compiler);
}
}
源码中...
省略号省略了很多相似的操作,process
函数很长,有接近500行左右的代码,主要做了两件事:
-
new
很多的Plugin
,并且apply
它们。在上一小节中,我们知道webpack插件其实就是一个提供apply方法的类,它在合适的时候会被webpack实例化并执行apply方法。而apply方法接收了
compiler
对象,方便在hooks上监听消息。 同时在process
函数中实例化的各个Plugin
都是webpack自己维护的,因此你会发现webpack项目根目录下有很多的以Plugin
结尾的文件。而用户自定义的插件在之前就已经注册完成了。 不同插件有自己不同的使命,它们的职责是钩住compiler.hooks
上的一个消息,一旦某个消息被触发,注册在消息上的回调根据hook类型依次调用。所谓“钩住”的三个方式:tap
tapAsync
tapPromise
,你需要知道Tapable
的工作原理哦。 -
根据
options.xxx
的配置项,做初始化工作,而大多数初始化工作还是在干上面👆的事情
这一小结总结一下:process
函数执行完,webpack将所有它关心的hook消息都注册完成,等待后续编译过程中挨个触发。
执行process
方法装填好弹药,等待大战即发。
compiler.run()
先贴上源码吧(./lib/Compiler.js
):
class Compiler {
constructor(context){
// 所有钩子都是由`Tapable`提供的,不同钩子类型在触发时,调用时序也不同
this.hooks = {
beforeRun: new AsyncSeriesHook(["compiler"]),
run: new AsyncSeriesHook(["compiler"]),
done: new AsyncSeriesHook(["stats"]),
// ...
}
}
// ...
run(callback){
const onCompiled = (err, compilation) => {
if(err) return
const stats = new Stats(compilation);
this.hooks.done.callAsync(stats, err => {
if(err) return
callback(err, stats)
this.hooks.afterDone.call(stats)
})
}
this.hooks.beforeRun.callAsync(this, err => {
if(err) return
this.hooks.run.callAsync(this, err => {
if(err) return
this.compile(onCompiled)
})
})
}
}
通读一遍run
函数过程,你会发现它钩住了编译过程的一些阶段,并在相应阶段去调用已经提前注册好的钩子函数(this.hooks.xxxx.call(this)
),效果与React中生命周期函数是一样的。在run
函数中出现的钩子有:beforeRun --> run --> done --> afterDone
。第三方插件可以钩住不同的生命周期,接收compiler
对象,处理不同逻辑。
run
函数钩住了webpack编译的前期和后期的阶段,那么中期最为关键的代码编译过程就交给了this.compile()
来完成了。在this.comille()
中,另一个主角compilation粉墨登场了。
compiler.compile()
compiler.compile
函数是模块编译的主战场,话不多说,先贴上精简后伪代码:
compile(callback){
const params = this.newCompilationParams() // 初始化模块工厂对象
this.hooks.beforeCompile.callAsync(params, err => {
this.hooks.compile.call(params)
// compilation记录本次编译作业的环境信息
const compilation = new Compilation(this)
this.hooks.make.callAsync(compilation, err => {
compilation.finish(err => {
compilation.seal(err=>{
this.hooks.afterCompile.callAsync(compilation, err => {
return callback(null, compilation)
})
})
})
})
})
}
compile
函数和run
一样,触发了一系列的钩子函数,在compile
函数中出现的钩子有:beforeCompile --> compile --> make --> afterCompile
。
其中make
就是我们关心的编译过程。但在这里它仅是一个钩子触发,显然真正的编译执行是注册在这个钩子的回调上面。
webpack因为有Tapable
的加持,代码编写非常灵活,node中流行的callback回调机制(说的就是回调地狱),webpack使用的炉火纯青,如果用断点调试,可能不太容易捕捉到。这里我使用搜索关键词的方法反向查找make
钩子是在哪里注册的。
通过搜索关键词hooks.make.tapAsync
我们发现在lib/EntryPlugin.js
中找到了它的身影。
依靠搜索关键词,会列出较多干扰项,聪明的你就需要识别出哪个选项才是最接近实际情况的。
此时,我们要倒查一下这个EntryPlugin
是在什么时候被调用的,继续关键词new EntryPlugin
搜索,在lib/EntryOptionPlugin.js
中找到了它,而且其中你发现了熟悉的“东西”:
if(typeof entry === "string" || Array.isArray(entry)){
applyEntryPlugins(entry, "main")
}else if (typeof entry === "object") {
for (const name of Object.keys(entry)) {
applyEntryPlugins(entry[name], name);
}
} else if (typeof entry === "function") {
new DynamicEntryPlugin(context, entry).apply(compiler);
}
还记得在webpack.config.js
中,entry
字段是怎么配置的吗?此时你会明白entry
是字符串或数组时,打包出来的资源统一叫main.js
这个名字了。
我们的回溯还没有结束,继续搜索关键词new EntryOptionPlugin
,Oops,搜索到的文件就是lib/WebpackOptionsApply.js
。如此一切都明了了,make
钩子在process
函数中就已经注册好了,就等着你来调用。
回到lib/EntryPlugin.js
看看compiler.hooks.make.tapAsync
都干了啥。其实就是运行compiliation.addEntry
方法,继续探索compiliation.addEntry
。
addEntry(context, entry, name, callback) {
this.hooks.addEntry.call(entry, name);
// entryDependencies中的每一项都代表了一个入口,打包输出就会有多个文件
let entriesArray = this.entryDependencies.get(name)
entriesArray.push(entry)
this.addModuleChain(context, entry, (err, module) => {
this.hooks.succeedEntry.call(entry, name, module);
return callback(null, module);
})
}
addEntry
的作用是将模块的入口信息传递给模块链中,即addModuleChain
,随后继续调用compiliation.factorizeModule
,这些调用最后会将entry
的入口信息”翻译“成一个模块(严格上说,模块是NormalModule
实例化后的对象)。读这段源码的时候,有点难理解,由于node回调地狱的陷进,我一度以为entry
的处理应该是同步,后来发现process.nextTick
的使用使得很多回调都是异步调用的。建议在这里多断点,多调试,以理解弯弯绕的异步回调。

这里我列出相关函数的调用顺序:this.addEntry --> this.addModuleChain --> this.handleModuleCreation --> this.addModule --> this.buildModule --> this._buildModule --> module.build
(this指代compiliation
)`。
最终会走到NormalModule
对象(./lib/NormalModule.js
)中,执行build
方法。
在normalModule.build
方法中会先调用自身doBuild
方法:
const { runLoaders } = require("loader-runner");
doBuild(options, compilation, resolver, fs, callback){
// runLoaders从包'loader-runner'引入的方法
runLoaders({
resource: this.resource, // 这里的resource可能是js文件,可能是css文件,可能是img文件
loaders: this.loaders,
}, (err, result) => {
const source = result[0];
const sourceMap = result.length >= 1 ? result[1] : null;
const extraInfo = result.length >= 2 ? result[2] : null;
// ...
})
}
其实doBuild
就是选用合适的loader
去加载resource
,目的是为了将这份resource
转换为JS模块(原因是webpack只识别JS模块)。最后返回加载后的源文件source
,以便接下来继续处理。
webpack对处理标准的JS模块很在行,但处理其他类型文件(css, scss, json, jpg)等就无能为力了,此时它就需要loader的帮助。loader的作用就是转换源代码为JS模块,这样webpack就可以正确识别了。
loader
的作用就像是Linux中信息流管道,它接收源码字符串流,加工一下,然后返回加工后的源码字符串交给下一个loader
继续处理。loader
的基本范式:(code, sourceMap, meta) => string
经过了doBuild
后,任何的模块都转换成标准JS模块。
可以试着在js代码中引入css代码,观察一下转换出的标准JS模块的数据结构。
![]()
接下来就是编译标准JS代码了。在传入doBuild
的回调函数中这样处理source
:
const result = this.parser.parse(source)
而这里的this.parser
其实就是JavascriptParser
的实例对象,最终JavascriptParser
会调用第三方包acorn
提供的parse
方法对JS源代码进行语法解析。
parse(code, options){
// 调用第三方插件`acorn`解析JS模块
let ast = acorn.parse(code)
// 省略部分代码
if (this.hooks.program.call(ast, comments) === undefined) {
this.detectStrictMode(ast.body)
this.prewalkStatements(ast.body)
this.blockPrewalkStatements(ast.body)
// 这里webpack会遍历一次ast.body,其中会收集这个模块的所有依赖项,最后写入到`module.dependencies`中
this.walkStatements(ast.body)
}
}
有个线上小工具 AST explorer 可以在线将JS代码转换为语法树AST,将解析器选择为acorn
即可。将调试代码./debug/src/index.js
使用acron
解析一下语法,得到如下的数据结构:

可能你会有些疑惑,通常我们会使用一些类似于babel-loader
等 loader 预处理源文件,那么webpack 在这里的parse
具体作用是什么呢?parse
的最大作用就是收集模块依赖关系,比如调试代码中出现的import {is} from 'object-is'
或const xxx = require('XXX')
的模块引入语句,webpack会记录下这些依赖项,记录在module.dependencies
数组中。
compilation.seal()
至此,从入口文件开始,webpack收集完整了该模块的信息和依赖项,接下来就是如何进一步打包封装模块了。
在执行
compilation.seal
(./lib/Compliation
)之前,你可以打个断点,查看此时compilation.modules
的情况。此时compilation.modules
有三个子模块,分别为./src/index.js
node_modules/object.is/index.js
以及node_modules/object.is/is.is
compilation.seal
的步骤比较多,先封闭模块,生成资源,这些资源保存在compilation.assets
, compilation.chunks
。
你会在多数第三方webpack插件中看到
compilation.assets
和compilation.chunks
的身影。
然后调用compilation.createChunkAssets
方法把所有依赖项通过对应的模板 render 出一个拼接好的字符串:
createChunkAssets(callback){
asyncLib.forEach(
this.chunks,
(chunk, callback) => {
// manifest是数组结构,每个manifest元素都提供了 `render` 方法,提供后续的源码字符串生成服务。至于render方法何时初始化的,在`./lib/MainTemplate.js`中
let manifest = this.getRenderManifest()
asyncLib.forEach(
manifest,
(fileManifest, callback) => {
...
source = fileManifest.render()
this.emitAsset(file, source, assetInfo)
},
callback
)
},
callback
)
}
可以在createChunkAssets
方法体中的this.emitAsset(file, source, assetInfo)
代码行打上断点,观察此时source
中的数据结构。在source._source
字段已经初见打包后源码雏形:

值得一提的是,createChunkAssets
执行过程中,会优先读取cache中是否已经有了相同hash的资源,如果有,则直接返回内容,否则才会继续执行模块生成的逻辑,并存入cache中。
compiler.hooks.emit.callAsync()
在seal执行后,关于模块所有信息以及打包后源码信息都存在内存中,是时候将它们输出为文件了。接下来就是一连串的callback回调,最后我们到达了compiler.emitAssets
方法体中。在compiler.emitAssets
中会先调用this.hooks.emit
生命周期,之后根据webpack config文件的output配置的path属性,将文件输出到指定的文件夹。至此,你就可以在./debug/dist
中查看到调试代码打包后的文件了。
this.hooks.emit.callAsync(compilation, () => {
outputPath = compilation.getPath(this.outputPath, {})
mkdirp(this.outputFileSystem, outputPath, emitFiles)
})
总结
非常感谢你阅读到最后,本文篇幅较长,简单总结一下 webpack 编译模块的基本流程:
- 调用
webpack
函数接收config
配置信息,并初始化compiler
,在此期间会apply
所有 webpack 内置的插件; - 调用
compiler.run
进入模块编译阶段; - 每一次新的编译都会实例化一个
compilation
对象,记录本次编译的基本信息; - 进入
make
阶段,即触发compilation.hooks.make
钩子,从entry
为入口: a. 调用合适的loader
对模块源码预处理,转换为标准的JS模块; b. 调用第三方插件acorn
对标准JS模块进行分析,收集模块依赖项。同时也会继续递归每个依赖项,收集依赖项的依赖项信息,不断递归下去;最终会得到一颗依赖树🌲; - 最后调用
compilation.seal
render 模块,整合各个依赖项,最后输出一个或多个chunk;
以下为简单的时序图:

以上过程并不能完全概括webpack的全部流程,随着webpack.config
配置越来越复杂,webpack会衍生更多的流程去应对不同的情况。
webpack复杂吗?很复杂,Tabable
与Node回调
让整个流程存在多种多样的走向,也因为它的插件系统,让 webpack 高度可配置。
webpack容易吗?也容易,它只做了一件事,编译打包JS模块,并把这件事做到极致完美。
最后
码字不易,如果:
- 这篇文章对你有用,请不要吝啬你的小手为我点赞;
- 有不懂或者不正确的地方,请评论,我会积极回复或勘误;
- 期望与我一同持续学习前端技术知识,请关注我吧;
- 转载请注明出处;
您的支持与关注,是我持续创作的最大动力!
转载自:https://juejin.cn/post/6844903987129352206