Webpack: 三种Chunk产物的打包逻辑

概述

  • 在前文 Webpack: Dependency Graph 管理模块间依赖 中,我们已经详细讲解了「构建」阶段如何从 Entry 开始逐步递归读入、解析模块内容,并最终构建出模块依赖关系图 ------ ModuleGraph 对象。本文我们继续往下,讲解在接下来的「封装」阶段,如何根据 ModuleGraph 内容组织 Chunk,并进一步构建出 ChunkGroup、ChunkGraph 依赖关系对象的主流程。

主流程之外,我们还会详细讲解几个比较模糊的概念:

  • Chunk、ChunkGroup、ChunGraph 对象分别是什么?互相之间存在怎样的交互关系?
  • Webpack 默认分包规则,以及规则中存在的问题。

ChunkGraph 构建过程

在 前 Init、Make、Seal》中,我们已经介绍了 Webpack 底层构建逻辑大体上可以划分为:「初始化、构建、封装」三个阶段:

其中,「构建 」阶段负责分析模块间的依赖关系,建立起模块之间的 依赖关系图(ModuleGraph);紧接着,在「封装」阶段根据依赖关系图,将模块分开封装进若干 Chunk 对象中,并将 Chunk 之间的父子依赖关系梳理成 ChunkGraph 与若干 ChunkGroup 对象。

「封装」阶段最重要的目标就是根据「构建」阶段收集到的 ModuleGraph 关系图构建 ChunkGraph 关系图,这个过程的逻辑比较复杂:

我们简单分析一下这里面几个重要步骤的实现逻辑。

第一步非常关键: 调用 seal() 函数后,遍历 entry 配置,为每个入口创建一个空的 ChunkEntryPoint 对象(一种特殊的 ChunkGroup),并初步设置好基本的 ChunkGraph 结构关系,为下一步骤做好准备,关键代码:

js 复制代码
class Compilation {
  seal(callback) {
    // ...
    const chunkGraphInit = new Map();
    // 遍历入口模块列表
    for (const [name, { dependencies, includeDependencies, options }] of this
      .entries) {
      // 为每一个 entry 创建对应的 Chunk 对象
      const chunk = this.addChunk(name);
      // 为每一个 entry 创建对应的 ChunkGroup 对象
      const entrypoint = new Entrypoint(options);
      // 关联 Chunk 与 ChunkGroup
      connectChunkGroupAndChunk(entrypoint, chunk);

      // 遍历 entry Dependency 列表
      for (const dep of [...this.globalEntry.dependencies, ...dependencies]) {
        // 为每一个 EntryPoint 关联入口依赖对象,以便下一步从入口依赖开始遍历其它模块
        entrypoint.addOrigin(null, { name }, /** @type {any} */ (dep).request);

        const module = this.moduleGraph.getModule(dep);
        if (module) {
          // 在 ChunkGraph 中记录入口模块与 Chunk 关系
          chunkGraph.connectChunkAndEntryModule(chunk, module, entrypoint);
          // ...
        }
      }
    }
    // 调用 buildChunkGraph 方法,开始构建 ChunkGraph
    buildChunkGraph(this, chunkGraphInit);
    // 触发各种优化钩子
    // ...
  }
}

执行完成后,形成如下数据结构:

其次,若此时配置了 entry.runtime,Webpack 还会在这个阶段为运行时代码 创建 相应的 Chunk 并直接 分配entry 对应的 ChunkGroup对象。一切准备就绪后调用 buildChunkGraph 函数,进入下一步骤。

第二步:buildChunkGraph 函数内 调用 visitModules 函数,遍历 ModuleGraph,将所有 Module 按照依赖关系分配给不同 Chunk 对象;这个过程中若遇到 异步模块,则为该模块 创建新的 ChunkGroupChunk 对象,最终形成如下数据结构:

第三步:buildChunkGraph 函数中调用 connectChunkGroups 方法,建立 ChunkGroup 之间、Chunk 之间的依赖关系,生成完整的 ChunkGraph 对象,最终形成如下数据结构:

第四步:buildChunkGraph 函数中调用 cleanupUnconnectedGroups 方法,清理无效 ChunkGroup,主要起到性能优化作用。

自上而下经过这四个步骤后,ModuleGraph 中存储的模块将根据模块本身的性质,被分配到 Entry、Async、Runtime 三种不同的 Chunk 对象,并将 Chunk 之间的依赖关系存储到 ChunkGraph 与 ChunkGroup 集合中,后续可在这些对象基础上继续修改分包策略(例如 SplitChunksPlugin),通过重新组织、分配 Module 与 Chunk 对象的归属实现分包优化。

Chunk vs ChunkGroup vs ChunkGraph

上述构建过程涉及 Chunk、ChunkGroup、ChunkGraph 三种关键对象,我们先总结它们的概念与作用,加深理解:

  • Chunk:Module 用于读入模块内容,记录模块间依赖等;而 Chunk 则根据模块依赖关系合并多个 Module,输出成资产文件(合并、输出产物的逻辑,我们放到下一章讲解):
  • ChunkGroup:一个 ChunkGroup 内包含一个或多个 Chunk 对象;ChunkGroupChunkGroup 之间形成父子依赖关系:
  • ChunkGraph:最后,Webpack 会将 Chunk 之间、ChunkGroup 之间的依赖关系存储到 compilation.chunkGraph 对象中,形成如下类型关系:

默认分包规则

综合上述 ChunkGraph 构建流程最终会将 Module 组织成三种不同类型的 Chunk:

  • Entry Chunk:同一个 entry 下触达到的模块组织成一个 Chunk;
  • Async Chunk:异步模块单独组织为一个 Chunk;
  • Runtime Chunk:entry.runtime 不为空时,会将运行时模块单独组织成一个 Chunk。

这是 Webpack 内置的,在不使用 splitChunks 或其它插件的情况下,模块输入映射到输出的默认规则,是 Webpack 底层关键原理之一,因此有必要展开介绍每一种 Chunk 的具体规则。

Entry Chunk:

先从 Entry Chunk 开始,Webpack 首先会为每一个 entry 创建 Chunk 对象,例如对于如下配置:

js 复制代码
module.exports = {
  entry: {
    main: "./src/main",
    home: "./src/home",
  }
};

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

初始化完毕后,Webpack 会根据 ModuleGraph 的依赖关系数据,将 entry 下所触及的所有 Module 塞入 Chunk (发生在 visitModules 方法),比如对于如下文件依赖:

main.js 以同步方式直接或间接引用了 a/b/c/d 四个文件,Webpack 会首先为 main.js 模块创建 Chunk 与 EntryPoint 对象,之后将 a/b/c/d 模块逐步添加到 chunk[main] 中,最终形成:

Async Chunk:

其次,Webpack 会将每一个异步导入语句(import(xxx)require.ensure)处理为一个单独的 Chunk 对象,并将其子模块都加入这个 Chunk 中 ------ 我们称之为 Async Chunk。例如对于下面的例子:

js 复制代码
// index.js
import './sync-a.js'
import './sync-b.js'

import('./async-a.js')

// async-a.js
import './sync-c.js'

在入口模块 index.js 中,以同步方式引入 sync-a、sync-b;以异步方式引入 async-a 模块;同时,在 async-a 中以同步方式引入 sync-c 模块,形成如下模块依赖关系图:

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

并且 chunk[index]chunk[async-a] 之间形成了单向依赖关系,Webpack 会将这种依赖关系保存在 ChunkGroup._parentsChunkGroup._children 属性中。

Runtime Chunk:

最后,除了 entry、异步模块外,Webpack5 还支持将 Runtime 代码单独抽取为 Chunk。这里说的 Runtime 代码是指一些为了确保打包产物能正常运行,而由 Webpack 注入的一系列基础框架代码,举个例子,常见的 Webpack 打包产物结构如:

上图红框圈出来的一大段代码就是 Webpack 动态生成的运行时代码,编译时,Webpack 会根据业务代码,决定输出哪些支撑特性的运行时代码(基于 Dependency 子类),例如:

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

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

js 复制代码
module.exports = {
  entry: {
    index: { import: "./src/index", runtime: "solid-runtime" },
  }
};

compilation.seal 函数中,Webpack 首先为 entry 创建 EntryPoint,之后判断 entry 配置中是否带有 runtime 属性,有则创建以 runtime 值为名的 Chunk,因此,上例配置将生成两个 Chunk:chunk[index.js]chunk[solid-runtime],并据此最终产出两个文件:

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

在多 entry 场景中,只要为每个 entry 都设定相同的 runtime 值,Webpack 运行时代码就会合并写入到同一个 Runtime Chunk 中,最终达成产物性能优化效果。例如对于如下配置:

js 复制代码
module.exports = {
  entry: {
    index: { import: "./src/index", runtime: "solid-runtime" },
    home: { import: "./src/home", runtime: "solid-runtime" },
  }
};

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

此时入口 chunk[index]chunk[home] 与运行时 chunk[solid-runtime] 也会形成父子依赖关系。

分包规则的问题

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

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

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

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

为此,在 Webpack4 之后才专门引入了更复杂的数据结构 ------ ChunkGroup 专门实现关系链管理,配合 SplitChunksPlugin 能够更高效、智能地实现启发式分包。

总结

综上,「构建」阶段负责根据模块的引用关系构建 ModuleGraph;「封装」阶段则负责根据 ModuleGraph 构建一系列 Chunk 对象,并将 Chunk 之间的依赖关系(异步引用、Runtime)组织为 ChunkGraph ------ Chunk 依赖关系图对象。与 ModuleGraph 类似,ChunkGraph 结构的引入也能解耦 Chunk 之间依赖关系的管理逻辑,整体架构逻辑更合理更容易扩展。

不过,虽然看着很复杂,但「封装」阶段最重要的目标还是在于:确定有多少个 Chunk,以及每一个 Chunk 中包含哪些 Module ------ 这些才是真正影响最终打包结果的关键因素。

针对这一点,我们需要理解 Webpack5 内置的三种分包规则:Entry Chunk、Async Chunk 与 Runtime Chunk,这些是最最原始的分包逻辑,其它插件(例如 splitChunksPlugin)都是在此基础,借助 buildChunkGraph 后触发的各种钩子进一步拆分、合并、优化 Chunk 结构,实现扩展分包效果。

思考 Chunk 一定会且只会生产出一个产物文件吗?为什么?mini-css-extract-pluginfile-loader 这一类能写出额外文件的组件,底层是怎么实现的?

相关推荐
我要洋人死6 分钟前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人18 分钟前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人18 分钟前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR24 分钟前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香26 分钟前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q24985969329 分钟前
前端预览word、excel、ppt
前端·word·excel
小华同学ai34 分钟前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
Gavin_91543 分钟前
【JavaScript】模块化开发
前端·javascript·vue.js
懒大王爱吃狼2 小时前
Python教程:python枚举类定义和使用
开发语言·前端·javascript·python·python基础·python编程·python书籍
逐·風6 小时前
unity关于自定义渲染、内存管理、性能调优、复杂物理模拟、并行计算以及插件开发
前端·unity·c#