背景
虽然现在构建工具日新月异,Vite、Rspack、Turbopack...,但还是有大量的旧项目使用了Wepback,并且面试大概率还是会问Wepback相关的东西,所以这里还是总结一些Webpack相关知识点吧。
Webpack流程概述

该图来自范文杰老师的系列文章。具体哪一篇不记得了,这里随便贴了个链接。
总体介绍
首先要知道的是Webpack的核心功能:可以将各种资源包括图片/css/js等打包成标准的浏览器可以识别的文件。
从功能上可以将Webpack的工作分为三个阶段:
- 初始化阶段
- 初始化参数:会从配置文件、 配置对象、Shell参数中读取参数,然后和默认参数合并得到最终的参数配置。
- 初始化编译环境:用上一步的参数创建
Compiler对象,然后注入内置插件、注册各种module工厂函数、初始化RullSet集合、加载配置的插件Plugins等。 - 确定入口:编译环境确定好之后,会执行
Compiler.run方法,进入编译阶段。然后从配置参数的entry中找到入口文件,调用compilition.addEntry将入口文件转为dependence对象。
- 构建阶段
- 编译模块(make):根据上面的
dependence对象创建module对象,然后调用loader将模块转译成标准的JS内容,然后调用JS解释器将内容转为AST对象,进一步分析出其依赖的模块,然后递归直到所有的依赖都被处理。
NOTE
Webpack5之前只能识别符合JavaScript规范的文本,Webpack5之后添加了其他parser
- 完成模块编译:所有的依赖都处理完成之后,就得到了每个模块转译之后的内容以及
依赖关系图
- 编译模块(make):根据上面的
- 生成阶段
- 输出(seal):根据入口和模块之间的依赖关系,就可以组装成一个个包含多个模块
module的Chunk,然后再将Chunk转换成单独文件加入到输出列表。这里是最后修改输入文件的时机。 - 写入文件系统(emitAsstes):输出内容确定之后,就可以根据配置的输出参数
output配置,将文件内容写入文件系统。
- 输出(seal):根据入口和模块之间的依赖关系,就可以组装成一个个包含多个模块
NOTE
单次构建是从上而下顺序执行的,如果开启了watch,构建完成后不会退出Webpack进程,而是监听文件内容,如果文件变化,重新回到构建阶段重新构建
每个阶段的侧重点都不同,所做的事情也不同,初始化的目的是根据用户配置创建好构建环境;构建阶段主要是解析文件以及依赖的关系;最后的生成阶段重点在按照规则组装、包装模块输出可直接运行的产物包。
对涉及到的对象做一下介绍:
Compiler:编译容器,包含一些编译环境,起到一个管理编译的作用。该对象一直存在,直到退出。Compilation:单次编译过程的管理器。如watch = true时,运行过程只有一个Compiler对象,但是每次重新编译,都会创建一个新的Compilation对象。Dependence:依赖对象,记录着模块间的依赖关系。比如a文件中引入了b文件,在单次编译过程中,b文件会先暂时以dependence对象的形式存在。Module:Webpack内所有资源都以Module对象的形式存在,所有关于资源的操作都是以其为基本单位的。Chunk:编译完成准备输出时,会将Module按照依赖关系组装成Chunk。
详细介绍
初始化阶段Init
首先是配置参数的合并:从配置文件、Shell中拿到用户指定的配置参数,之后会校验配置,之后和默认配置合并,这就得到了所有的配置参数。
然后是编译环境的初始化:在全部的配置参数完毕之后,会调用new Compiler创建Compiler对象。然后遍历用户指定的插件,执行插件的apply方法;之后加载内置插件。
NOTE
内置插件是根据配置内容动态注入的,比如:
EntryOptionPlugin插件用来处理entry配置根据
devtool的值选择插件来处理sourceMap:EvalSourceMapDevToolPlugin/SourceMapDevToolPlugin/EvalDevToolModuelPlugin注入
RuntimePlugin,根据代码内容动态注入Webpack runtime代码
经过上述处理,就创建出了compiler实例,并且编译环境也初始化完成了,之后就会调用compiler.compile函数开始构建。
在compiler.compile函数中,会先创建当前的compilation对象,然后经过一系列函数链路之后,执行compilation.addEntry函数,将入口文件转为dependence对象,最后会执行handleModuleCreate,开始构建内容。
NOTE
每次重新构建都会执行一次compile函数,所以每次构建都是新的compilation对象
在compiler.compile函数中并没有什么实质的功能,但是却搭建起了后续构建的流程。
compile(callback) {
const params = this.newCompilationParams();
// 执行beforeCompile compile两个hook
this.hooks.beforeCompile.callAsync(params, err => {
this.hooks.compile.call(params)
// 创建compilation对象,每次重新构建都会执行,所以compilation会被重复创建
const compilation = this.newCompilation(params);
// 执行make hook 初始化完成 进入构建阶段
this.hooks.make.callAsync(compilation, err => {
// 执行finishMake hook 构建结束
this.hooks.finishMake.callAsync(compilation, err => {
process.nextTick(() => {
compilation.finish(err => {
// 执行seal hook 进入生成阶段
compilation.seal(err => {
// 执行afterCompile hook
this.hooks.afterCompile.callAsync(compilation, err => {
return callback(null, compilation);
});
});
});
});
});
});
});
}可以看到,通过触发各种重要的hook,完成了后续流程:
- beforeComile
(Asynchook) - compile
(Synchook) - make
(Asynchook):初始化完成,进入构建阶段 - finishMake
(Asynchook) - seal
(Synchook):构建完成,进入生成阶段 - afterCompile
(Asynchook)
构建阶段Make
该阶段的目的就是从入口文件开始,通过递归对所有涉及到的资源进行解析处理。
在compile函数中,通过执行make这个hook进入的构建阶段,EntryPlugin会监听该hook从而开始构建工作的。
// EntryPlugin.js 主要逻辑
class EntryPlugin {
apply(compiler) {
const { entry, options, context } = this;
// 将入口文件加入dependency 依赖对象 中
const dep = EntryPlugin.createDependency(entry, options);
// 监听make hook,调用addEntry方法
compiler.hooks.make.tapAsync(
"EntryPlugin",
(compilation, callback) => {
compilation.addEntry(context, dep, options, err => {
callback(err);
});
});
}
}所以构建阶段是通过addEntry触发的,大致过程如下:
- 找到入口文件,从入口文件开始,
handleModuleCreate根据文件类型,调用对应的工厂函数创建出module子类。 - 然后调用
loader-runner中的runLoaders将不同类型的module转译成js格式。 - 接着调用
acorn js解释器通过词法分析、语法分析、语义分析转成AST。 - 之后遍历
AST,分析其对应的依赖(import),调用module.addDependency将依赖对象放到当前module的依赖列表中。在遍历AST时,会触发各种hook:- 遇到
import语句时,触发exportImportSpecifierhook - 还会触发
HarmonyExportDependencyParserPlugin,将依赖的资源添加为Dependency对象
- 遇到
AST遍历之后,调用module.handleParseResult处理模块依赖,即将AST再转回代码。- 对于新增的依赖,调用
handleModuleCreate,回到第一步,开始递归构建子依赖。 - 所有的依赖都构建完成之后,就可以得到依赖关系图。
在这个过程中,要先通过loader将各种资源转成js,然后通过acorn来解析js转成AST,然后分析AST,进行递归构建。构建之后,所有的依赖都转为了Module,并且能获得Module之间的dependence依赖关系。
生成阶段Seal
该阶段主要目的就是将Module按照一定规则组装成Chunk,并且将Chunk转成适合在目标环境运行的产物形态。
在compile函数中,通过执行sealhook进入的该阶段。
构建完成之后,已经有了处理后的文件(Module)以及依赖之间的关系,然后就会执行compilation.seal生成最终的资源。
该阶段主要的工作,就是将文件从Module按规则组装成Chunk,默认规则就是:
entry以及依赖组合成一个Chunk动态引入的组合成一个Chunk
但是默认的规则有些简单,通常是需要添加额外配置的,比如一些公共库、第三方库等是需要单独成Chunk的。
和plugin相关的钩子有:
seal进入生成阶段触发optimizeChunks处理chunks触发,即上述的额外配置,可以通过该hook添加一些chunk的组合规则。
生成chunk之后,遍历chunks转为assets,最后将assets写入文件系统。
入口的entry和Chunk是一一对应的,Chunk和最终的产物也是一一对应的。
小结
整个流程资源形态转化是这样的:
- 编译阶段
compilation.make:将入口文件加入dependency依赖列表,然后根据文件类型调用工厂函数创建module对象,然后调用loader对内容进行处理,直到最后所有依赖都转成module - 生成阶段
compilation.seal:该阶段按照一些规则(默认 + 自定义)将module组合成Chunk,然后将Chunk转成assets资源集合的形式 - 输入资源阶段
compilation.emitAssets:该阶段将assets资源转为文件。
到这里,大致过程就描述的差不多了。不过在每个阶段都会触发一些hook钩子,用来触发Plugins,这也是Webpack的插件架构。
Plugin插件简介
Webpack的插件机制是基于tapable的,所以可以先看一下tapable的描述。
简单描述插件机制就是webpack的构建过程中会触发一些hook的回调,并且提供了一些上下文信息,所以插件可以通过定义这些hook的回调来做一些操作,进而达到影响构建的目的。
一个简单的插件如下:
class SimplePlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap('SimplePlugin', (compilation) => {
compilation.hooks.optimizeChunkAssets.tapAsync('SimplePlugin', () => {
// 触发的回调
})
})
}
}