关于webpack:Webpack-原理系列八产物转译打包逻辑

6次阅读

共计 12435 个字符,预计需要花费 32 分钟才能阅读完成。

全文 6000 字,咱们来聊聊打包闭环,欢送点赞关注转发。

回顾一下,在之前的文章《有点难的 webpack 知识点:Dependency Graph 深度解析》曾经聊到,通过 构建 (make) 阶段 后,Webpack 解析出:

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

而进入 生成 (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.jsindex.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 调用开始,对应到上述流程图的:

总结一下关键步骤:

  • 调用 JavascriptGenerator 的对象的 generate 办法,办法外部:

    • 遍历模块的 dependenciespresentationalDependencies 数组
    • 执行每个数组项 dependeny 对象的对应的 template.apply 办法,在 apply 内批改模块代码,或更新 initFragments 数组
  • 遍历结束后,调用 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 函数的逻辑绝对比拟固化:

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

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

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

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

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 子类实例,它们通常带有两个函数:getContentgetEndContent,别离用于获取代码片段的头尾局部。

例如 HarmonyImportDependencyTemplateapply 函数中:

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 插件,实现在每个模块前主动插入一段字符串。

实现上,插件次要波及 DependencyTemplatehooks 对象,代码:

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 函数,插入咱们定义好的字符串,成果如:

感兴趣的读者也能够间接浏览 Webpack 5 仓库的如下文件,学习更多用例:

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

二、模块合并打包原理

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 实现}
    /************************************************************************/
    // ! 各种 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,使之整体合乎开发预期,可能失常运行整个应用逻辑。接下来,咱们揭晓这部分代码的生成原理。

2.2 外围流程

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() {/*  */}
}

这里的外围逻辑是,compilationrenderManifest 钩子形式对外公布 bundle 打包需要;JavascriptModulesPlugin 监听这个钩子,依照 chunk 的内容个性,调用不同的打包函数。

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

JavascriptModulesPlugin 内置的打包函数有:

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

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

2.3 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;
  }
}

外围逻辑为:

  • 先计算出 bundle CMD 代码,即 __webpack_require__ 函数
  • 计算出以后 chunk 下,除 entry 外其它模块代码 chunkModules
  • 计算出运行时模块代码
  • 开始执行合并操作,子步骤有:

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

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

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

三、总结

本文深刻 Webpack 源码,具体探讨了打包流程后半截 —— 从 chunk graph 生成始终到最终输入产物的实现逻辑,重点:

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

回顾一下,咱们:

  • 在《[万字总结] 一文吃透 Webpack 外围原理》中高度概括的探讨了 Webpack 从前到后的工作流程,帮忙读者对 Webpack 的实现原理有一个较形象的认知;
  • 在《[源码解读] Webpack 插件架构深度解说》具体介绍了 Webpack 插件机制的实现原理,帮忙读者深刻了解 Webpack 架构与钩子的设计;
  • 在《有点难的 webpack 知识点:Dependency Graph 深度解析》具体介绍了语焉不详的 模块依赖图 概念,帮忙读者了解 Webpack 中依赖发现与依赖关系构建过程
  • 在《有点难的知识点:Webpack Chunk 分包规定详解》具体介绍了 chunk 分包的根本逻辑与实现办法,帮忙读者了解产物分片的原理
  • 在《Webpack 原理系列六:彻底了解 Webpack 运行时》具体介绍了 bundle 中,除业务模块外其它运行时代码的由来与作用,帮忙读者了解产物的运行逻辑
  • 最初,再到本文介绍的模块转译与合并打包逻辑

至此,Webpack 编译打包的主体流程曾经可能很好地串联起来,置信读者沿着这条文章脉络,仔细对照源码急躁学习,必然对前端的打包与工程化有一个深度的了解,互勉。

正文完
 0