您现在的位置是:亿华云 > 应用开发
Webpack 原理系列八:产物转译打包逻辑
亿华云2025-10-03 06:22:55【应用开发】1人已围观
简介回顾一下,在之前的文章《有点难的 webpack 知识点:Dependency Graph 深度解析》已经聊到,经过 「构建(make)阶段」 后,Webpack 解析出:module 内容
回顾一下,原译打在之前的理系列产文章《有点难的 webpack 知识点:Dependency Graph 深度解析》已经聊到,经过 「构建(make)阶段」 后,物转Webpack 解析出:
module 内容 module 与 module 之间的包逻依赖关系图而进入 「生成(「「seal」」)阶段」 后,Webpack 首先根据模块的原译打依赖关系、模块特性、理系列产entry配置等计算出 Chunk Graph,物转确定最终产物的包逻数量和内容,这部分原理在前文《有点难的原译打知识点:Webpack Chunk 分包规则详解》中也有较详细的描述。
本文继续聊聊 Chunk Graph 后面之后,理系列产模块开始转译到模块合并打包的物转过程,大体流程如下:
为了方便理解,包逻我将打包过程横向切分为三个阶段:
「入口」:指代从 Webpack 启动到调用 compilation.codeGeneration 之前的原译打所有前置操作 「模块转译」:遍历 modules 数组,完成所有模块的理系列产转译操作,并将结果存储到 compilation.codeGenerationResults 对象 「模块合并打包」:在特定上下文框架下,物转组合业务模块、runtime 模块,合并打包成 bundle ,并调用 compilation.emitAsset 输出产物这里说的 「业务模块」 是指开发者所编写的项目代码;「runtime 模块」 是指 Webpack 分析业务模块后,动态注入的亿华云计算用于支撑各项特性的运行时代码,在上一篇文章 Webpack 原理系列六:彻底理解 Webpack 运行时 已经有详细讲解,这里不赘述。
可以看到,Webpack 先将 modules 逐一转译为模块产物 —— 「模块转译」,再将模块产物拼接成 bundle —— 「模块合并打包」,我们下面会按照这个逻辑分开讨论这两个过程的原理。
一、模块转译原理
1.1 简介
先回顾一下 Webpack 产物:
上述示例由 index.js / name.js 两个业务文件组成,对应的 Webpack 配置如上图左下角所示;Webpack 构建产物如右边 main.js 文件所示,包含三块内容,从上到下分别为:
name.js 模块对应的转译产物,函数形态 Webpack 按需注入的运行时代码 index.js 模块对应的转译产物,IIFE(立即执行函数) 形态其中,运行时代码的作用与生成逻辑在上篇文章 Webpack 原理系列六:彻底理解 Webpack 运行时 已有详尽介绍;另外两块分别为 name.js 、index.js 构建后的产物,可以看到产物与源码语义、功能均相同,但表现形式发生了较大变化,例如 index.js 编译前后的内容:
上图右边是 Webpack 编译产物中对应的代码,相对于左边的源码有如下变化:
整个模块被包裹进 IIFE (立即执行函数)中 添加 __webpack_require__.r(__webpack_exports__); 语句,高防服务器用于适配 ESM 规范 源码中的 import 语句被转译为 __webpack_require__ 函数调用 源码 console 语句所使用的 name 变量被转译为 _name__WEBPACK_IMPORTED_MODULE_0__.default 添加注释那么 Webpack 中如何执行这些转换的呢?
1.2 核心流程
「模块转译」操作从 module.codeGeneration 调用开始,对应到上述流程图的:
总结一下关键步骤:
1.调用 JavascriptGenerator 的对象的 generate 方法,方法内部:
遍历模块的 dependencies 与 presentationalDependencies 数组 执行每个数组项 dependeny 对象的对应的 template.apply 方法,在 apply 内修改模块代码,或更新 initFragments 数组2.遍历完毕后,调用 InitFragment.addToSource 静态方法,将上一步操作产生的 source 对象与 initFragments 数组合并为模块产物
简单说就是遍历依赖,在依赖对象中修改 module 代码,最后再将所有变更合并为最终产物。这里面关键点:
在 Template.apply 函数中,如何更新模块代码 在 InitFragment.addToSource 静态方法中,如何将 Template.apply 所产生的 side effect 合并为最终产物这两部分逻辑比较复杂,下面分开讲解。
1.3 Template.apply 函数
上述流程中,JavascriptGenerator 类是毋庸置疑的C位角色,但它并不直接修改 module 的内容,而是绕了几层后委托交由 Template 类型实现。云服务器提供商
Webpack 5 源码中,JavascriptGenerator.generate 函数会遍历模块的 dependencies 数组,调用依赖对象对应的 Template 子类 apply 方法更新模块内容,说起来有点绕,原始代码更饶,所以我将重要步骤抽取为如下伪代码:
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 函数的逻辑相对比较固化:
初始化一系列变量 遍历 module 对象的依赖数组,找到每个 dependency 对应的 template 对象,调用 template.apply 函数修改模块内容 调用 InitFragment.addToSource 方法,合并 source 与 initFragments 数组,生成最终结果这里的重点是 JavascriptGenerator.generate 函数并不操作 module 源码,它仅仅提供一个执行框架,真正处理模块内容转译的逻辑都在 xxxDependencyTemplate 对象的 apply 函数实现,如上例伪代码中 24-28行。
每个 Dependency 子类都会映射到一个唯一的 Template 子类,且通常这两个类都会写在同一个文件中,例如 ConstDependency 与 ConstDependencyTemplate;NullDependency 与 NullDependencyTemplate。Webpack 构建(make)阶段,会通过 Dependency 子类记录不同情况下模块之间的依赖关系;到生成(seal)阶段再通过 Template 子类修改 module 代码。
综上 Module、JavascriptGenerator、Dependency、Template 四个类形成如下交互关系:
Template 对象可以通过两种方法更新 module 的代码:
直接操作 source 对象,直接修改模块代码,该对象最初的内容等于模块的源码,经过多个 Template.apply 函数流转后逐渐被替换成新的代码形式 操作 initFragments 数组,在模块源码之外插入补充代码片段这两种操作所产生的 side effect,最终都会被传入 InitFragment.addToSource 函数,合成最终结果,下面简单补充一些细节。
1.3.1 使用 Source 更改代码
Source 是 Webpack 中编辑字符串的一套工具体系,提供了一系列字符串操作方法,包括:
字符串合并、替换、插入等 模块代码缓存、sourcemap 映射、hash 计算等Webpack 内部以及社区的很多插件、loader 都会使用 Source 库编辑代码内容,包括上文介绍的 Template.apply 体系中,逻辑上,在启动模块代码生成流程时,Webpack 会先用模块原本的内容初始化 Source 对象,即:
const source = new ReplaceSource(module.originalSource());之后,不同 Dependency 子类按序、按需更改 source 内容,例如 ConstDependencyTemplate 中的核心代码:
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 替换一段代码。
1.3.2 使用 InitFragment 更新代码
除直接操作 source 外,Template.apply 中还可以通过操作 initFragments 数组达成修改模块产物的效果。initFragments 数组项通常为 InitFragment 子类实例,它们通常带有两个函数:getContent、getEndContent,分别用于获取代码片段的头尾部分。
例如 HarmonyImportDependencyTemplate 的 apply 函数中:
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 ) ); //... } }1.4 代码合并
上述 Template.apply 处理完毕后,产生转译后的 source 对象与代码片段 initFragments 数组,接着就需要调用 InitFragment.addToSource 函数将两者合并为模块产物。
addToSource 的核心代码如下:
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 函数的逻辑:
遍历 initFragments 数组,按顺序合并 fragment.getContent() 的产物 合并 source 对象 遍历 initFragments 数组,按顺序合并 fragment.getEndContent() 的产物所以,模块代码合并操作主要就是用 initFragments 数组一层一层包裹住模块代码 source,而两者都在 Template.apply 层面维护。
1.5 示例:自定义 banner 插件
经过 Template.apply 转译与 InitFragment.addToSource 合并之后,模块就完成了从用户代码形态到产物形态的转变,为加深对上述 「模块转译」 流程的理解,接下来我们尝试开发一个 Banner 插件,实现在每个模块前自动插入一段字符串。
实现上,插件主要涉及 Dependency、Template、hooks 对象,代码:
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()); }); }); } };示例插件的关键步骤:
编写 DemoDependency 与 DemoDependencyTemplate 类,其中 DemoDependency 仅做示例用,没有实际功能;DemoDependencyTemplate 则在其 apply 中调用 source.insert 插入字符串,如示例代码第 10-14 行
使用 compilation.dependencyTemplates 注册 DemoDependency 与 DemoDependencyTemplate 的映射关系 使用 thisCompilation 钩子取得 compilation 对象 使用 succeedModule 钩子订阅 module 构建完毕事件,并调用 module.addDependency 方法添加 DemoDependency 依赖完成上述操作后,module 对象的产物在生成过程就会调用到 DemoDependencyTemplate.apply 函数,插入我们定义好的字符串,效果如:
感兴趣的读者也可以直接阅读 Webpack 5 仓库的如下文件,学习更多用例:
lib/dependencies/ConstDependency.js,一个简单示例,可学习 source 的更多操作方法 lib/dependencies/HarmonyExportSpecifierDependencyTemplate.js,一个简单示例,可学习 initFragments 数组的更多用法 lib/dependencies/HarmonyImportDependencyTemplate.js,一个较复杂但使用率极高的示例,可综合学习 source、initFragments 数组的用法二、模块合并打包原理
2.1 简介
讲完单个模块的转译过程后,我们先回到这个流程图:
流程图中,compilation.codeGeneration 函数执行完毕 —— 也就是模块转译阶段完成后,模块的转译结果会一一保存到 compilation.codeGenerationResults 对象中,之后会启动一个新的执行流程 —— 「模块合并打包」。
「模块合并打包」 过程会将 chunk 对应的 module 及 runtimeModule 按规则塞进 「模板框架」 中,最终合并输出成完整的 bundle 文件,例如上例中:
示例右边 bundle 文件中,红框框出来的部分为用户代码文件及运行时模块生成的产物,其余部分撑起了一个 IIFE 形式的运行框架即为 「模板框架」,也就是:
(() => { // 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 实现 } /很赞哦!(512)