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

开发 前端
本文深入 Webpack 源码,详细讨论了打包流程后半截 —— 从 chunk graph 生成一直到最终输出产物的实现逻辑,首先遍历 chunk 中的所有模块,为每个模块执行转译操作,产出模块级别的产物。

 回顾一下,在之前的文章《有点难的 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 方法更新模块内容,说起来有点绕,原始代码更饶,所以我将重要步骤抽取为如下伪代码:

  1. class JavascriptGenerator { 
  2.     generate(module, generateContext) { 
  3.         // 先取出 module 的原始代码内容 
  4.         const source = new ReplaceSource(module.originalSource()); 
  5.         const { dependencies, presentationalDependencies } = module; 
  6.         const initFragments = []; 
  7.         for (const dependency of [...dependencies, ...presentationalDependencies]) { 
  8.             // 找到 dependency 对应的 template 
  9.             const template = generateContext.dependencyTemplates.get(dependency.constructor); 
  10.             // 调用 template.apply,传入 source、initFragments 
  11.             // 在 apply 函数可以直接修改 source 内容,或者更改 initFragments 数组,影响后续转译逻辑 
  12.             template.apply(dependency, source, {initFragments}) 
  13.         } 
  14.         // 遍历完毕后,调用 InitFragment.addToSource 合并 source 与 initFragments 
  15.         return InitFragment.addToSource(source, initFragments, generateContext); 
  16.     } 
  17.  
  18. // Dependency 子类 
  19. class xxxDependency extends Dependency {} 
  20.  
  21. // Dependency 子类对应的 Template 定义 
  22. const xxxDependency.Template = class xxxDependencyTemplate extends Template { 
  23.     apply(dep, source, {initFragments}) { 
  24.         // 1. 直接操作 source,更改模块代码 
  25.         source.replace(dep.range[0], dep.range[1] - 1, 'some thing'
  26.         // 2. 通过添加 InitFragment 实例,补充代码 
  27.         initFragments.push(new xxxInitFragment()) 
  28.     } 

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

  1. 初始化一系列变量
  2. 遍历 module 对象的依赖数组,找到每个 dependency 对应的 template 对象,调用 template.apply 函数修改模块内容
  3. 调用 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 对象,即:

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

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

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

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

1.3.2 使用 InitFragment 更新代码

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

例如 HarmonyImportDependencyTemplate 的 apply 函数中:

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

1.4 代码合并

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

addToSource 的核心代码如下:

  1. class InitFragment { 
  2.   static addToSource(source, initFragments, generateContext) { 
  3.     // 先排好顺序 
  4.     const sortedFragments = initFragments 
  5.       .map(extractFragmentIndex) 
  6.       .sort(sortFragmentWithIndex); 
  7.     // ... 
  8.  
  9.     const concatSource = new ConcatSource(); 
  10.     const endContents = []; 
  11.     for (const fragment of sortedFragments) { 
  12.         // 合并 fragment.getContent 取出的片段内容 
  13.       concatSource.add(fragment.getContent(generateContext)); 
  14.       const endContent = fragment.getEndContent(generateContext); 
  15.       if (endContent) { 
  16.         endContents.push(endContent); 
  17.       } 
  18.     } 
  19.  
  20.     // 合并 source 
  21.     concatSource.add(source); 
  22.     // 合并 fragment.getEndContent 取出的片段内容 
  23.     for (const content of endContents.reverse()) { 
  24.       concatSource.add(content); 
  25.     } 
  26.     return concatSource; 
  27.   } 

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

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

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

1.5 示例:自定义 banner 插件

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

实现上,插件主要涉及 Dependency、Template、hooks 对象,代码:

  1. const { Dependency, Template } = require("webpack"); 
  2.  
  3. class DemoDependency extends Dependency { 
  4.   constructor() { 
  5.     super(); 
  6.   } 
  7.  
  8. DemoDependency.Template = class DemoDependencyTemplate extends Template { 
  9.   apply(dependency, source) { 
  10.     const today = new Date().toLocaleDateString(); 
  11.     source.insert(0, `/* Author: Tecvan */ 
  12. /* Date: ${today} */ 
  13. `); 
  14.   } 
  15. }; 
  16.  
  17. module.exports = class DemoPlugin { 
  18.   apply(compiler) { 
  19.     compiler.hooks.thisCompilation.tap("DemoPlugin", (compilation) => { 
  20.       // 调用 dependencyTemplates ,注册 Dependency 到 Template 的映射 
  21.       compilation.dependencyTemplates.set
  22.         DemoDependency, 
  23.         new DemoDependency.Template() 
  24.       ); 
  25.       compilation.hooks.succeedModule.tap("DemoPlugin", (module) => { 
  26.         // 模块构建完毕后,插入 DemoDependency 对象 
  27.         module.addDependency(new DemoDependency()); 
  28.       }); 
  29.     }); 
  30.   } 
  31. }; 

示例插件的关键步骤:

编写 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 形式的运行框架即为 「模板框架」,也就是:

  1. (() => { // webpackBootstrap 
  2.     "use strict"
  3.     var __webpack_modules__ = ({ 
  4.         "module-a": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { 
  5.             // ! module 代码, 
  6.         }), 
  7.         "module-b": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { 
  8.             // ! module 代码, 
  9.         }) 
  10.     }); 
  11.     // The module cache 
  12.     var __webpack_module_cache__ = {}; 
  13.     // The require function 
  14.     function __webpack_require__(moduleId) { 
  15.         // ! webpack CMD 实现 
  16.     } 
  17.     /************************************************************************/ 
  18.     // ! 各种 runtime 
  19.     /************************************************************************/ 
  20.     var __webpack_exports__ = {}; 
  21.     // This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. 
  22.     (() => { 
  23.         // ! entry 模块 
  24.     })(); 
  25. })(); 

捋一下这里的逻辑,运行框架包含如下关键部分:

  • 最外层由一个 IIFE 包裹
  • 一个记录了除 entry 外的其它模块代码的 __webpack_modules__ 对象,对象的 key 为模块标志符;值为模块转译后的代码
  • 一个极度简化的 CMD 实现:__webpack_require__ 函数
  • 最后,一个包裹了 entry 代码的 IIFE 函数

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

2.2 核心流程

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

  1. // Webpack 5 
  2. // lib/Compilation.js 
  3. class Compilation { 
  4.   seal() { 
  5.     // 先把所有模块的代码都转译,准备好 
  6.     this.codeGenerationResults = this.codeGeneration(this.modules); 
  7.     // 1. 调用 createChunkAssets 
  8.     this.createChunkAssets(); 
  9.   } 
  10.  
  11.   createChunkAssets() { 
  12.     // 遍历 chunks ,为每个 chunk 执行 render 操作 
  13.     for (const chunk of this.chunks) { 
  14.       // 2. 触发 renderManifest 钩子 
  15.       const res = this.hooks.renderManifest.call([], { 
  16.         chunk, 
  17.         codeGenerationResults: this.codeGenerationResults, 
  18.         ...others, 
  19.       }); 
  20.       // 提交组装结果 
  21.       this.emitAsset(res.render(), ...others); 
  22.     } 
  23.   } 
  24.  
  25. // lib/javascript/JavascriptModulesPlugin.js 
  26. class JavascriptModulesPlugin { 
  27.   apply() { 
  28.     compiler.hooks.compilation.tap("JavascriptModulesPlugin", (compilation) => { 
  29.       compilation.hooks.renderManifest.tap("JavascriptModulesPlugin", (result, options) => { 
  30.           // JavascriptModulesPlugin 插件中通过 renderManifest 钩子返回组装函数 render 
  31.           const render = () => 
  32.             // render 内部根据 chunk 内容,选择使用模板 `renderMain` 或 `renderChunk` 
  33.             // 3. 监听钩子,返回打包函数 
  34.             this.renderMain(options); 
  35.  
  36.           result.push({ render /* arguments */ }); 
  37.           return result; 
  38.         } 
  39.       ); 
  40.     }); 
  41.   } 
  42.  
  43.   renderMain() {/*  */} 
  44.  
  45.   renderChunk() {/*  */} 

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

上述仅针对 Webpack 5。在 Webpack 4 中,打包逻辑集中在 MainTemplate 完成。JavascriptModulesPlugin 内置的打包函数有:

  • renderMain:打包主 chunk 时使用
  • renderChunk:打包子 chunk ,如异步模块 chunk 时使用

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

2.3renderMain函数

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

  1. class JavascriptModulesPlugin { 
  2.   renderMain(renderContext, hooks, compilation) { 
  3.     const { chunk, chunkGraph, runtimeTemplate } = renderContext; 
  4.  
  5.     const source = new ConcatSource(); 
  6.     // ... 
  7.     // 1. 先计算出 bundle CMD 核心代码,包含: 
  8.     //      - "var __webpack_module_cache__ = {};" 语句 
  9.     //      - "__webpack_require__" 函数 
  10.     const bootstrap = this.renderBootstrap(renderContext, hooks); 
  11.  
  12.     // 2. 计算出当前 chunk 下,除 entry 外其它模块的代码 
  13.     const chunkModules = Template.renderChunkModules( 
  14.       renderContext, 
  15.       inlinedModules 
  16.         ? allModules.filter((m) => !inlinedModules.has(m)) 
  17.         : allModules, 
  18.       (module) => 
  19.         this.renderModule( 
  20.           module, 
  21.           renderContext, 
  22.           hooks, 
  23.           allStrict ? "strict" : true 
  24.         ), 
  25.       prefix 
  26.     ); 
  27.  
  28.     // 3. 计算出运行时模块代码 
  29.     const runtimeModules = 
  30.       renderContext.chunkGraph.getChunkRuntimeModulesInOrder(chunk); 
  31.  
  32.     // 4. 重点来了,开始拼接 bundle 
  33.     // 4.1 首先,合并核心 CMD 实现,即上述 bootstrap 代码 
  34.     const beforeStartup = Template.asString(bootstrap.beforeStartup) + "\n"
  35.     source.add
  36.       new PrefixSource( 
  37.         prefix, 
  38.         useSourceMap 
  39.           ? new OriginalSource(beforeStartup, "webpack/before-startup"
  40.           : new RawSource(beforeStartup) 
  41.       ) 
  42.     ); 
  43.  
  44.     // 4.2 合并 runtime 模块代码 
  45.     if (runtimeModules.length > 0) { 
  46.       for (const module of runtimeModules) { 
  47.         compilation.codeGeneratedModules.add(module); 
  48.       } 
  49.     } 
  50.     // 4.3 合并除 entry 外其它模块代码 
  51.     for (const m of chunkModules) { 
  52.       const renderedModule = this.renderModule(m, renderContext, hooks, false); 
  53.       source.add(renderedModule) 
  54.     } 
  55.  
  56.     // 4.4 合并 entry 模块代码 
  57.     if ( 
  58.       hasEntryModules && 
  59.       runtimeRequirements.has(RuntimeGlobals.returnExportsFromRuntime) 
  60.     ) { 
  61.       source.add(`${prefix}return __webpack_exports__;\n`); 
  62.     } 
  63.  
  64.     return source; 
  65.   } 

核心逻辑为:

1.先计算出 bundle CMD 代码,即 __webpack_require__ 函数

2.计算出当前 chunk 下,除 entry 外其它模块代码 chunkModules计算出运行时模块代码

3.开始执行合并操作,子步骤有:

  • 合并 CMD 代码
  • 合并 runtime 模块代码
  • 遍历 chunkModules 变量,合并除 entry 外其它模块代码
  • 合并 entry 模块代码

4.返回结果

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

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

三、总结

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

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

 

责任编辑:姜华 来源: Tecvan
相关推荐

2022-05-03 20:48:17

Webpackcommonjsesmodule

2021-05-31 05:36:43

WebpackJavaScript 前端

2021-09-13 09:40:35

Webpack 前端HMR 原理

2021-08-26 10:30:29

WebpackTree-Shakin前端

2021-12-16 22:02:28

webpack原理模块化

2021-12-24 08:01:44

Webpack优化打包

2021-12-25 22:29:04

WebpackRollup 前端

2020-08-05 08:21:41

Webpack

2020-07-10 12:06:28

WebpackBundleless浏览器

2021-10-12 09:52:30

Webpack 前端多进程打包

2024-05-27 00:00:01

2021-02-26 22:34:28

Webpack 前端项目

2022-02-10 14:23:16

WebpackJavaScript

2021-12-20 00:03:38

Webpack运行机制

2021-11-09 09:57:46

Webpack 前端分包优化

2021-04-19 10:45:52

Webpack热更新前端

2021-11-15 09:44:49

Webpack 前端 Scope Hois

2021-12-15 23:42:56

Webpack原理实践

2021-10-25 10:23:49

Webpack 前端Tree shakin

2021-12-15 09:21:59

Webpack 前端Sourcemap
点赞
收藏

51CTO技术栈公众号