Webpack 核心原理

一文吃透 Webpack 核心原理

主体框架

主体框架

核心流程解析

  1. 初始化阶段
    1. 初始化参数:从配置文件、 配置对象、Shell 参数中读取,与默认配置结合得出最终的参数
    2. 创建编译器对象:用上一步得到的参数创建 Compiler 对象
    3. 初始化编译环境:包括注入内置插件、注册各种模块工厂、初始化 RuleSet 集合、加载配置的插件等
    4. 开始编译:执行 Compiler 对象的 run 方法
    5. 确定入口:根据配置中的 entry 找出所有的入口文件,调用 compilation.addEntry 将入口文件转换为 dependence 对象
  2. 构建阶段
    1. 编译模块(make):根据 entry 对应的 dependence 创建 module 对象,调用 loader 将模块转译为标准 JS 内容,调用 JS 解释器将内容转换为 AST 对象,从中找出该模块依赖的模块,再 递归 本步骤直到所有入口依赖的文件都经过了本步骤的处理
    2. 完成模块编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间的 依赖关系图
  3. 生成阶段
    1. 输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会
    2. 写入文件系统(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 构建过程中,会在特定的时机广播对应的事件,插件监听这些事件,在特定时间点介入编译过程

初始化阶段

初始化阶段

构建阶段

构建阶段

生成阶段

seal 原意密封、上锁,我个人理解在 webpack 语境下接近于 “将模块装进蜜罐” 。seal 函数主要完成从 modulechunks 的转化,核心流程:

生成阶段

资源形态流转

资源形态流转

  • compiler.make phase:
    • entry 文件以 dependence 对象形式加入 compilation 的依赖列表,dependence 对象记录有 entry 的类型、路径等信息
    • 根据 dependence 调用对应的工厂函数创建 module 对象,之后读入 module 对应的文件内容,调用 loader-runner 对内容做转化,转化结果若有其它依赖则继续读入依赖资源,重复此过程直到所有依赖均被转化为 module
  • compilation.seal phase:
    • 遍历 module 集合,根据 entry 配置及引入资源的方式,将 module 分配到不同的 chunk
    • 遍历 chunk 集合,调用 compilation.emitAsset 方法标记 chunk 的输出规则,即转化为 assets 集合
  • compiler.emitAssets phase:
    • assets 写入文件系统

Webpack 插件架构深度讲解

[源码解读] Webpack 插件架构深度讲解

Tapable 全解析

Tapable 是 Webpack 插件架构的核心支架,但它的源码量其实很少,本质上就是围绕着 订阅/发布 模式叠加各种特化逻辑,适配 webpack 体系下复杂的事件源-处理器之间交互需求,比有些场景需要支持将前一个处理器的结果传入下一个回调处理器;有些场景需要支持异步并行调用这些回调处理器。

基本用法

Tapable 使用时通常需要经历如下步骤:

  • 创建钩子实例
  • 调用订阅接口注册回调,包括:taptapAsynctapPromise
  • 调用发布接口触发回调,包括:callcallAsyncpromise

example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { SyncHook } = require("tapable");

// 1. 创建钩子实例
const sleep = new SyncHook();

// 2. 调用订阅接口注册回调
sleep.tap("test", () => {
console.log("callback A");
});

// 3. 调用发布接口触发回调
sleep.call();

// 运行结果:
// callback A

示例中使用 tap 注册回调,使用 call 触发回调,在某些钩子中还可以使用异步风格的 tapAsync/callAsync、promise 风格 tapPromise/promise,具体使用哪一类函数与钩子类型有关。

Tapable 钩子类型

名称 简介 统计
SyncHook 同步钩子 Webpack 共出现 86 次,如 Compiler.hooks.compilation
SyncBailHook 同步熔断钩子 Webpack 共出现 90 次,如 Compiler.hooks.shouldEmit
SyncWaterfallHook 同步瀑布流钩子 Webpack 共出现 26 次,如 Compilation.hooks.assetPath
SyncLoopHook 同步循环钩子 Webpack 中未使用
AsyncParallelHook 异步并行钩子 Webpack 仅出现 6 次:Compiler.hooks.make
AsyncParallelBailHook 异步并行熔断钩子 Webpack 中未使用
AsyncSeriesHook 异步串行钩子 Webpack 共出现 32 次,如 Compiler.hooks.done
AsyncSeriesBailHook 异步串行熔断钩子 Webpack 共出现 9 次,如 Compilation.hooks.optimizeChunkModules
AsyncSeriesLoopHook 异步串行循环钩子 Webpack 中未使用
AsyncSeriesWaterfallHook 异步串行瀑布流钩子 Webpack 共出现 3 次,如 ContextModuleFactory.hooks.beforeResolve

按回调逻辑,分为:

  • 基本类型,名称不带 Waterfall/Bail/Loop 关键字,与通常 「订阅/回调」 模式相似,按钩子注册顺序,逐次调用回调
    1. waterfall 类型:前一个回调的返回值会被带入下一个回调
    2. bail 类型:逐次调用回调,若有任何一个回调返回非 undefined 值,则终止后续调用
    3. loop 类型:逐次、循环调用,直到所有回调函数都返回 undefined
  • 第二个维度,按执行回调的并行方式,分为:
    1. sync :同步执行,启动后会按次序逐个执行回调,支持 call/tap 调用语句
    2. async :异步执行,支持传入 callback 或 promise 风格的异步回调函数,支持 callAsync/tapAsyncpromise/tapPromise 两种调用语句

Dependency Graph 深度解析

有点难的 Webpack 知识点:Dependency Graph 深度解析

webpack 处理应用代码时,会从开发者提供的 entry 开始递归地组建起包含所有模块的 Dependency Graph ,之后再将这些 module 打包为 bundles

Dependency Graph

本节将深入 webpack 源码,解读 Dependency Graph 的内在数据结构及依赖关系收集过程。在正式展开之前,有必要回顾几个 webpack 重要的概念:

  • Module :资源在 webpack 内部的映射对象,包含了资源的路径、上下文、依赖、内容等信息
  • Dependency :在模块中引用其它模块,例如 import "a.js" 语句,webpack 会先将引用关系表述为 Dependency 子类并关联 module 对象,等到当前 module 内容都解析完毕之后,启动下次循环开始将 Dependency 对象转换为适当的 Module 子类。
  • Chunk :用于组织输出结构的对象,webpack 分析完所有模块资源的内容,构建出完整的 Dependency Graph 之后,会根据用户配置及 Dependency Graph 内容构建出一个或多个 chunk 实例,每个 chunk 与最终输出的文件大致上是一一对应的。

数据结构

Webpack 4.x 的 Dependency Graph 实现较简单,主要由 Dependence/Module 内置的系列属性记录引用、被引用关系。

而 Webpack 5.0 之后则实现了一套相对复杂的类结构记录模块间依赖关系,将模块依赖相关的逻辑从 Dependence/Module 解耦为一套独立的类型结构,主要类型有:

  • ModuleGraph :记录 Dependency Graph 信息的容器,一方面保存了构建过程中涉及到的所有 module 、dependency 对象,以及这些对象互相之间的引用;另一方面提供了各种工具方法,方便使用者迅速读取出 moduledependency 附加的信息
  • ModuleGraphConnection :记录模块间引用关系的数据结构,内部通过 originModule 属性记录引用关系中的父模块,通过 module 属性记录子模块。此外还提供了一系列函数工具用于判断对应的引用关系的有效性
  • ModuleGraphModule :Module 对象在 Dependency Graph 体系下的补充信息,包含模块对象的 incomingConnections —— 指向模块本身的 ModuleGraphConnection 集合,即谁引用了模块自己;outgoingConnections —— 该模块对外的依赖,即该模块引用了其他那些模块。

Dependency Graph

  • ModuleGraph 对象通过 _dependencyMap 属性记录 Dependency 对象与 ModuleGraphConnection 连接对象之间的映射关系,后续的处理中可以基于这层映射迅速找到 Dependency 实例对应的引用与被引用者
  • ModuleGraph 对象通过 _moduleMapmodule 基础上附加 ModuleGraphModule 信息,而 ModuleGraphModule 最大的作用就是记录了模块的引用与被引用关系,后续的处理可以基于该属性找到 module 实例的所有依赖与被依赖关系

ModuleGraphModuleGraphConnectionModuleGraphModule 三者协作,在 webpack 构建过程(make 阶段)中逐步收集模块间的依赖关系

依赖收集过程

  • addDependency :webpack 从模块内容中解析出引用关系后,创建适当的 Dependency 子类并调用该方法记录到 module 实例
  • handleModuleCreation :模块解析完毕后,webpack 遍历父模块的依赖集合,调用该方法创建 Dependency 对应的子模块对象,之后调用 compilation.moduleGraph.setResolvedModule 方法将父子引用信息记录到 ModuleGraph 对象上

实例解析

实例解析

Webpack 启动后,在构建阶段递归调用 compilation.handleModuleCreation 函数,逐步补齐 Dependency Graph 结构,最终可能生成如下数据结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
ModuleGraph: {
_dependencyMap: Map(3){
{
EntryDependency{request: "./src/index.js"} => ModuleGraphConnection{
module: NormalModule{request: "./src/index.js"},
// 入口模块没有引用者,故设置为 null
originModule: null
}
},
{
HarmonyImportSideEffectDependency{request: "./src/a.js"} => ModuleGraphConnection{
module: NormalModule{request: "./src/a.js"},
originModule: NormalModule{request: "./src/index.js"}
}
},
{
HarmonyImportSideEffectDependency{request: "./src/a.js"} => ModuleGraphConnection{
module: NormalModule{request: "./src/b.js"},
originModule: NormalModule{request: "./src/index.js"}
}
}
},

_moduleMap: Map(3){
NormalModule{request: "./src/index.js"} => ModuleGraphModule{
incomingConnections: Set(1) [
// entry 模块,对应 originModule 为null
ModuleGraphConnection{ module: NormalModule{request: "./src/index.js"}, originModule:null }
],
outgoingConnections: Set(2) [
// 从 index 指向 a 模块
ModuleGraphConnection{ module: NormalModule{request: "./src/a.js"}, originModule: NormalModule{request: "./src/index.js"} },
// 从 index 指向 b 模块
ModuleGraphConnection{ module: NormalModule{request: "./src/b.js"}, originModule: NormalModule{request: "./src/index.js"} }
]
},
NormalModule{request: "./src/a.js"} => ModuleGraphModule{
incomingConnections: Set(1) [
ModuleGraphConnection{ module: NormalModule{request: "./src/a.js"}, originModule: NormalModule{request: "./src/index.js"} }
],
// a 模块没有其他依赖,故 outgoingConnections 属性值为 undefined
outgoingConnections: undefined
},
NormalModule{request: "./src/b.js"} => ModuleGraphModule{
incomingConnections: Set(1) [
ModuleGraphConnection{ module: NormalModule{request: "./src/b.js"}, originModule: NormalModule{request: "./src/index.js"} }
],
// b 模块没有其他依赖,故 outgoingConnections 属性值为 undefined
outgoingConnections: undefined
}
}
}

module.issuer

十分钟精进 Webpack:module.issuer 属性详解

在 webpack 实现上,文件资源使用 Module 类管理,所有关于资源的操作、转译、合并、关系都在 module 实现。而 module.issuer 属性用于记录资源的引用者,例如对于下面的资源依赖:

modole issuer 资源依赖

index 引用了 a/b 两个文件,webpack 构建时会用三个 module 对象分别对应三个文件,同时在 a/b 模块中通过 issuer 属性指向 index 模块:

  • module['a.js'].issuer = module['index.js']
  • module['b.js'].issuer = module['index.js']
    通过 issuer 属性,模块可以反向查找到引用者。

实例: Stats 类

Stats 是 webpack 内置的对象,用于收集构建过程信息,比如耗时、模块依赖关系、错误信息、报警信息等,我们运行 webpack 命令输出的命令行信息就是由 Stats 类提供的

如果编译过程发生错误,Stats 会通过 module.issuer 属性逐级往上查找出完整调用堆栈

Stats

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Stats {
constructor(compilation) {
// ...
}
toJson(options, forToString) {
const formatError = (e) => {
// ...
if (showModuleTrace && e.origin) {
text += `\n @ ${this.formatFilePath(
e.origin.readableIdentifier(requestShortener)
)}`;
// ...
while (current.issuer) {
current = current.issuer;
text += `\n @ ${current.readableIdentifier(requestShortener)}`;
}
}
return text;
};
}
}

Stats 错误堆栈

何时修改 issuer

compilation 解析出 index.js 内容的 AST 后,遍历 require/import 语句解读当前模块引用了那些资源,解析到任意依赖后就会调用 addModuleDependencies 记录依赖关系,从 addModuleDependencies 源码看在依赖被创建为 module 时,会同步修改新模块的 issuer ,记录引用者的信息。

Webpack Chunk 分包规则详解

Webpack Chunk 分包规则详解

webpack 实现中,原始的资源模块以 Module 对象形式存在、流转、解析处理。

Chunk 则是输出产物的基本组织单位,在生成阶段 webpack 按规则将 entry 及其它 Module 插入 Chunk 中,之后再由 SplitChunksPlugin 插件根据优化规则与 ChunkGraphChunk 做一系列的变化、拆解、合并操作,重新组织成一批性能(可能)更高的 Chunks 。运行完毕之后 webpack 继续将 chunk 一一写入物理文件中,完成编译工作。

综上,Module 主要作用在 webpack 编译过程的前半段,解决原始资源“「如何读」”的问题;而 Chunk 对象则主要作用在编译的后半段,解决编译产物“「如何写」”的问题,两者合作搭建起 webpack 搭建主流程。

默认分包规则

Webpack 4 之后编译过程大致上可以拆解为四个阶段

四个阶段

在构建(make) 阶段,webpack 从 entry 出发根据模块间的引用关系(require/import) 逐步构建出模块依赖关系图(ModuleDependencyGraph),依赖关系图表达了模块与模块之间互相引用的先后次序,基于这种次序 webpack 就可以推断出模块运行之前需要先执行那些依赖模块,也就可以进一步推断出那些模块应该打包在一起,那些模块可以延后加载(异步执行),关于模块依赖图的更多信息,可以参考我另一篇文章 《有点难的 webpack 知识点:Dependency Graph 深度解析》。

到了生成(seal) 阶段,webpack 会根据模块依赖图的内容组织分包 —— Chunk 对象,默认的分包规则有:

  • 同一个 entry 下触达到的模块组织成一个 chunk
  • 异步模块单独组织为一个 chunk
  • entry.runtime 单独组织成一个 chunk

默认规则集中在 compilation.seal 函数实现,seal 核心逻辑运行结束后会生成一系列的 ChunkChunkGroupChunkGraph 对象,后续如 SplitChunksPlugin 插件会在 Chunk 系列对象上做进一步的拆解、优化,最终反映到输出上才会表现出复杂的分包结果。

Entry 分包处理

重点:seal 阶段遍历 entry 对象,为每一个 entry 单独生成 chunk,之后再根据模块依赖图将 entry 触达到的所有模块打包进 chunk 中。

在生成阶段,Webpack 首先根据遍历用户提供的 entry 属性值,为每一个 entry 创建 Chunk 对象,比如对于如下配置:

1
2
3
4
5
6
module.exports = {
entry: {
main: "./src/main",
home: "./src/home",
},
};

Webpack 遍历 entry 对象属性并创建出 chunk[main] 、chunk[home] 两个对象,此时两个 chunk 分别包含 main 、home 模块:

chunk[main] 、chunk[home]

初始化完毕后,Webpack 会读取 ModuleDependencyGraph 的内容,将 entry 所对应的内容塞入对应的 chunk (发生在 webpack/lib/buildChunkGrap.js 文件)。比如对于如下文件依赖:

entry 所对应的内容塞入对应的 chunk

main.js 以同步方式直接或间接引用了 a/b/c/d 四个文件,分析 ModuleDependencyGraph 过程会逐步将 a/b/c/d 模块逐步添加到 chunk[main] 中,最终形成:

Initial chunk

异步模块分包处理

重点:分析 ModuleDependencyGraph 时,每次遇到异步模块都会为之创建单独的 Chunk 对象,单独打包异步模块。

Webpack 4 之后,只需要用异步语句 require.ensure("./xx.js")import("./xx.js") 方式引入模块,就可以实现模块的动态加载,这种能力本质也是基于 Chunk 实现的。

Webpack 生成阶段中,遇到异步引入语句时会为该模块单独生成一个 chunk 对象,并将其子模块都加入这个 chunk 中。例如对于下面的例子:

1
2
3
4
5
// index.js, entry 文件
import "sync-a";
import "sync-b";

import("async-c");

index.js 中,以同步方式引入 sync-async-b;以异步方式引入 async-a 模块;同时,在 async-c 中以同步方式引入 sync-c 模块。对应的模块依赖如:

async-c

此时,webpack 会为入口 index.js、异步模块 async-a.js 分别创建分包,形成如下数据:

创建分包

里需要引入一个新的概念 —— Chunk 间的父子关系。由 entry 生成的 Chunk 之间相互孤立,没有必然的前后依赖关系,但异步生成的 Chunk 则不同,引用者(上例 index.js 块)需要在特定场景下使用被引用者(上例 async-a 块),两者间存在单向依赖关系,在 webpack 中称引用者为 parent、被引用者为 child,分别存放在 ChunkGroup._parents 、ChunkGroup._children 属性中。

上述分包方案默认情况下会生成两个文件:

  • 入口 index 对应的 index.js
  • 异步模块 async-a 对应的 src_async-a_js.js

运行时,webpack 在 index.js 中使用 promise 及 __webpack_require__.e 方法异步载入并运行文件 src_async-a_js.js ,从而实现动态加载。

PS: 基于异步模块的 chunk 在 webpack 官方文档中,通常称之为 「Async chunk」

Runtime 分包

重点: Webpack 5 之后还能根据 `entry.runtime` 配置单独打包运行时代码。

除了 entry、异步模块外,webpack 5 之后还支持基于 runtime 的分包规则。除业务代码外,Webpack 编译产物中还需要包含一些用于支持 webpack 模块化、异步加载等特性的支撑性代码,这类代码在 webpack 中被统称为 runtime。举个例子,产物中通常会包含如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/******/ (() => {
// webpackBootstrap
/******/ var __webpack_modules__ = {}; // The module cache
/************************************************************************/
/******/ /******/ var __webpack_module_cache__ = {}; // The require function
/******/

/******/ /******/ function __webpack_require__(moduleId) {
/******/ /******/ __webpack_modules__[moduleId](
module,
module.exports,
__webpack_require__
); // Return the exports of the module
/******/

/******/ /******/ return module.exports;
/******/
} // expose the modules object (__webpack_modules__)
/******/

/******/ /******/ __webpack_require__.m =
__webpack_modules__; /* webpack/runtime/compat get default export */
/******/

// ...
})();

编译时,Webpack 会根据业务代码决定输出那些支撑特性的运行时代码(基于 Dependency 子类),例如:

  • 需要 __webpack_require__.f__webpack_require__.r 等功能实现最起码的模块化支持
  • 如果用到动态加载特性,则需要写入 __webpack_require__.e 函数
  • 如果用到 Module Federation 特性,则需要写入 __webpack_require__.o 函数
  • 等等

虽然每段运行时代码可能都很小,但随着特性的增加,最终结果会越来越大,特别对于多 entry 应用,在每个入口都重复打包一份相似的运行时代码显得有点浪费,为此 webpack 5 专门提供了 entry.runtime 配置项用于声明如何打包运行时代码。用法上只需在 entry 项中增加字符串形式的 runtime 值,例如:

1
2
3
4
5
module.exports = {
entry: {
index: { import: "./src/index", runtime: "solid-runtime" },
},
};

Webpack 执行完 entry、异步模块分包后,开始遍历 entry 配置判断是否带有 runtime 属性,如果有则创建以 runtime 值为名的 Chunk,因此,上例配置将生成两个chunkchunk[index.js]chunk[solid-runtime],并据此最终产出两个文件:

  • 入口 index 对应的 index.js 文件
  • 运行时配置对应的 solid-runtime.js 文件

在多 entry 场景中,只要为每个 entry 都设定相同的 runtime 值,webpack 运行时代码最终就会集中写入到同一个 chunk,例如对于如下配置:

入口 indexhome 共享相同的 runtime ,最终生成三个 chunk,分别为:

Runtime 分包

同时生成三个文件:

  • 入口 index 对应的 index.js
  • 入口 index 对应的 home.js
  • 运行时代码对应的 solid-runtime.js

分包规则的问题

至此,webpack 分包规则的基本逻辑就介绍完毕了,实现上,大部分功能代码都集中在:

  • webpack/lib/compilation.js 文件的 seal 函数
  • webpack/lib/buildChunkGraph.js 的 buildChunkGraph 函数

默认分包规则最大的问题是无法解决模块重复,如果多个 chunk 同时包含同一个 module,那么这个 module 会被不受限制地重复打包进这些 chunk。比如假设我们有两个入口 main/index 同时依赖了同一个模块:

main/index

默认情况下,webpack 不会对此做额外处理,只是单纯地将 c 模块同时打包进 main/index 两个 chunk,最终形成:

 c 被重复打包

可以看到 chunk 间互相孤立,模块 c 被重复打包,对最终产物可能造成不必要的性能损耗!

为了解决这个问题,webpack 3 引入 CommonChunkPlugin 插件试图将 entry 之间的公共依赖提取成单独的 chunk,但 CommonChunkPlugin 本质上是基于 Chunk 之间简单的父子关系链实现的,很难推断出提取出的第三个包应该作为 entry 的父 chunk 还是子 chunkCommonChunkPlugin 统一处理为父 chunk,某些情况下反而对性能造成了不小的负面影响。

在 webpack 4 之后则引入了更负责的设计 —— ChunkGroup 专门实现关系链管理,配合 SplitChunksPlugin 能够更高效、智能地实现 「启发式分包」

彻底理解 Webpack 运行时

彻底理解 Webpack 运行时

编译产物分析

为了正常、正确运行业务项目,Webpack 需要将开发者编写的业务代码以及支撑、调配这些业务代码的「运行时」一并打包到产物(bundle)中,以建筑作类比的话,业务代码相当于砖瓦水泥,是看得见摸得着能直接感知的逻辑;运行时相当于掩埋在砖瓦之下的钢筋地基,通常不会关注但决定了整座建筑的功能、质量。

大多数 Webpack 特性都需要特定钢筋地基才能跑起来,比如说:

异步按需加载

  • HMR
  • WASM
  • Module Federation

基本结构

1
2
3
4
5
6
// a.js
export default "a module";

// index.js
import name from "./a";
console.log(name);

使用如下配置:

1
2
3
4
5
6
7
8
9
module.exports = {
entry: "./src/index",
mode: "development",
devtool: false,
output: {
filename: "[name].js",
path: path.join(__dirname, "./dist"),
},
};

直接看编译生成的结果

编译生成的结果

虽然看起来很非主流,但细心分析还是能拆解出代码脉络的,bundle 整体由一个 IIFE 包裹,里面的内容从上到下依次为:

  • __webpack_modules__ 对象,包含了除入口外的所有模块,示例中即 a.js 模块
  • __webpack_module_cache__ 对象,用于存储被引用过的模块
  • __webpack_require__ 函数,实现模块引用(require) 逻辑
  • __webpack_require__.d ,工具函数,实现将模块导出的内容附加的模块对象上
  • __webpack_require__.o ,工具函数,判断对象属性用
  • __webpack_require__.r ,工具函数,在 ESM 模式下声明 ESM 模块标识
  • 最后的 IIFE ,对应 entry 模块即上述示例的 index.js ,用于启动整个应用

这几个 __webpack_ 开头奇奇怪怪的函数可以统称为 Webpack 运行时代码,作用如前面所说的是搭起整个业务项目的骨架,就上述简单示例所罗列出来的几个函数、对象而言,它们协作构建起一个简单的模块化体系从而实现 ES Module 规范所声明的模块化特性。

上述示例中最终的函数是 __webpack_require__,它实现了模块间引用功能,核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function __webpack_require__(moduleId) {
/******/ // 如果模块被引用过
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/
}
/******/ // Create a new module (and put it into the cache)
/******/ var module = (__webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {},
/******/
});
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](
module,
module.exports,
__webpack_require__
);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/
}

从代码可以推测出,它的功能:

根据 moduleId 参数找到对应的模块代码,执行并返回结果
如果 moduleId 对应的模块被引用过,则直接返回存储在 __webpack_module_cache__ 缓存对象中的导出内容,避免重复执行

其中,业务模块代码被存储在 bundle 最开始的 __webpack_modules__ 变量中,内容如:

1
2
3
4
5
6
7
8
9
var __webpack_modules__ = {
"./src/a.js": (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) => {
// ...​
},
};

结合 __webpack_require__ 函数与 __webpack_modules__ 变量就可以正确地引用到代码模块,例如上例生成代码最后面的IIFE

1
2
3
4
5
6
7
8
9
(() => {
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
/* harmony import */ var _a__WEBPACK_IMPORTED_MODULE_0__ =
__webpack_require__(/*! ./a */ "./src/a.js");

console.log(_a__WEBPACK_IMPORTED_MODULE_0__.name);
})();

这几个函数、对象构成了 Webpack 运行时最基本的能力 —— 模块化,它们的生成规则与原理我们放到文章第二节《实现原理》再讲,下面我们继续看看异步模块加载、模块热更新场景下对应的运行时内容。

异步模块加载
我们来看个简单的异步模块加载示例:

1
2
3
4
5
// ./src/a.js
export default "module-a";

// ./src/index.js
import("./a").then(console.log);

使用异步模块加载特性时,会额外增加如下运行时:

__webpack_require__.e :逻辑上包裹了一层中间件模式与 promise.all ,用于异步加载多个模块
__webpack_require__.f :供 __webpack_require__.e 使用的中间件对象,例如使用 Module Federation 特性时就需要在这里注册中间件以修改 e 函数的执行逻辑
__webpack_require__.u :用于拼接异步模块名称的函数
__webpack_require__.l :基于 JSONP 实现的异步模块加载函数
__webpack_require__.p :当前文件的完整 URL,可用于计算异步模块的实际 URL

建议读者运行示例对比实际生成代码,感受它们的具体功能。这几个运行时模块构建起 Webpack 异步加载能力,其中最核心的是 __webpack_require__.e 函数,它的代码很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
__webpack_require__.f = {};
/******/ // This file contains only the entry chunk.
/******/ // The chunk loading function for additional chunks
/******/ __webpack_require__.e = (chunkId) => {
/******/ return Promise.all(
Object.keys(__webpack_require__.f).reduce((promises, key) => {
/******/ __webpack_require__.f[key](chunkId, promises);
/******/ return promises;
/******/
}, [])
);
/******/
};

从代码看,只是实现了一套基于 __webpack_require__.f 的中间件模式,以及用 Promise.all 实现并行处理,实际加载工作由 __webpack_require__.f.j__webpack_require__.l实现,分开来看两个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/******/  __webpack_require__.f.j = (chunkId, promises) => {
/******/ // JSONP chunk loading for javascript
/******/ var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
/******/ if(installedChunkData !== 0) { // 0 means "already installed".
/******/
/******/ // a Promise means "currently loading".
/******/ if(installedChunkData) {
/******/ promises.push(installedChunkData[2]);
/******/ } else {
/******/ if(true) { // all chunks have JS
/******/ // ...
/******/ // start chunk loading
/******/ var url = __webpack_require__.p + __webpack_require__.u(chunkId);
/******/ // create error before stack unwound to get useful stacktrace later
/******/ var error = new Error();
/******/ var loadingEnded = ...;
/******/ __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
/******/ } else installedChunks[chunkId] = 0;
/******/ }
/******/ }
/******/ };

__webpack_require__.f.j 实现了异步 chunk 路径的拼接、缓存、异常处理三个方面的逻辑,而 __webpack_require__.l 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/******/    var inProgress = {};
/******/ // data-webpack is not used as build has no uniqueName
/******/ // loadScript function to load a script via script tag
/******/ __webpack_require__.l = (url, done, key, chunkId) => {
/******/ if(inProgress[url]) { inProgress[url].push(done); return; }
/******/ var script, needAttach;
/******/ if(key !== undefined) {
/******/ var scripts = document.getElementsByTagName("script");
/******/ /​/ ...
/******/ }
/******/ //​ ...
/******/ inProgress[url] = [done];
/******/ var onScriptComplete = (prev, event) => {
/******/ //​ ...
/******/ }
/******/ ;
/******/ var timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000);
/******/ script.onerror = onScriptComplete.bind(null, script.onerror);
/******/ script.onload = onScriptComplete.bind(null, script.onload);
/******/ needAttach && document.head.appendChild(script);
/******/ };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/******/    var inProgress = {};
/******/ // data-webpack is not used as build has no uniqueName
/******/ // loadScript function to load a script via script tag
/******/ __webpack_require__.l = (url, done, key, chunkId) => {
/******/ if(inProgress[url]) { inProgress[url].push(done); return; }
/******/ var script, needAttach;
/******/ if(key !== undefined) {
/******/ var scripts = document.getElementsByTagName("script");
/******/ /​/ ...
/******/ }
/******/ //​ ...
/******/ inProgress[url] = [done];
/******/ var onScriptComplete = (prev, event) => {
/******/ //​ ...
/******/ }
/******/ ;
/******/ var timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000);
/******/ script.onerror = onScriptComplete.bind(null, script.onerror);
/******/ script.onload = onScriptComplete.bind(null, script.onload);
/******/ needAttach && document.head.appendChild(script);
/******/ };

__webpack_require__.l中通过 script 实现异步 chunk 内容的加载与执行。

e + l + f.j 三个运行时函数支撑起 Webpack 异步模块运行的能力,落到实际用法上只需要调用 e 函数即可完成异步模块加载、运行,例如上例对应生成的 entry 内容:

1
2
3
4
5
6
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
__webpack_require__
.e(/*! import() */ "src_a_js")
.then(__webpack_require__.bind(__webpack_require__, /*! ./a */ "./src/a.js"));

模块热更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module.exports = {
entry: "./src/index",
mode: "development",
devtool: false,
output: {
filename: "[name].js",
path: path.join(__dirname, "./dist"),
},
// 简单起见,这里使用 HtmlWebpackPlugin 插件自动生成作为 host 的 html 文件
plugins: [
new HtmlWebpackPlugin({
title: "Hot Module Replacement",
}),
],
// 配置 devServer 属性,启动 HMR
devServer: {
contentBase: "./dist",
hot: true,
writeToDisk: true,
},

按照上述配置,使用命令 webpack serve --hot-only 启动 Webpack,就可以在 dist 文件夹找到产物:

HMR产物

  • 支持 HMR 所需要用到的 webpack-dev-server 、webpack/hot/xxx 、querystring 等框架,这一部分占了大部分代码
  • __webpack_require__.l :与异步模块加载一样,基于 JSONP 实现的异步模块加载函数
  • __webpack_require__.e :与异步模块加载一样
  • __webpack_require__.f :与异步模块加载一样
  • __webpack_require__.hmrF: 用于拼接热更新模块 url 的函数
  • webpack/runtime/hot :这不是单个对象或函数,而是包含了一堆实现模块替换的方法

可以看到, HMR 运行时是上面异步模块加载运行时的超集,而异步模块加载的运行时又是第一个基本示例运行时的超集,层层叠加。在 HMR 中包含了:

  • 模块化能力
  • 异步模块加载能力 —— 实现变更模块的异步加载
  • 热替换能力 —— 用拉取到的新模块替换掉旧的模块,并触发热更新事件

实现原理

  • 除了业务代码外,bundle 中还必须包含「运行时」代码才能正常运行
  • 「运行时的具体内容由业务代码,确切地说由业务代码所使用到的特性决定」,例如使用到异步加载时需要打包 __webpack_require__.e 函数,那么这里面必然有一个运行时依赖收集的过程
  • 开发者编写的业务代码会被包裹进恰当的运行时函数中,实现整体协调

落到 Webpack 源码实现上,运行时的生成逻辑可以划分为两个步骤:

  1. 「依赖收集」:遍历业务代码模块收集模块的特性依赖,从而确定整个项目对 Webpack runtime 的依赖列表
  2. 「生成」:合并 runtime 的依赖列表,打包到最终输出的 bundle

两个步骤都发生在打包阶段,即 Webpack(v5) 源码的 compilation.seal 函数中:

compilation.seal

进入 runtime 处理环节时 Webpack 已经解析得出 ModuleDependencyGraphChunkGraph 关系,也就意味着此时已经可以计算出:

  • 需要输出那些 chunk
  • 每个 chunk 包含那些 module,以及每个 module 的内容
  • chunkchunk 之间的父子依赖关系

bundlemodulechunk

基于这些信息,接下来首先需要收集运行时依赖。

依赖收集

Webpack runtime 的依赖概念上很像 Vue 的依赖,都是用来表达模块对其它模块存在依附关系,只是实现方法上 Vue 基于动态、在运行过程中收集,而 Webpack 则基于静态代码分析的方式收集依赖。实现逻辑大致为:

依赖收集

运行时依赖的计算逻辑集中在 compilation.processRuntimeRequirements 函数,代码上包含三次循环:

第一次循环遍历所有 module,收集所有 moduleruntime 依赖
第二次循环遍历所有 chunk,将 chunk 下所有 moduleruntime 统一收录到 chunk
第三次循环遍历所有 runtime chunk,收集其对应的子 chunk 下所有 runtime 依赖,之后遍历所有依赖并发布 runtimeRequirementInTree 钩子,(主要是) RuntimePlugin 插件订阅该钩子并根据依赖类型创建对应的 RuntimeModule 子类实例

第一次循环:收集模块依赖

在打包(seal)阶段,完成 ChunkGraph 的构建之后,Webpack 会紧接着调用 codeGeneration 函数遍历 module 数组,调用它们的 module.codeGeneration 函数执行模块转译,模块转译结果如:

模块转译结果

其中,sources 属性为模块经过转译后的结果;而 runtimeRequirements 则是基于 AST 计算出来的,为运行该模块时所需要用到的运行时,计算过程与本文主题无关,挖个坑下一回我们再继续讲。

所有模块转译完毕后,开始调用 compilation.processRuntimeRequirements 进入第一重循环,将上述转译结果的 runtimeRequirements 记录到 ChunkGraph 对象中。

第二次循环:整合 chunk 依赖

第一次循环针对 module 收集依赖,第二次循环则遍历 chunk 数组,收集将其对应所有 moduleruntime 依赖,例如:

收集将其对应所有 module 的 runtime 依赖

示例图中,module a 包含两个运行时依赖;module b 包含一个运行时依赖,则经过第二次循环整合后,对应的 chunk 会包含两个模块对应的三个运行时依赖。

第三次循环:依赖标识转 RuntimeModule 对象

源码中,第三次循环的代码最少但逻辑最复杂,大致上执行三个操作:

  • 遍历所有 runtime chunk,收集其所有子 chunkruntime 依赖
  • 为该 runtime chunk 下的所有依赖发布 runtimeRequirementInTree 钩子
  • RuntimePlugin 监听钩子,并根据 runtime 依赖的标识信息创建对应的 RuntimeModule 子类对象,并将对象加入到 ModuleDependencyGraphChunkGraph 体系中管理

至此,runtime 依赖完成了从 module 内容解析,到收集,到创建依赖对应的 Module 子类,再将 Module 加入到 ModuleDependencyGraph /ChunkGraph 体系的全流程,业务代码及运行时代码对应的模块依赖关系图完全 ready,可以准备进入下一阶段 —— 生成最终产物。

但在继续讲解产物逻辑之前,我们有必要先解决两个问题:

  • 何谓 runtime chunk?与普通 chunk 是什么关系
  • 何谓 RuntimeModule?与普通 Module 有什么区别

回顾一下在三种特定的情况下,Webpack 会创建新的 chunk:

  • 每个 entry 项都会对应生成一个 chunk 对象,称之为 initial chunk
  • 每个异步模块都会对应生成一个 chunk 对象,称之为 async chunk
  • Webpack 5 之后,如果 entry 配置中包含 runtime 值,则在 entry 之外再增加一个专门容纳 runtimechunk 对象,此时可以称之为 runtime chunk

默认情况下 initial chunk 通常包含运行该 entry 所需要的所有 runtime 代码,但 webpack 5 之后出现的第三条规则打破了这一限制,允许开发者将 runtimeinitial chunk 中剥离出来独立为一个多 entry 间可共享的 runtime chunk

类似的,异步模块对应 runtime 代码大部分都被包含在对应的引用者身上,比如说:

1
2
3
4
5
6
// a.js
export default "a-module";

// index.js
// 异步引入 a 模块
import("./a").then(console.log);

在这个示例中,index 异步引入 a 模块,那么按默认分配规则会产生两个 chunk:入口文件 index 对应的 initial chunk、异步模块 a 对应的 async chunk。此时从 ChunkGraph 的角度看 chunk[index]chunk[a] 的父级,运行时代码会被打入 chunk[index],站在浏览器的角度,运行 chunk[a] 之前必须先运行 chunk[index] ,两者形成明显的父子关系。

在最开始阅读 Webpack 源码的时候,我就觉得很奇怪,Module 是 Webpack 资源管理的基本单位,但 Module 底下总共衍生出了 54 个子类,且大部分为 Module => RuntimeModule => xxxRuntimeModule 的继承关系:

Module => RuntimeModule => xxxRuntimeModule

有点难的 webpack 知识点:Dependency Graph 深度解析 一文中我们聊到模块依赖关系图的生成过程及作用,但篇文章的内容是围绕业务代码展开的,用到的大多是 NormalModule 。到 seal 函数收集运行时的过程中,RuntimePlugin 还会为运行时依赖一一创建对应的 RuntimeModule 子类,例如:

  • 模块化实现中依赖 __webpack_require__.r ,则对应创建 MakeNamespaceObjectRuntimeModule 对象
  • ESM 依赖 __webpack_require__.o ,则对应创建 HasOwnPropertyRuntimeModule 对象
  • 异步模块加载依赖 __webpack_require__.e,则对应创建 EnsureChunkRuntimeModule 对象
  • 等等

所以可以推导出所有 RuntimeModule 结尾的类型与特定的运行时功能一一对应,收集依赖的结果就是在业务代码之外创建出一堆支撑性质的 RuntimeModule 子类,这些子类对象随后被加入 ModuleDependencyGraph ,并入整个模块依赖体系中。

资源合并生成

经过上面的运行时依赖收集过程后,bundles 所需要的所有内容都就绪了,接着就可以准备写出到文件中,即下图核心流程中的生成(emit)阶段:

核心流程中的生成(`emit`)阶段

Webpack-核心原理对这一块有比较细致的讲解,这里从运行时的视角再简单聊一下代码流程:

调用 compilation.createChunkAssets ,遍历 chunkschunk 对应的所有 module,包括业务模块、运行时模块全部合并成一个资源(Source 子类)对象
调用 compilation.emitAsset 将资源对象挂载到 compilation.assets 属性中
调用 compiler.emitAssetsassets 全部写到 FileSystem
发布 compiler.hooks.done 钩子
运行结束

如何编写 loader

如何编写 loader

Loader 基础

代码层面,Loader 通常是一个函数,结构如下:

1
2
3
4
module.exports = function (source, sourceMap?, data?) {
// source 为 loader 的输入,可能是文件内容,也可能是上一个 loader 处理结果
return source;
};

Loader 函数接收三个参数,分别为:

  • source:资源输入,对于第一个执行的 loader 为资源文件的内容;后续执行的 loader 则为前一个 loader 的执行结果
  • sourceMap: 可选参数,代码的 sourcemap 结构
  • data: 可选参数,其它需要在 Loader 链中传递的信息,比如 posthtml/posthtml-loader 就会通过这个参数传递参数的 AST 对象

其中 source 是最重要的参数,大多数 Loader 要做的事情就是将 source 转译为另一种形式的 output

1
2
3
4
5
6
7
8
9
10
11
12
export default function rawLoader(source) {
// ...

const json = JSON.stringify(source)
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");

const esModule =
typeof options.esModule !== "undefined" ? options.esModule : true;

return `${esModule ? "export default" : "module.exports ="} ${json};`;
}

这段代码的作用是将文本内容包裹成 JavaScript 模块

返回多个结果

上例通过 return 语句返回处理结果,除此之外 Loader 还可以以 callback 方式返回更多信息,供下游 Loader 或者 Webpack 本身使用

1
2
3
4
5
export default function loader(content, map) {
// ...
linter.printOutput(linter.lint(content));
this.callback(null, content, map);
}

通过 this.callback(null, content, map) 语句同时返回转译后的内容与 sourcemap 内容。callback 的完整签名如下:

1
2
3
4
5
6
7
8
9
10
11
this.callback(
// 异常信息,Loader 正常运行时传递 null 值即可
err: Error | null,
// 转译结果
content: string | Buffer,
// 源码的 sourcemap 信息
sourceMap?: SourceMap,
// 任意需要在 Loader 间传递的值
// 经常用来传递 ast 对象,避免重复解析
data?: any
);

异步处理

涉及到异步或 CPU 密集操作时,Loader 中还可以以异步形式返回处理结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import less from "less";

async function lessLoader(source) {
// 1. 获取异步回调函数
const callback = this.async();
// ...

let result;

try {
// 2. 调用less 将模块内容转译为 css
result = await (options.implementation || less).render(data, lessOptions);
} catch (error) {
// ...
}

const { css, imports } = result;

// ...

// 3. 转译结束,返回结果
callback(null, css, map);
}

export default lessLoader;

在 less-loader 中,逻辑分三步:

  • 调用 this.async 获取异步回调函数,此时 Webpack 会将该 Loader 标记为异步加载器,会挂起当前执行队列直到 callback 被触发
  • 调用 less 库将 less 资源转译为标准 css
  • 调用异步回调 callback 返回处理结果

this.async 返回的异步回调函数签名与上一节介绍的 this.callback 相同,此处不再赘述。

缓存

Loader 为开发者提供了一种便捷的扩展方法,但在 Loader 中执行的各种资源内容转译操作通常都是 CPU 密集型 —— 这放在单线程的 Node 场景下可能导致性能问题;又或者异步 Loader 会挂起后续的加载器队列直到异步 Loader 触发回调,稍微不注意就可能导致整个加载器链条的执行时间过长。

为此,默认情况下 Webpack 会缓存 Loader 的执行结果直到资源或资源依赖发生变化,开发者需要对此有个基本的理解,必要时可以通过 this.cachable 显式声明不作缓存,例如:

1
2
3
4
5
module.exports = function (source) {
this.cacheable(false);
// ...
return output;
};

上下文与 Side Effect

除了作为内容转换器外,Loader 运行过程还可以通过一些上下文接口,有限制地影响 Webpack 编译过程,从而产生内容转换之外的副作用。

上下文信息可通过 this 获取,this 对象由 NormolModule.createLoaderContext 函数在调用 Loader 前创建,常用的接口包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const loaderContext = {
// 获取当前 Loader 的配置信息
getOptions: (schema) => {},
// 添加警告
emitWarning: (warning) => {},
// 添加错误信息,注意这不会中断 Webpack 运行
emitError: (error) => {},
// 解析资源文件的具体路径
resolve(context, request, callback) {},
// 直接提交文件,提交的文件不会经过后续的chunk、module处理,直接输出到 fs
emitFile: (name, content, sourceMap, assetInfo) => {},
// 添加额外的依赖文件
// watch 模式下,依赖文件发生变化时会触发资源重新编译
addDependency(dep) {},
};

其中,addDependencyemitFileemitErroremitWarning 都会对后续编译流程产生副作用,例如 less-loader 中包含这样一段代码:

1
2
3
4
5
6
7
8
9
10
try {
result = await (options.implementation || less).render(data, lessOptions);
} catch (error) {
// ...
}
const { css, imports } = result;
imports.forEach((item) => {
// ...
this.addDependency(path.normalize(item));
});

解释一下,代码中首先调用 less 编译文件内容,之后遍历所有 import 语句,也就是上例 result.imports 数组,一一调用 this.addDependency 函数将 import 到的其它资源都注册为依赖,之后这些其它资源文件发生变化时都会触发重新编译。

Loader 链式调用

使用上,可以为某种资源文件配置多个 Loader,Loader 之间按照配置的顺序从前到后(pitch),再从后到前依次执行,从而形成一套内容转译工作流,例如对于下面的配置:

1
2
3
4
5
6
7
8
9
10
module.exports = {
module: {
rules: [
{
test: /\.less$/i,
use: ["style-loader", "css-loader", "less-loader"],
},
],
},
};

这是一个典型的 less 处理场景,针对 .less 后缀的文件设定了:less、css、style 三个 loader 协作处理资源文件,按照定义的顺序,Webpack 解析 less 文件内容后先传入 less-loader;less-loader 返回的结果再传入 css-loader 处理;css-loader 的结果再传入 style-loader;最终以 style-loader 的处理结果为准,流程简化后如:

三个 loader 协作处理资源文件

上述示例中,三个 Loader 分别起如下作用:

  • less-loader:实现 less => css 的转换,输出 css 内容,无法被直接应用在 Webpack 体系下
  • css-loader:将 css 内容包装成类似 module.exports = "${css}" 的内容,包装后的内容符合 JavaScript 语法
  • style-loader: 做的事情非常简单,就是将 css 模块包进 require 语句,并在运行时调用 injectStyle 等函数将内容注入到页面的 style 标签

三个 Loader 分别完成内容转化工作的一部分,形成从右到左的调用链条。链式调用这种设计有两个好处,一是保持单个 Loader 的单一职责,一定程度上降低代码的复杂度;二是细粒度的功能能够被组装成复杂而灵活的处理链条,提升单个 Loader 的可复用性。

不过,这只是链式调用的一部分,这里面有两个问题:

  • oader 链条一旦启动之后,需要所有 Loader 都执行完毕才会结束,没有中断的机会 —— 除非显式抛出异常
  • 某些场景下并不需要关心资源的具体内容,但 Loader 需要在 source 内容被读取出来之后才会执行

为了解决这两个问题,Webpack 在 loader 基础上叠加了 pitch 的概念。

Loader Pitch

Webpack 允许在这个函数上挂载名为 pitch 的函数,运行时 pitch 会比 Loader 本身更早执行,例如:

1
2
3
4
5
6
7
8
9
10
const loader = function (source) {
console.log("后执行");
return source;
};

loader.pitch = function (requestString) {
console.log("先执行");
};

module.exports = loader;

Pitch 函数的完整签名:

1
2
3
4
5
function pitch(
remainingRequest: string,
previousRequest: string,
data = {}
): void {}
  • remainingRequest : 当前 loader 之后的资源请求字符串
  • previousRequest : 在执行当前 loader 之前经历过的 loader 列表
  • data : 与 Loader 函数的 data 相同,用于传递需要在 Loader 传播的信息

这些参数不复杂,但与 requestString 紧密相关,我们看个例子加深了解:

1
2
3
4
5
6
{
test: /\.less$/i,
use: [
"style-loader", "css-loader", "less-loader"
],
},

css-loader.pitch 中拿到的参数依次为:

1
2
3
4
5
6
// css-loader 之后的 loader 列表及资源路径
remainingRequest = less-loader!./xxx.less
// css-loader 之前的 loader 列表
previousRequest = style-loader
// 默认值
data = {}

调度逻辑

Pitch 翻译成中文是抛、球场、力度、事物最高点等,我觉得 pitch 特性之所以被忽略完全是这个名字的锅,它背后折射的是一整套 Loader 被执行的生命周期概念。

实现上,Loader 链条执行过程分三个阶段:pitch、解析资源、执行,设计上与 DOM 的事件模型非常相似,pitch 对应到捕获阶段;执行对应到冒泡阶段;而两个阶段之间 Webpack 会执行资源内容的读取、解析操作,对应 DOM 事件模型的 AT_TARGET 阶段:

Loader 被执行的生命周期

pitch 阶段按配置顺序从左到右逐个执行 loader.pitch 函数(如果有的话),开发者可以在 pitch 返回任意值中断后续的链路的执行:

中断 Loader 被执行的生命周期

那么为什么要设计 pitch 这一特性呢?「阻断」

示例:style-loader

实际上, style-loader 只是负责让 css 能够在浏览器环境下跑起来,本质上并不需要关心具体内容,很适合用 pitch 来处理,核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// ...
// Loader 本身不作任何处理
const loaderApi = () => {};

// pitch 中根据参数拼接模块代码
loaderApi.pitch = function loader(remainingRequest) {
//...

switch (injectType) {
case "linkTag": {
return `${
esModule
? `...`
: // 引入 runtime 模块
`var api = require(${loaderUtils.stringifyRequest(
this,
`!${path.join(__dirname, "runtime/injectStylesIntoLinkTag.js")}`
)});
// 引入 css 模块
var content = require(${loaderUtils.stringifyRequest(
this,
`!!${remainingRequest}`
)});

content = content.__esModule ? content.default : content;`
} // ...`;
}

case "lazyStyleTag":
case "lazySingletonStyleTag": {
//...
}

case "styleTag":
case "singletonStyleTag":
default: {
// ...
}
}
};

export default loaderApi;
  • loaderApi 为空函数,不做任何处理
  • loaderApi.pitch 中拼接结果,导出的代码包含:
    • 引入运行时模块 runtime/injectStylesIntoLinkTag.js
  • 复用 remainingRequest 参数,重新引入 css 文件
1
2
3
// 运行结果大致如
var api = require("xxx/style-loader/lib/runtime/injectStylesIntoLinkTag.js");
var content = require("!!css-loader!less-loader!./xxx.less");

注意了,到这里 style-loaderpitch 函数返回这一段内容,后续的 Loader 就不会继续执行,当前调用链条中断了:

`style-loader`当前调用链条中断

之后,Webpack 继续解析、构建 style-loader 返回的结果,遇到 inline loader 语句:

var content = require('!!css-loader!less-loader!./xxx.less');

所以从 Webpack 的角度看,实际上对同一个文件调用了两次 loader 链,第一次在 style-loaderpitch 中断,第二次根据 inline loader 的内容跳过了 style-loader

产物转译打包逻辑

Webpack 原理系列八:产物转译打包逻辑

Dependency-Graph-深度解析
经过 「构建(make)阶段」 后,Webpack 解析出:

  • module 内容
  • modulemodule 之间的依赖关系图

而进入 「生成(「「seal」」)阶段」 后,Webpack 首先根据模块的依赖关系、模块特性、entry配置等计算出 Chunk Graph,确定最终产物的数量和内容

本文继续聊聊 Chunk Graph 后面之后,模块开始转译到模块合并打包的过程,大体流程如下:

模块开始转译到模块合并打包的过程

为了方便理解,我将打包过程横向切分为三个阶段:

  • 「入口」:指代从 Webpack 启动到调用 compilation.codeGeneration 之前的所有前置操作
  • 「模块转译」:遍历 modules 数组,完成所有模块的转译操作,并将结果存储到 compilation.codeGenerationResults 对象
  • 「模块合并打包」:在特定上下文框架下,组合业务模块、runtime 模块,合并打包成 bundle ,并调用 compilation.emitAsset 输出产物

这里说的 「业务模块」 是指开发者所编写的项目代码;runtime 模块」 是指 Webpack 分析业务模块后,动态注入的用于支撑各项特性的运行时代码. 彻底理解-Webpack-运行时

模块转译原理

Webpack 产物

上述示例由 index.js / name.js 两个业务文件组成,对应的 Webpack 配置如上图左下角所示;Webpack 构建产物如右边 main.js 文件所示,包含三块内容,从上到下分别为:

  • name.js 模块对应的转译产物,函数形态
  • Webpack 按需注入的运行时代码
  • index.js 模块对应的转译产物,IIFE(立即执行函数) 形态

以看到产物与源码语义、功能均相同,但表现形式发生了较大变化,例如 index.js 编译前后的内容:

运行时代码的作用与生成逻辑

上图右边是 Webpack 编译产物中对应的代码,相对于左边的源码有如下变化:

  • 整个模块被包裹进 IIFE (立即执行函数)中
  • 添加 __webpack_require__.r(__webpack_exports__); 语句,用于适配 ESM 规范
  • 源码中的 import 语句被转译为 __webpack_require__ 函数调用
  • 源码 console 语句所使用的 name 变量被转译为 _name__WEBPACK_IMPORTED_MODULE_0__.default
  • 添加注释

核心流程

「模块转译」 操作从 module.codeGeneration 调用开始,对应到上述流程图的:

「模块转译」

  • 调用 JavascriptGenerator 的对象的 generate 方法,方法内部:
    1. 遍历模块的 dependenciespresentationalDependencies 数组
    2. 执行每个数组项 dependency 对象的对应的 Template.apply 方法,在 apply 内修改模块代码,或更新 initFragments 数组
  • 遍历完毕后,调用 InitFragment.addToSource 静态方法,将上一步操作产生的 source 对象与 initFragments 数组合并为模块产物

简单说就是遍历依赖,在依赖对象中修改 module 代码,最后再将所有变更合并为最终产物。这里面关键点:

  1. Template.apply 函数中,如何更新模块代码
  2. InitFragment.addToSource 静态方法中,如何将 Template.apply 所产生的 side effect 合并为最终产物

Template.apply 函数

上述流程中,JavascriptGenerator 类是毋庸置疑的 C 位角色,但它并不直接修改 module 的内容,而是绕了几层后委托交由 Template 类型实现。

Webpack 5 源码中,JavascriptGenerator.generate 函数会遍历模块的 dependencies 数组,调用依赖对象对应的 Template 子类 apply 方法更新模块内容,说起来有点绕,原始代码更饶,所以我将重要步骤抽取为如下伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class JavascriptGenerator {
generate(module, generateContext) {
// 先取出 module 的原始代码内容
const source = new ReplaceSource(module.originalSource());
const { dependencies, presentationalDependencies } = module;
const initFragments = [];
for (const dependency of [...dependencies, ...presentationalDependencies]) {
// 找到 dependency 对应的 template
const template = generateContext.dependencyTemplates.get(dependency.constructor);
// 调用 template.apply,传入 source、initFragments
// 在 apply 函数可以直接修改 source 内容,或者更改 initFragments 数组,影响后续转译逻辑
template.apply(dependency, source, {initFragments})
}
// 遍历完毕后,调用 InitFragment.addToSource 合并 source 与 initFragments
return InitFragment.addToSource(source, initFragments, generateContext);
}
}

// Dependency 子类
class xxxDependency extends Dependency {}

// Dependency 子类对应的 Template 定义
const xxxDependency.Template = class xxxDependencyTemplate extends Template {
apply(dep, source, {initFragments}) {
// 1. 直接操作 source,更改模块代码
source.replace(dep.range[0], dep.range[1] - 1, 'some thing')
// 2. 通过添加 InitFragment 实例,补充代码
initFragments.push(new xxxInitFragment())
}
}

从上述伪代码可以看出,JavascriptGenerator.generate 函数的逻辑相对比较固化:

  1. 初始化一系列变量
  2. 遍历 module 对象的依赖数组,找到每个 dependency 对应的 template 对象,调用 template.apply 函数修改模块内容
  3. 调用 InitFragment.addToSource 方法,合并 sourceinitFragments 数组,生成最终结果

这里的重点是 JavascriptGenerator.generate 函数并不操作 module 源码,它仅仅提供一个执行框架,真正处理模块内容转译的逻辑都在 xxxDependencyTemplate 对象的 apply 函数实现,如上例伪代码中 24-28 行。

每个 Dependency 子类都会映射到一个唯一的 Template 子类,且通常这两个类都会写在同一个文件中,例如 ConstDependencyConstDependencyTemplate``;NullDependencyNullDependencyTemplate。Webpack 构建(make)阶段,会通过 Dependency 子类记录不同情况下模块之间的依赖关系;到生成(seal)阶段再通过 Template 子类修改 module 代码。

综上 ModuleJavascriptGeneratorDependencyTemplate 四个类形成如下交互关系:

四个类形成如下交互关系

Template 对象可以通过两种方法更新 module 的代码:

直接操作 source 对象,直接修改模块代码,该对象最初的内容等于模块的源码,经过多个 Template.apply 函数流转后逐渐被替换成新的代码形式
操作 initFragments 数组,在模块源码之外插入补充代码片段
这两种操作所产生的 side effect,最终都会被传入 InitFragment.addToSource 函数,合成最终结果,下面简单补充一些细节。

使用 Source 更改代码

Source 是 Webpack 中编辑字符串的一套工具体系,提供了一系列字符串操作方法,包括:

  • 字符串合并、替换、插入等
  • 模块代码缓存、sourcemap 映射、hash 计算等

Webpack 内部以及社区的很多插件、loader 都会使用 Source 库编辑代码内容,包括上文介绍的 Template.apply 体系中,逻辑上,在启动模块代码生成流程时,Webpack 会先用模块原本的内容初始化 Source 对象,即:

const source = new ReplaceSource(module.originalSource());

之后,不同 Dependency 子类按序、按需更改 source 内容,例如 ConstDependencyTemplate 中的核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
ConstDependency.Template = class ConstDependencyTemplate extends (
NullDependency.Template
) {
apply(dependency, source, templateContext) {
// ...
if (typeof dep.range === "number") {
source.insert(dep.range, dep.expression);
return;
}

source.replace(dep.range[0], dep.range[1] - 1, dep.expression);
}
};

上述 ConstDependencyTemplate 中,apply 函数根据参数条件调用 source.insert 插入一段代码,或者调用 source.replace 替换一段代码。

使用 InitFragment 更新代码

除直接操作 source 外,Template.apply 中还可以通过操作 initFragments 数组达成修改模块产物的效果。initFragments 数组项通常为 InitFragment 子类实例,它们通常带有两个函数: getContentgetEndContent,分别用于获取代码片段的头尾部分。

例如 HarmonyImportDependencyTemplateapply 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HarmonyImportDependency.Template = class HarmonyImportDependencyTemplate extends (
ModuleDependency.Template
) {
apply(dependency, source, templateContext) {
// ...
templateContext.initFragments.push(
new ConditionalInitFragment(
importStatement[0] + importStatement[1],
InitFragment.STAGE_HARMONY_IMPORTS,
dep.sourceOrder,
key,
runtimeCondition
)
);
//...
}
};

代码合并

上述 Template.apply 处理完毕后,产生转译后的 source 对象与代码片段 initFragments 数组,接着就需要调用 InitFragment.addToSource 函数将两者合并为模块产物。

addToSource 的核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class InitFragment {
static addToSource(source, initFragments, generateContext) {
// 先排好顺序
const sortedFragments = initFragments
.map(extractFragmentIndex)
.sort(sortFragmentWithIndex);
// ...

const concatSource = new ConcatSource();
const endContents = [];
for (const fragment of sortedFragments) {
// 合并 fragment.getContent 取出的片段内容
concatSource.add(fragment.getContent(generateContext));
const endContent = fragment.getEndContent(generateContext);
if (endContent) {
endContents.push(endContent);
}
}

// 合并 source
concatSource.add(source);
// 合并 fragment.getEndContent 取出的片段内容
for (const content of endContents.reverse()) {
concatSource.add(content);
}
return concatSource;
}
}

可以看到,addToSource 函数的逻辑:

  1. 遍历 initFragments 数组,按顺序合并 fragment.getContent() 的产物
  2. 合并 source 对象
  3. 遍历 initFragments 数组,按顺序合并 fragment.getEndContent() 的产物

所以,模块代码合并操作主要就是用 initFragments 数组一层一层包裹住模块代码 source,而两者都在 Template.apply 层面维护。

自定义 banner 插件

经过 Template.apply 转译与 InitFragment.addToSource 合并之后,模块就完成了从用户代码形态到产物形态的转变,为加深对上述 「模块转译」 流程的理解,接下来我们尝试开发一个 Banner 插件,实现在每个模块前自动插入一段字符串。

实现上,插件主要涉及 DependencyTemplatehooks 对象,代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const { Dependency, Template } = require("webpack");

class DemoDependency extends Dependency {
constructor() {
super();
}
}

DemoDependency.Template = class DemoDependencyTemplate extends Template {
apply(dependency, source) {
const today = new Date().toLocaleDateString();
source.insert(
0,
`/* Author: Tecvan */
/* Date: ${today} */
`
);
}
};

module.exports = class DemoPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap("DemoPlugin", (compilation) => {
// 调用 dependencyTemplates ,注册 Dependency 到 Template 的映射
compilation.dependencyTemplates.set(
DemoDependency,
new DemoDependency.Template()
);
compilation.hooks.succeedModule.tap("DemoPlugin", (module) => {
// 模块构建完毕后,插入 DemoDependency 对象
module.addDependency(new DemoDependency());
});
});
}
};

关键步骤:

  • 编写 DemoDependencyDemoDependencyTemplate 类,其中 DemoDependency 仅做示例用,没有实际功能;DemoDependencyTemplate则在其 apply 中调用 source.insert 插入字符串,如示例代码第 10-14
  • 使用 compilation.dependencyTemplates 注册 DemoDependencyDemoDependencyTemplate 的映射关系
  • 使用 thisCompilation 钩子取得 compilation 对象
  • 使用 succeedModule 钩子订阅 module 构建完毕事件,并调用 module.addDependency 方法添加 DemoDependency 依赖

module 对象的产物在生成过程就会调用到 DemoDependencyTemplate.apply 函数,插入我们定义好的字符串,效果如:

插入我们定义好的字符串

  • lib/dependencies/ConstDependency.js,一个简单示例,可学习 source 的更多操作方法
  • lib/dependencies/HarmonyExportSpecifierDependencyTemplate.js,一个简单示例,可学习 initFragments 数组的更多用法
    l- ib/dependencies/HarmonyImportDependencyTemplate.js,一个较复杂但使用率极高的示例,可综合学习 source``、initFragments 数组的用法

模块合并打包原理

讲完单个模块的转译过程后,我们先回到这个流程图:

模块合并打包

流程图中,compilation.codeGeneration 函数执行完毕 —— 也就是模块转译阶段完成后,模块的转译结果会一一保存到 compilation.codeGenerationResults 对象中,之后会启动一个新的执行流程 —— 「模块合并打包」

「模块合并打包」 过程会将 chunk 对应的 moduleruntimeModule 按规则塞进 「模板框架」 中,最终合并输出成完整的 bundle 文件,例如上例中:

最终合并输出成完整的 `bundle`

示例右边 bundle 文件中,红框框出来的部分为用户代码文件及运行时模块生成的产物,其余部分撑起了一个 IIFE 形式的运行框架即为 「模板框架」

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
(() => {
// webpackBootstrap
"use strict";
var __webpack_modules__ = {
"module-a": (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) => {
// ! module 代码,
},
"module-b": (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) => {
// ! module 代码,
},
};
// The module cache
var __webpack_module_cache__ = {};
// The require function
function __webpack_require__(moduleId) {
// ! webpack CMD 实现
}
/************************************************************************/
// ! 各种 runtime
/************************************************************************/
var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
(() => {
// ! entry 模块
})();
})();
  • 最外层由一个 IIFE 包裹
  • 一个记录了除 entry 外的其它模块代码的 __webpack_modules__ 对象,对象的 key 为模块标志符;值为模块转译后的代码
  • 一个极度简化的 CMD 实现: __webpack_require__ 函数
  • 最后,一个包裹了 entry 代码的 IIFE 函数

「模块转译」 是将 module 转译为可以在宿主环境如浏览器上运行的代码形式;而 「模块合并」 操作则串联这些 modules ,使之整体符合开发预期,能够正常运行整个应用逻辑。接下来,我们揭晓这部分代码的生成原理。

核心流程

compilation.codeGeneration 执行完毕,即所有用户代码模块与运行时模块都执行完转译操作后,seal 函数调用 compilation.createChunkAssets 函数,触发 renderManifest 钩子,JavascriptModulesPlugin 插件监听到这个钩子消息后开始组装 bundle,伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Webpack 5
// lib/Compilation.js
class Compilation {
seal() {
// 先把所有模块的代码都转译,准备好
this.codeGenerationResults = this.codeGeneration(this.modules);
// 1. 调用 createChunkAssets
this.createChunkAssets();
}

createChunkAssets() {
// 遍历 chunks ,为每个 chunk 执行 render 操作
for (const chunk of this.chunks) {
// 2. 触发 renderManifest 钩子
const res = this.hooks.renderManifest.call([], {
chunk,
codeGenerationResults: this.codeGenerationResults,
...others,
});
// 提交组装结果
this.emitAsset(res.render(), ...others);
}
}
}

// lib/javascript/JavascriptModulesPlugin.js
class JavascriptModulesPlugin {
apply() {
compiler.hooks.compilation.tap("JavascriptModulesPlugin", (compilation) => {
compilation.hooks.renderManifest.tap("JavascriptModulesPlugin", (result, options) => {
// JavascriptModulesPlugin 插件中通过 renderManifest 钩子返回组装函数 render
const render = () =>
// render 内部根据 chunk 内容,选择使用模板 `renderMain` 或 `renderChunk`
// 3. 监听钩子,返回打包函数
this.renderMain(options);

result.push({ render /* arguments */ });
return result;
}
);
});
}

renderMain() {/* */}

renderChunk() {/* */}
}

这里的核心逻辑是,compilationrenderManifest 钩子方式对外发布 bundle 打包需求; JavascriptModulesPlugin 监听这个钩子,按照 chunk 的内容特性,调用不同的打包函数。

上述仅针对 Webpack 5。在 Webpack 4 中,打包逻辑集中在 MainTemplate 完成。

两个打包函数实现的逻辑接近,都是按顺序拼接各个模块,下面简单介绍下 renderMain 的实现。

renderMain 函数

renderMain 函数涉及比较多场景判断,原始代码很长很绕,我摘了几个重点步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class JavascriptModulesPlugin {
renderMain(renderContext, hooks, compilation) {
const { chunk, chunkGraph, runtimeTemplate } = renderContext;

const source = new ConcatSource();
// ...
// 1. 先计算出 bundle CMD 核心代码,包含:
// - "var __webpack_module_cache__ = {};" 语句
// - "__webpack_require__" 函数
const bootstrap = this.renderBootstrap(renderContext, hooks);

// 2. 计算出当前 chunk 下,除 entry 外其它模块的代码
const chunkModules = Template.renderChunkModules(
renderContext,
inlinedModules
? allModules.filter((m) => !inlinedModules.has(m))
: allModules,
(module) =>
this.renderModule(
module,
renderContext,
hooks,
allStrict ? "strict" : true
),
prefix
);

// 3. 计算出运行时模块代码
const runtimeModules =
renderContext.chunkGraph.getChunkRuntimeModulesInOrder(chunk);

// 4. 重点来了,开始拼接 bundle
// 4.1 首先,合并核心 CMD 实现,即上述 bootstrap 代码
const beforeStartup = Template.asString(bootstrap.beforeStartup) + "\n";
source.add(
new PrefixSource(
prefix,
useSourceMap
? new OriginalSource(beforeStartup, "webpack/before-startup")
: new RawSource(beforeStartup)
)
);

// 4.2 合并 runtime 模块代码
if (runtimeModules.length > 0) {
for (const module of runtimeModules) {
compilation.codeGeneratedModules.add(module);
}
}
// 4.3 合并除 entry 外其它模块代码
for (const m of chunkModules) {
const renderedModule = this.renderModule(m, renderContext, hooks, false);
source.add(renderedModule)
}

// 4.4 合并 entry 模块代码
if (
hasEntryModules &&
runtimeRequirements.has(RuntimeGlobals.returnExportsFromRuntime)
) {
source.add(`${prefix}return __webpack_exports__;\n`);
}

return source;
}
}
  1. 先计算出 bundle CMD 代码,即 __webpack_require__ 函数
  2. 计算出当前 chunk 下,除 entry 外其它模块代码 chunkModules
  3. 计算出运行时模块代码
  4. 开始执行合并操作,子步骤有:
    1. 合并 CMD 代码
    2. 合并 runtime 模块代码
    3. 遍历 chunkModules 变量,合并除 entry 外其它模块代码
    4. 合并 entry 模块代码
  5. 返回结果

总结:先计算出不同组成部分的产物形态,之后按顺序拼接打包,输出合并后的版本。

至此,Webpack 完成 bundle 的转译、打包流程,后续调用 compilation.emitAsset ,按上下文环境将产物输出到 fs 即可,Webpack 单次编译打包过程就结束了。

详细讨论了打包流程后半截 —— 从 chunk graph 生成一直到最终输出产物的实现逻辑,重点:

  • 首先遍历 chunk 中的所有模块,为每个模块执行转译操作,产出模块级别的产物
  • 根据 chunk 的类型,选择不同结构框架,按序逐次组装模块产物,打包成最终 bundle