全文 6000 字,咱们来聊聊打包闭环,欢迎点赞关注转发。
回顾一下,在以前的文章《有点难的 webpack 知识点:Dependency Graph 深度解析》已经聊到,通过 构建(make)阶段 后,Webpack 解析出:javascript
module
内容module
与 module
之间的依赖关系图而进入 生成(seal)阶段 后,Webpack 首先根据模块的依赖关系、模块特性、entry配置等计算出 Chunk Graph,肯定最终产物的数量和内容,这部分原理在前文《有点难的知识点: Webpack Chunk 分包规则详解》中也有较详细的描述。前端
本文继续聊聊 Chunk Graph 后面以后,模块开始转译到模块合并打包的过程,大致流程以下:java
为了方便理解,我将打包过程横向切分为三个阶段:webpack
compilation.codeGeneration
以前的全部前置操做modules
数组,完成全部模块的转译操做,并将结果存储到 compilation.codeGenerationResults
对象compilation.emitAsset
输出产物这里说的 业务模块 是指开发者所编写的项目代码;runtime 模块 是指 Webpack 分析业务模块后,动态注入的用于支撑各项特性的运行时代码,在上一篇文章 Webpack 原理系列六: 完全理解 Webpack 运行时 已经有详细讲解,这里不赘述。web
能够看到,Webpack 先将 modules
逐一转译为模块产物 —— 模块转译,再将模块产物拼接成 bundle —— 模块合并打包,咱们下面会按照这个逻辑分开讨论这两个过程的原理。express
先回顾一下 Webpack 产物:bootstrap
上述示例由 index.js
/ name.js
两个业务文件组成,对应的 Webpack 配置如上图左下角所示;Webpack 构建产物如右边 main.js
文件所示,包含三块内容,从上到下分别为:segmentfault
name.js
模块对应的转译产物,函数形态index.js
模块对应的转译产物,IIFE(当即执行函数) 形态其中,运行时代码的做用与生成逻辑在上篇文章 Webpack 原理系列六: 完全理解 Webpack 运行时 已有详尽介绍;另外两块分别为 name.js
、index.js
构建后的产物,能够看到产物与源码语义、功能均相同,但表现形式发生了较大变化,例如 index.js
编译先后的内容:数组
上图右边是 Webpack 编译产物中对应的代码,相对于左边的源码有以下变化:浏览器
__webpack_require__.r(__webpack_exports__);
语句,用于适配 ESM 规范import
语句被转译为 __webpack_require__
函数调用console
语句所使用的 name
变量被转译为 _name__WEBPACK_IMPORTED_MODULE_0__.default
那么 Webpack 中如何执行这些转换的呢?
模块转译 操做从 module.codeGeneration
调用开始,对应到上述流程图的:
总结一下关键步骤:
调用 JavascriptGenerator
的对象的 generate
方法,方法内部:
dependencies
与 presentationalDependencies
数组dependeny
对象的对应的 template.apply
方法,在 apply
内修改模块代码,或更新 initFragments
数组InitFragment.addToSource
静态方法,将上一步操做产生的 source
对象与 initFragments
数组合并为模块产物简单说就是遍历依赖,在依赖对象中修改 module
代码,最后再将全部变动合并为最终产物。这里面关键点:
Template.apply
函数中,如何更新模块代码InitFragment.addToSource
静态方法中,如何将 Template.apply
所产生的 side effect 合并为最终产物这两部分逻辑比较复杂,下面分开讲解。
上述流程中,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
函数,合成最终结果,下面简单补充一些细节。
Source
是 Webpack 中编辑字符串的一套工具体系,提供了一系列字符串操做方法,包括:
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
替换一段代码。
除直接操做 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 ) ); //... } }
上述 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
层面维护。
通过 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
数组的用法
讲完单个模块的转译过程后,咱们先回到这个流程图:
流程图中,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 实现 } /************************************************************************/ // ! 各类 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 模块 })(); })();
捋一下这里的逻辑,运行框架包含以下关键部分:
entry
外的其它模块代码的 __webpack_modules__
对象,对象的 key 为模块标志符;值为模块转译后的代码__webpack_require__
函数entry
代码的 IIFE 函数模块转译 是将 module
转译为能够在宿主环境如浏览器上运行的代码形式;而 模块合并 操做则串联这些 modules
,使之总体符合开发预期,可以正常运行整个应用逻辑。接下来,咱们揭晓这部分代码的生成原理。
在 compilation.codeGeneration
执行完毕,即全部用户代码模块与运行时模块都执行完转译操做后,seal
函数调用 compilation.createChunkAssets
函数,触发 renderManifest
钩子,JavascriptModulesPlugin
插件监听到这个钩子消息后开始组装 bundle,伪代码:
// 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() {/* */} }
这里的核心逻辑是,compilation
以 renderManifest
钩子方式对外发布 bundle 打包需求; JavascriptModulesPlugin
监听这个钩子,按照 chunk 的内容特性,调用不一样的打包函数。
上述仅针对 Webpack 5。在 Webpack 4 中,打包逻辑集中在
MainTemplate
完成。
JavascriptModulesPlugin
内置的打包函数有:
renderMain
:打包主 chunk 时使用renderChunk
:打包子 chunk ,如异步模块 chunk 时使用两个打包函数实现的逻辑接近,都是按顺序拼接各个模块,下面简单介绍下 renderMain
的实现。
renderMain
函数renderMain
函数涉及比较多场景判断,原始代码很长很绕,我摘了几个重点步骤:
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; } }
核心逻辑为:
__webpack_require__
函数chunkModules
开始执行合并操做,子步骤有:
chunkModules
变量,合并除 entry 外其它模块代码总结:先计算出不一样组成部分的产物形态,以后按顺序拼接打包,输出合并后的版本。
至此,Webpack 完成 bundle 的转译、打包流程,后续调用 compilation.emitAsset
,按上下文环境将产物输出到 fs 便可,Webpack 单次编译打包过程就结束了。
本文深刻 Webpack 源码,详细讨论了打包流程后半截 —— 从 chunk graph 生成一直到最终输出产物的实现逻辑,重点:
回顾一下,咱们:
至此,Webpack 编译打包的主体流程已经可以很好地串联起来,相信读者沿着这条文章脉络,细心对照源码耐心学习,一定对前端的打包与工程化有一个深度的理解,互勉。