这是我参与「第五届青训营 」伴学笔记创作活动的第 12 天
邂逅Webpack
核心流程解析
首先,我们要理解一个点,Webpack 最核心的功能:
At its core, webpack is a static module bundler for modern JavaScript applications.
也就是将各种类型的资源,包括图片、css、js等,转译、组合、拼接、生成 JS 格式的 bundler 文件。官网首页的动画很形象地表达了这一点:
这个过程核心完成了 内容转换 + 资源合并 两种功能,实现上包含三个阶段:
- 初始化阶段:
- 初始化参数:从配置文件、 配置对象、Shell 参数中读取,与默认配置结合得出最终的参数
- 创建编译器对象:用上一步得到的参数创建
Compiler对象 - 初始化编译环境:包括注入内置插件、注册各种模块工厂、初始化 RuleSet 集合、加载配置的插件等
- 开始编译:执行
compiler对象的run方法 - 确定入口:根据配置中的
entry找出所有的入口文件,调用compilition.addEntry将入口文件转换为dependence对象
- 构建阶段:
- 编译模块(make):根据
entry对应的dependence创建module对象,调用loader将模块转译为标准 JS 内容,调用 JS 解释器将内容转换为 AST 对象,从中找出该模块依赖的模块,再 递归 本步骤直到所有入口依赖的文件都经过了本步骤的处理 - 完成模块编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间的 依赖关系图
- 编译模块(make):根据
- 生成阶段:
- 输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的
Chunk,再把每个Chunk转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会 - 写入文件系统(emitAssets):在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
- 输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的
单次构建过程自上而下按顺序执行,下面会展开聊聊细节,在此之前,对上述提及的各类技术名词不太熟悉的同学,可以先看看简介:
Entry:编译入口,webpack 编译的起点Compiler:编译管理器,webpack 启动后会创建compiler对象,该对象一直存活直到结束退出Compilation:单次编辑过程的管理器,比如watch = true时,运行过程中只有一个compiler但每次文件变更触发重新编译时,都会创建一个新的compilation对象Dependence:依赖对象,webpack 基于该类型记录模块间依赖关系Module:webpack 内部所有资源都会以“module”对象形式存在,所有关于资源的操作、转译、合并都是以 “module” 为基本单位进行的Chunk:编译完成准备输出时,webpack 会将module按特定的规则组织成一个一个的chunk,这些chunk某种程度上跟最终输出一一对应Loader:资源内容转换器,其实就是实现从内容 A 转换 B 的转换器Plugin:webpack构建过程中,会在特定的时机广播对应的事件,插件监听这些事件,在特定时间点介入编译过程
webpack 编译过程都是围绕着这些关键对象展开的,更详细完整的信息,可以参考 Webpack 知识图谱 。
初始化阶段
学习一个项目的源码通常都是从入口开始看起,按图索骥慢慢摸索出套路的,所以先来看看 webpack 的初始化过程:
解释一下:
- 将
process.args + webpack.config.js合并成用户配置 - 调用
validateSchema校验配置 - 调用
getNormalizedWebpackOptions + applyWebpackOptionsBaseDefaults合并出最终配置 - 创建
compiler对象 - 遍历用户定义的
plugins集合,执行插件的apply方法 - 调用
new WebpackOptionsApply().process方法,加载各种内置插件
主要逻辑集中在 WebpackOptionsApply 类,webpack 内置了数百个插件,这些插件并不需要我们手动配置,WebpackOptionsApply 会在初始化阶段根据配置内容动态注入对应的插件,包括:
- 注入
EntryOptionPlugin插件,处理entry配置 - 根据
devtool值判断后续用那个插件处理sourcemap,可选值:EvalSourceMapDevToolPlugin、SourceMapDevToolPlugin、EvalDevToolModulePlugin - 注入
RuntimePlugin,用于根据代码内容动态注入 webpack 运行时
到这里,compiler 实例就被创建出来了,相应的环境参数也预设好了,紧接着开始调用 compiler.compile 函数:
// 取自 webpack/lib/compiler.js
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
// ...
const compilation = this.newCompilation(params);
this.hooks.make.callAsync(compilation, err => {
// ...
this.hooks.finishMake.callAsync(compilation, err => {
// ...
process.nextTick(() => {
compilation.finish(err => {
compilation.seal(err => {...});
});
});
});
});
});
}
复制代码
Webpack 架构很灵活,但代价是牺牲了源码的直观性,比如说上面说的初始化流程,从创建 compiler 实例到调用 make 钩子,逻辑链路很长:
- 启动 webpack ,触发
lib/webpack.js文件中createCompiler方法 createCompiler方法内部调用WebpackOptionsApply插件WebpackOptionsApply定义在lib/WebpackOptionsApply.js文件,内部根据entry配置决定注入entry相关的插件,包括:DllEntryPlugin、DynamicEntryPlugin、EntryPlugin、PrefetchPlugin、ProgressPlugin、ContainerPluginEntry相关插件,如lib/EntryPlugin.js的EntryPlugin监听compiler.make钩子lib/compiler.js的compile函数内调用this.hooks.make.callAsync- 触发
EntryPlugin的make回调,在回调中执行compilation.addEntry函数 compilation.addEntry函数内部经过一坨与主流程无关的hook之后,再调用handleModuleCreate函数,正式开始构建内容
这个过程需要在 webpack 初始化的时候预埋下各种插件,经历 4 个文件,7次跳转才开始进入主题,前戏太足了,如果读者对 webpack 的概念、架构、组件没有足够了解时,源码阅读过程会很痛苦。
关于这个问题,我在文章最后总结了一些技巧和建议,有兴趣的可以滑到附录阅读模块。
构建阶段
基本流程
你有没有思考过这样的问题:
- Webpack 编译过程会将源码解析为 AST 吗?webpack 与 babel 分别实现了什么?
- Webpack 编译过程中,如何识别资源对其他资源的依赖?
- 相对于 grunt、gulp 等流式构建工具,为什么 webpack 会被认为是新一代的构建工具?
这些问题,基本上在构建阶段都能看出一些端倪。构建阶段从 entry 开始递归解析资源与资源的依赖,在 compilation 对象内逐步构建出 module 集合以及 module 之间的依赖关系,核心流程:
解释一下,构建阶段从入口文件开始:
- 调用
handleModuleCreate,根据文件类型构建module子类 - 调用 loader-runner 仓库的
runLoaders转译module内容,通常是从各类资源类型转译为 JavaScript 文本 - 调用 acorn 将 JS 文本解析为AST
- 遍历 AST,触发各种钩子
- 在
HarmonyExportDependencyParserPlugin插件监听exportImportSpecifier钩子,解读 JS 文本对应的资源依赖 - 调用
module对象的addDependency将依赖对象加入到module依赖列表中
- 在
- AST 遍历完毕后,调用
module.handleParseResult处理模块依赖 - 对于
module新增的依赖,调用handleModuleCreate,控制流回到第一步 - 所有依赖都解析完毕后,构建阶段结束
这个过程中数据流 module => ast => dependences => module ,先转 AST 再从 AST 找依赖。这就要求 loaders 处理完的最后结果必须是可以被 acorn 处理的标准 JavaScript 语法,比如说对于图片,需要从图像二进制转换成类似于 export default "data:image/png;base64,xxx" 这类 base64 格式或者 export default "http://xxx" 这类 url 格式。
compilation 按这个流程递归处理,逐步解析出每个模块的内容以及 module 依赖关系,后续就可以根据这些内容打包输出。
示例:层级递进
假如有如下图所示的文件依赖树:
其中 index.js 为 entry 文件,依赖于 a/b 文件;a 依赖于 c/d 文件。初始化编译环境之后,EntryPlugin 根据 entry 配置找到 index.js 文件,调用 compilation.addEntry 函数触发构建流程,构建完毕后内部会生成这样的数据结构:
此时得到 module[index.js] 的内容以及对应的依赖对象 dependence[a.js] 、dependence[b.js] 。OK,这就得到下一步的线索:a.js、b.js,根据上面流程图的逻辑继续调用 module[index.js] 的 handleParseResult 函数,继续处理 a.js、b.js 文件,递归上述流程,进一步得到 a、b 模块:
从 a.js 模块中又解析到 c.js/d.js 依赖,于是再再继续调用 module[a.js] 的 handleParseResult ,再再递归上述流程:
到这里解析完所有模块后,发现没有更多新的依赖,就可以继续推进,进入下一步。
总结
回顾章节开始时提到的问题:
- Webpack 编译过程会将源码解析为 AST 吗?webpack 与 babel 分别实现了什么?
- 构建阶段会读取源码,解析为 AST 集合。
- Webpack 读出 AST 之后仅遍历 AST 集合;babel 则对源码做等价转换
- Webpack 编译过程中,如何识别资源对其他资源的依赖?
- Webpack 遍历 AST 集合过程中,识别
require/ import之类的导入语句,确定模块对其他资源的依赖关系
- Webpack 遍历 AST 集合过程中,识别
- 相对于 grant、gulp 等流式构建工具,为什么 webpack 会被认为是新一代的构建工具?
- Grant、Gulp 仅执行开发者预定义的任务流;而 webpack 则深入处理资源的内容,功能上更强大
生成阶段
基本流程
构建阶段围绕 module 展开,生成阶段则围绕 chunks 展开。经过构建阶段之后,webpack 得到足够的模块内容与模块关系信息,接下来开始生成最终资源了。代码层面,就是开始执行 compilation.seal 函数:
// 取自 webpack/lib/compiler.js
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
// ...
const compilation = this.newCompilation(params);
this.hooks.make.callAsync(compilation, err => {
// ...
this.hooks.finishMake.callAsync(compilation, err => {
// ...
process.nextTick(() => {
compilation.finish(err => {
**compilation.seal**(err => {...});
});
});
});
});
});
}
复制代码
seal 原意密封、上锁,我个人理解在 webpack 语境下接近于 “将模块装进蜜罐” 。seal 函数主要完成从 module 到 chunks 的转化,核心流程:
简单梳理一下:
- 构建本次编译的
ChunkGraph对象; - 遍历
compilation.modules集合,将module按entry/动态引入的规则分配给不同的Chunk对象; compilation.modules集合遍历完毕后,得到完整的chunks集合对象,调用createXxxAssets方法createXxxAssets遍历module/chunk,调用compilation.emitAssets方法将资assets信息记录到compilation.assets对象中- 触发
seal回调,控制流回到compiler对象
这一步的关键逻辑是将 module 按规则组织成 chunks ,webpack 内置的 chunk 封装规则比较简单:
entry及 entry 触达到的模块,组合成一个chunk- 使用动态引入语句引入的模块,各自组合成一个
chunk
chunk 是输出的基本单位,默认情况下这些 chunks 与最终输出的资源一一对应,那按上面的规则大致上可以推导出一个 entry 会对应打包出一个资源,而通过动态引入语句引入的模块,也对应会打包出相应的资源,我们来看个示例。
示例:多入口打包
假如有这样的配置:
const path = require("path");
module.exports = {
mode: "development",
context: path.join(__dirname),
entry: {
a: "./src/index-a.js",
b: "./src/index-b.js",
},
output: {
filename: "[name].js",
path: path.join(__dirname, "./dist"),
},
devtool: false,
target: "web",
plugins: [],
};
复制代码
实例配置中有两个入口,对应的文件结构:
index-a 依赖于c,且动态引入了 e;index-b 依赖于 c/d 。根据上面说的规则:
entry及entry触达到的模块,组合成一个 chunk- 使用动态引入语句引入的模块,各自组合成一个 chunk
生成的 chunks 结构为:
也就是根据依赖关系,chunk[a] 包含了 index-a/c 两个模块;chunk[b] 包含了 c/index-b/d 三个模块;chunk[e-hash] 为动态引入 e 对应的 chunk。
不知道大家注意到没有,chunk[a] 与 chunk[b] 同时包含了 c,这个问题放到具体业务场景可能就是,一个多页面应用,所有页面都依赖于相同的基础库,那么这些所有页面对应的 entry 都会包含有基础库代码,这岂不浪费?为了解决这个问题,webpack 提供了一些插件如 CommonsChunkPlugin 、SplitChunksPlugin,在基本规则之外进一步优化 chunks 结构。
SplitChunksPlugin 的作用
SplitChunksPlugin 是 webpack 架构高扩展的一个绝好的示例,我们上面说了 webpack 主流程里面是按 entry / 动态引入 两种情况组织 chunks 的,这必然会引发一些不必要的重复打包,webpack 通过插件的形式解决这个问题。
回顾 compilation.seal 函数的代码,大致上可以梳理成这么4个步骤:
- 遍历
compilation.modules,记录下模块与chunk关系 - 触发各种模块优化钩子,这一步优化的主要是模块依赖关系
- 遍历
module构建 chunk 集合 - 触发各种优化钩子
上面 1-3 都是预处理 + chunks 默认规则的实现,不在我们讨论范围,这里重点关注第4个步骤触发的 optimizeChunks 钩子,这个时候已经跑完主流程的逻辑,得到 chunks 集合,SplitChunksPlugin 正是使用这个钩子,分析 chunks 集合的内容,按配置规则增加一些通用的 chunk :
module.exports = class SplitChunksPlugin {
constructor(options = {}) {
// ...
}
_getCacheGroup(cacheGroupSource) {
// ...
}
apply(compiler) {
// ...
compiler.hooks.thisCompilation.tap("SplitChunksPlugin", (compilation) => {
// ...
compilation.hooks.optimizeChunks.tap(
{
name: "SplitChunksPlugin",
stage: STAGE_ADVANCED,
},
(chunks) => {
// ...
}
);
});
}
};
复制代码
理解了吗?webpack 插件架构的高扩展性,使得整个编译的主流程是可以固化下来的,分支逻辑和细节需求“外包”出去由第三方实现,这套规则架设起了庞大的 webpack 生态,关于插件架构的更多细节,下面 plugin 部分有详细介绍,这里先跳过。
写入文件系统
经过构建阶段后,compilation 会获知资源模块的内容与依赖关系,也就知道“输入”是什么;而经过 seal 阶段处理后, compilation 则获知资源输出的图谱,也就是知道怎么“输出”:哪些模块跟那些模块“绑定”在一起输出到哪里。seal 后大致的数据结构:
compilation = {
// ...
modules: [
/* ... */
],
chunks: [
{
id: "entry name",
files: ["output file name"],
hash: "xxx",
runtime: "xxx",
entryPoint: {xxx}
// ...
},
// ...
],
};
复制代码
seal 结束之后,紧接着调用 compiler.emitAssets 函数,函数内部调用 compiler.outputFileSystem.writeFile 方法将 assets 集合写入文件系统,实现逻辑比较曲折,但是与主流程没有太多关系,所以这里就不展开讲了。
资源形态流转
OK,上面已经把逻辑层面的构造主流程梳理完了,这里结合资源形态流转的角度重新考察整个过程,加深理解:
-
compiler.make阶段:
entry文件以dependence对象形式加入compilation的依赖列表,dependence对象记录有entry的类型、路径等信息- 根据
dependence调用对应的工厂函数创建module对象,之后读入module对应的文件内容,调用loader-runner对内容做转化,转化结果若有其它依赖则继续读入依赖资源,重复此过程直到所有依赖均被转化为module
-
compilation.seal阶段:
- 遍历
module集合,根据entry配置及引入资源的方式,将module分配到不同的chunk - 遍历
chunk集合,调用compilation.emitAsset方法标记chunk的输出规则,即转化为assets集合
- 遍历
-
compiler.emitAssets阶段:
- 将
assets写入文件系统
- 将