背景
Webpack 特别难学!!!
时至 5.0 版本之后,Webpack 功能集变得非常庞大,包括:模块打包、代码分割、按需加载、HMR、Tree-shaking、文件监听、sourcemap、Module Federation、devServer、DLL、多进程等等,为了实现这些功能,webpack 的代码量已经到了惊人的程度:
498 份JS文件
18862 行注释
73548 行代码
54 个 module 类型
69 个 dependency 类型
162 个内置插件
237 个hook
在这个数量级下,源码的阅读、分析、学习成本非常高,加上 webpack 官网语焉不详的文档,导致 webpack 的学习、上手成本极其高。为此,社区围绕着 Webpack 衍生出了各种手脚架,比如 vue-cli、create-react-app,解决“用”的问题。
但这又导致一个新的问题,大部分人在工程化方面逐渐变成一个配置工程师,停留在“会用会配”但是不知道黑盒里面到底是怎么转的阶段,遇到具体问题就瞎了:
想给基础库做个升级,出现兼容性问题跑不动了,直接放弃
想优化一下编译性能,但是不清楚内部原理,无从下手
究其原因还是对 webpack 内部运行机制没有形成必要的整体认知,无法迅速定位问题 —— 对,连问题的本质都常常看不出,所谓的不能透过现象看本质,那本质是啥?我个人将 webpack 整个庞大的体系抽象为三方面的知识:
构建的核心流程
loader 的作用
plugin 架构与常用套路
三者协作构成 webpack 的主体框架:
理解了这三块内容就算是入了个门,对 Webpack 有了一个最最基础的认知了,工作中再遇到问题也就能按图索骥了。补充一句,作为一份入门教程,本文不会展开太多 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 对象,从中找出该模块依赖的模块,再 递归 本步骤直到所有入口依赖的文件都经过了本步骤的处理
完成模块编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间的 依赖关系图
生成阶段:
输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会
写入文件系统(emitAssets):在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
单次构建过程自上而下按顺序执行,下面会展开聊聊细节,在此之前,对上述提及的各类技术名词不太熟悉的同学,可以先看看简介:
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、ContainerPlugin
Entry 相关插件,如 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
之间的依赖关系,核心流程:
解释一下,构建阶段从入口文件开始:
赖,在 compilation
对象内逐步构建出 module
集合以及 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 之类的导入语句,确定模块对其他资源的依赖关系
相对于 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} // ... }, // ... ], }; 复制代码
eal 结束之后,紧接着调用 compiler.emitAssets 函数,函数内部调用 compiler.outputFileSystem.writeFile 方法将 assets 集合写入文件系统,实现逻辑比较曲折,但是与主流程没有太多关系,所以这里就不展开讲了。
资源形态流转OK,上面已经把逻辑层面的构造主流程梳理完了,这里结合资源形态流转的角度重新考察整个过程,加深理解:
compiler.make • 1
- 阶段:
entry 文件以 dependence 对象形式加入 compilation 的依赖列表,dependence 对象记录有 entry 的类型、路径等信息
根据 dependence 调用对应的工厂函数创建 module 对象,之后读入 module 对应的文件内容,调用 loader-runner 对内容做转化,转化结果若有其它依赖则继续读入依赖资源,重复此过程直到所有依赖均被转化为 module
阶段:
遍历 module 集合,根据 entry 配置及引入资源的方式,将 module 分配到不同的 chunk
遍历 chunk 集合,调用 compilation.emitAsset 方法标记 chunk 的输出规则,即转化为 assets 集合
compiler.emitAssets
- 阶段:
- 将
assets
写入文件系统
Plugin 解析
网上不少资料将 webpack 的插件架构归类为“事件/订阅”模式,我认为这种归纳有失偏颇。订阅模式是一种松耦合架构,发布器只是在特定时机发布事件消息,订阅者并不或者很少与事件直接发生交互,举例来说,我们平常在使用 HTML 事件的时候很多时候只是在这个时机触发业务逻辑,很少调用上下文操作。而 webpack 的钩子体系是一种强耦合架构,它在特定时机触发钩子时会附带上足够的上下文信息,插件定义的钩子回调中,能也只能与这些上下文背后的数据结构、接口交互产生 side effect,进而影响到编译状态和后续流程。
学习插件架构,需要理解三个关键问题:
WHAT: 什么是插件
WHEN: 什么时间点会有什么钩子被触发
HOW: 在钩子回调中,如何影响编译状态
What: 什么是插件
从形态上看,插件通常是一个带有 apply
函数的类:
class SomePlugin { apply(compiler) { } } 复制代码
apply 函数运行时会得到参数 compiler ,以此为起点可以调用 hook 对象注册各种钩子回调,例如: compiler.hooks.make.tapAsync ,这里面 make 是钩子名称,tapAsync 定义了钩子的调用方式,webpack 的插件架构基于这种模式构建而成,插件开发者可以使用这种模式在钩子回调中,插入特定代码。webpack 各种内置对象都带有 hooks 属性,比如 compilation 对象:
class SomePlugin { apply(compiler) { compiler.hooks.thisCompilation.tap('SomePlugin', (compilation) => { compilation.hooks.optimizeChunkAssets.tapAsync('SomePlugin', ()=>{}); }) } } 复制代码
钩子的核心逻辑定义在 Tapable 仓库,内部定义了如下类型的钩子:
const { SyncHook, SyncBailHook, SyncWaterfallHook, SyncLoopHook, AsyncParallelHook, AsyncParallelBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } = require("tapable"); 复制代码
不同类型的钩子根据其并行度、熔断方式、同步异步,调用方式会略有不同,插件开发者需要根据这些的特性,编写不同的交互逻辑,这部分内容也特别多,回头展开聊聊。