likes
comments
collection
share

[译]深度解析Webpack的SplitChunksPlugin(万字预警)

作者站长头像
站长
· 阅读数 1

原文:angularindepth.com/posts/1490/…

准备

为了更好地了解此插件的功能,本文使用了一个示例,随着我们的学习过程,该示例将使用不同的配置以展示此插件的功能。

在介绍此插件解决的问题之前,让我们先看看这个小项目以及它的配置,这将标志着我们的学习之旅的开始:

├── a.js
├── b.js
├── c.js
├── d.js
├── e.js
├── f.js
├── g.js
├── index.js
├── node_modules
│ ├── x.js
│ ├── y.js
│ └── z.js
└── webpack.config.js

webpack.config.js 文件内容如下:

module.exports = {
    mode: 'production',
    entry: {
        main: './src',
    },
    output: {
        path: path.join(__dirname, 'dist'),
        filename: '[name].js',
        clean: true,
    },
    optimization: {
        // 命令 webpack 不要压缩生成的代码
        minimize: false,
        splitChunks: false,
    },
    context: __dirname,
};

在构建完成后,dist 目录应具有以下结构:

[译]深度解析Webpack的SplitChunksPlugin(万字预警)

请注意,有5个 chunk,一个对应 entry 对象中的项(即 main:'./src'),另外4个 chunk 是根据 import() 函数生成的。

这是一个示意图,描述了模块之间的关系(依赖和被依赖模块),以及 chunk 之间的父子关系。

[译]深度解析Webpack的SplitChunksPlugin(万字预警)

阐述问题

我们将通过在 dist 目录中产生的结果来迈向理解问题的第一步。

让我们花点时间分析 dist 目录中的内容。正如你所看到的,有5个文件,因此我们的项目由5个 chunk 组成。主 chunk 实际上可以视为应用程序的入口点,从其代码中可以注意到——它包含了大量的 Webpack 运行时代码,其中包含了加载其他 chunk(例如通过 HTTP)的逻辑,存储模块等。如果你查看 async-* 文件,你会看到只需要少量的运行时代码即可将这些 chunk 与主 chunk 连接起来。在 async-*.js 文件中的大部分代码都是用于导入的模块和导出的成员。

让我们考虑一个有趣的问题:你认为 x 模块(对应于 x.js 文件并被导入到 abc 模块中)将在这5个结果文件中被打包多少次?可以参考示意图。为了找到这个问题的答案,你可以复制 x.js 文件中导出的字符串,并在项目中进行全局搜索:

[译]深度解析Webpack的SplitChunksPlugin(万字预警)

因此,我们可以得出结论,x.js 的内容被复制了3次,每个导入模块都会生成一个单独的异步 chunk(例如,a 模块对应 async-ab 对应 async-bc 对应 async-c)。现在想象一下,如果 x.js 文件有数百行代码!这些代码将被重复打包3次——这是一种资源浪费。虽然在这个例子中不会导致 Webpack 多次执行 x 模块,在应用程序中仍然只会存在一个 x.js 实例。

然而,每个异步加载的 chunk 都将导致一次额外的 HTTP 请求。由于 x.js 的内容存在于3个 chunk 中,例如首次加载 async-a chunk,那么必须从网络下载并解析 async-a.js 文件中的所有代码。如果 x.js 文件相当大,这个加载过程可能需要相当长的时间才能完成。我们需要有更好的方法,让我们不必再次经历这个漫长的过程。

我们可以通过下面这个示意图更直观地来了解这个问题:

[译]深度解析Webpack的SplitChunksPlugin(万字预警)

这张图与第一张图并没有太大的区别,它只是用红色突出了 x 模块。这仅仅表明这个模块存在问题,它也将帮助我们辨别 SplitChunksPlugin 为解决这个问题所做的工作。下一节将进行详细的介绍。

注意:你可能已经注意到,fdy 模块也处于与 x 相同的情况,但为了简单起见,我们现在只关注 x

SplitChunksPlugin 如何解决这个问题

我猜你一定渴望深入了解 Webpack 配置,以便我们可以看到 SplitChunksPlugin 能做什么,但是让我们先考虑如何在理论层面解决这个问题。换句话说,在看到上面的示意图后,你会如何优化当前情况?假设 x.js 有1000行代码,我们的应用程序将不得不重复加载它们3次(因为 x 是3个不同 chunk 的一部分,每个块都通过 import() 从主 chunk 请求)。我还应该提到,如果同一个 chunk 被多次请求,只会进行一次 HTTP 请求,然后它的模块将被添加到缓存对象中,这样,在对该 chunk 进行后续请求时,将直接从该缓存对象中检索所有内容。

依赖以上信息,我们可以通过将 x 模块放置到一个新的 chunk 中,仅加载1000行代码一次。这样,当模块 a 需要模块 x 时,将发出 HTTP 请求以加载 x 所在的块(因为第一次加载块)。然后,当模块 b 需要 x 时,不会再次加载那1000行代码,而是直接从内存中检索 x。当模块 c 需要 x 时也是同样的情况。

让我们看看这个解决方案的示意图:

[译]深度解析Webpack的SplitChunksPlugin(万字预警)

视觉上被弱化的红色矩形代表已经进行的替换。现在,因为 x 模块在一个单独的 chunk 中,无论模块被请求多少次,它都只会在网络上加载一次。这就是可以使用 SplitChunksPlugin 实现的。

现在,让我们回到 Webpack 配置。为了使用 SplitChunksPlugin 并使用默认配置,webpack.config.js 文件应该如下所示:

module.exports = {
    mode: 'production',
    entry: {
        main: './src',
    },
    output: {
        path: path.join(__dirname, 'dist'),
        filename: '[name].js',
        clean: true,
    },
    optimization: {
        // 命令 webpack 不要压缩生成的代码
        minimize: false,
        splitChunks: {
            minSize: 0,
        },
    },
    context: __dirname,
}

minSize 选项现在与我们的目的并不是很相关,但我们需要它,因为 x 所属的 chunk 非常小,Webpack 会认为为几个字节创建一个新 chunk 并不值得。因此,我们需要告诉 SplitChunksPlugin,如果其总字节数大于或等于0,就创建一个新 chunk。

注意,x 模块不是唯一可以属于这个新 chunk 的模块,还可以有其他模块。分组标准是模块在其他 chunk 中的使用方式。例如,如果除了 x 之外,还有一个模块 wx 处境相同,则独立的 chunk 将包含 xw 模块。

SplitChunksPlugin 的配置决定是否以及如何创建新的 chunk。为了创建一个 chunk,必须遵守一组规则,这些规则定义了所谓的缓存组(cache group)。

例如,我们可以说:我希望仅为来自 node_modules 的模块创建 chunk,并且它们组成的 chunk 必须至少有1000字节。我们还可以强制模块所在的现有 chunk 数至少为 N。在我们的示例中,x 模块必须存在在至少3个 chunk 中,我们才会创建 chunk。

所有这些约束都可以被定义在一个缓存组中,只有满足缓存组中的所有条件才会被创建 chunk 。

在接下来的章节中,我们将研究 SplitChunksPlugin 可用的配置项。

cacheGroup 配置

回顾上一节末尾所提到的内容,缓存组定义了一组规则。这组规则决定什么情况下可以创建一个新 chunk。让我们看一个相当简单的缓存组示例:

cacheGroupFoo: {
    // module 必须属于的 chunk 数量
    minChunks: 3,
    // 块中放置的字节数(即每个组成模块的字节数之和)
    // 例如,如果一个块包含2个模块,`x` 和 `w`,那么 `nrBytesChunk = nrBytes(x)+ nrBytes(w)`。
    minSize: 10,
    // 哪些模块需要考虑。
    modulePathPattern: /node_modules/
}

根据上面所看到的,只有同时满足以下条件,才会创建一个新的 chunk:

  • 模块必须属于至少3个 chunk
  • chunk 的最小字节数必须至少为10
  • 导致创建新 chunk 的模块必须来自 node_modules 文件夹

这就是缓存组。从这样的缓存组可以创建许多 chunk。例如,如果 x 模块在3个不同的 chunk 中被需要,它属于 node_modules 文件夹,并且它至少有10个字节(将创建的块也有10个字节),那么根据上面的缓存组,x 将被提取到它自己的块中。如果还有另一个来自 node_modules 的模块 w,在4个不同的块中被需要,那么假设满足上述缓存组描述的所有其他条件,就会有另一个 w 模块的 chunk。如果 w 只在3个 chunk 中被需要,那么它将被放入与 x 相同的 chunk 中。

如果使用 SplitChunksPlugin 的默认配置。

/* ... */
optimization: {
    // splitChunks: false,
    splitChunks: {
        minSize: 0,
    },
},
/* ... */

更具体地说,不明确配置缓存组,插件将使用2个隐式缓存组,它们与以下显式声明的缓存组相同:

/* ... */
optimization: {
    splitChunks: {
        default: {
            idHint: "",
            reuseExistingChunk: true,
            minChunks: 2,
            priority: -20
        },
        defaultVendors: {
            idHint: "vendors",
            reuseExistingChunk: true,
            test: NODE_MODULES_REGEXP,
            priority: -10
        }
    },
},
/* ... */

不必担心上面新出现的配置,其中一些稍后在文章中会有介绍。现在,有一个大概的了解就足够了。

有两个缓存组:

  • 默认(default)缓存组,它考虑我们应用程序中的任何模块(即无论它们来自哪里);
  • 默认供应商(defaultVendors)缓存组,它仅考虑来自 node_modules 的模块(test 属性决定这一点)。

你可能已经注意到,默认供应商缓存组具有更高的优先级,在模块属于多个缓存组时,这起着重要作用。由于我们已经知道缓存组决定新 chunk 的产生,那么一个模块存在于多个新 chunk 中,这是不可取的,因为这与我们正在试图解决的问题相同:代码重复。因此,优先级选项将有助于确定模块将唯一存在在某个 chunk 中。

为了更好地理解默认缓存组,让我们看看它们在我们 SplitChunksPlugin 的示例中创建的 chunk。

webpack 配置文件如下:

{
    mode: 'production',
    entry: {
        main: './src',
    },
    output: {
        path: path.join(__dirname, 'dist'),
        filename: '[name].js',
        clean: true,
    },
    optimization: {
        // 命令 webpack 不要压缩生成的代码
        minimize: false,
        splitChunks: { minSize: 0, },
    },
    context: __dirname,
};

注意,如果缓存组本身没有配置某些选项,那么缓存组会继承这些选项。例如,minSize: 0 将被默认缓存组和默认供应商缓存组继承。你可以在 webpack 的类型声明文件中查看允许缓存组继承的选项。

在构建完毕后,让我们看一下 dist 目录。除了我们熟悉的 main 文件和 async-* 文件,还有些新文件:

[译]深度解析Webpack的SplitChunksPlugin(万字预警)

这些新文件表示存在新的 chunk。

毫无疑问,这些新的 chunk 是由 SplitChunksPlugin 创建的,但现在的问题是这些 chunk 内部包含哪些模块?在检查文件内部内容之前,这里有一张图,描述了文件结构和默认创建的 chunk(没有 SplitChunksPlugin 的情况下):

[译]深度解析Webpack的SplitChunksPlugin(万字预警)

我们将使用这张图来理解每个新 chunk 都是为何被创建的。现在让我们从最上面的文件开始逐个浏览各个新文件。

在这里,让我们再次看一下默认的缓存组配置,我们需要它们来理解为什么某些模块成为某个 chunk 的一部分:

// `SplitChunksPlugin` 使用的默认缓存组配置。
default: {
    idHint: "",
    reuseExistingChunk: true,
    minChunks: 2,
    priority: -20
},
defaultVendors: {
    idHint: "vendors",
    reuseExistingChunk: true,
    test: NODE_MODULES_REGEXP,
    priority: -10
}

在 571.js 中:

/* ... */
const __WEBPACK_DEFAULT_EXPORT__ = ('z');
/* ... */

这里有一个 z 模块。为什么呢?znode_modules 文件夹中,因此命中 defaultVendors 缓存组,它对模块必须出现的 chunk 数施加任何限制。在目前的情况下,z 只属于 async-c 块,defaultVendors 创建为其创建一个新 chunk。z 也可以被 default 缓存组命中,但是 defaultVendors 具有更高的优先级,因此 z 属于后者。

在 616.js 中:

/* ... */
const __WEBPACK_DEFAULT_EXPORT__ = ('y');
/* ... */

这是 y 模块。它被放在不同的 chunk 中,因为它在 node_modules文件夹中(因此 defaultVendors 再次创建一个新的 chunk),并出现在 async-basync-a 块中。

在 673.js 中:

/* ... */
const __WEBPACK_DEFAULT_EXPORT__ = ('d');
/* ... */

这是 d 模块。从截图中可以看出,它不来自 node_modules。这意味着默认缓存组负责创建这个 chunk。我们在图中可以看到它被 async-aasync-basync-c chunk 请求。这意味着有3个块,而默认缓存组定义一个模块必须出现的最小 chunk 数至少为2。这个条件得到满足,所以这个模块被放到了一个单独的 chunk 中。

在 714.js 中:

/* ... */
const __WEBPACK_DEFAULT_EXPORT__ = ('f');
/* ... */

这是 f 模块。从图中可以看到,f 存在于3个块中:async-gasync-basync-cf 模块不来自 node_modules,因此与上述情况相同,意味着该块是默认缓存组创建的。

最后,在 934.js 中:

/* ... */
const __WEBPACK_DEFAULT_EXPORT__ = ('some content from `x.js`!');
/* ... */

如预期,这是 x 模块。你可能已经猜到为什么它被放置到一个新的 chunk 中,因为我们在整篇文章中已经谈论过几次:它来自 node_modules(因此 defaultVendors 创建了这个新 chunk)。此外,它被3个块请求,但只有在我们在 defaultVendors 缓存组中明确使用 minChunks 选项时才会有影响。

这是使用 SplitChunksPlugin 默认配置的效果。顺便说一下,如果你有兴趣看看缓存组是如何进行比较以确定应该使用哪个来创建新的 chunk,这里是执行此操作的源代码

要有效地使用 SplitChunksPlugin 并根据需要自定义其行为,我们有必要花时间弄清默认缓存组的工作方式,这将使我们对其进行修改时不至于手足无措。这同样为我们进一步深入探讨 SplitChunksPlugin 配置提供了基础。不过在此之前,让我们先快速了解如何禁用缓存组,这是因为在后续内容中,我们将通过禁用它们来简化配置的理解。为示范起见,我们先行禁用默认的缓存组。

/* ... */
optimization: {
    splitChunks: {
        cacheGroups: {
        	// 我们将通过将其设置为 false 来禁用它
        	default: false,
        },
        minSize: 0,
  },
}
/* ... */

如果我们现在执行构建,你期望新生成的 chunk 是什么?如果默认缓存组被禁用,那么只有来自 node_modules 的模块会被考虑在内。由于没有其他限制,除了 minSize(如果没有它,我们将看不到任何新生成的块,因为文件与 minSize 的默认值相比太小),那么根据我们在本节中学到的知识,dist 目录应该包含3个新生成的 chunk,分别为 xyz 模块各一个:

[译]深度解析Webpack的SplitChunksPlugin(万字预警)

在接下来的章节中,我们将探索 SplitChunksPlugin 的一些功能。为了便于理解,我们将禁用每个示例中的默认缓存组。让我们开始吧!

minChunks 配置

我们已经在文章中遇到了这个配置:它表示模块所需的最小 chunk 数。让我们回想一下,为了让 SplitChunksPlugin 创建新的 chunk,它们必须满足一系列要求。也就是说,chunk 必须属于某个特定的缓存组。

为了让事情更容易理解,我们举一个例子,我们希望满足以下条件:

  • 新 chunk 将包含的模块必须来自 node_modules
  • 该模块必须出现在至少3个 chunk 中

考虑到这些限制,webpack 配置将如下所示:

optimization: {
    // 命令 webpack 不要压缩生成的代码
    minimize: false,
    splitChunks: {
      // minSize: 0,
      // minChunks: 3,
      cacheGroups: {
        // 禁用这个缓存组,以便我们可以一次专注于一件事情。
        default: false,
        defaultVendors: {
          // 我们也可以将此属性设置为:`splitChunks.minSize: 0`。
          // 由于此属性默认情况下被缓存组继承。
          minSize: 0,
 
          // 强制请求模块的最小 chunk 数。
          minChunks: 3,
 
          // Q: 新的块应该包含哪些模块?
          // A: 来自 `node_modules` 的模块。
          test: /node_modules/,
        },
      },
    },
  },

构建完毕后,dist 的内容会是什么?除了我们熟悉的 mainasync-* chunk 之外,只有一个新的 chunk,它包含 x 模块。根据我们之前看的第一张图,这是唯一来自 node_modules 并存在于至少 3 个 chunk 中的模块。

[译]深度解析Webpack的SplitChunksPlugin(万字预警)

如果我们设置 minChunks: 2,输出会是什么?我们应该看到2个 chunk:一个包含 x 模块,一个包含 y 模块。

chunks 配置

我们已经听到过几次类似“模块 x 出现在 N 个不同的 chunk 中”。通过 chunks 配置,我们可以指定这些 chunk 的类型。到目前为止,我们没有谈论过关于 chunks 和与其相关的内容。因此,我们将不得不再次回到初始图:

[译]深度解析Webpack的SplitChunksPlugin(万字预警)

除了一个 chunk 包含多个模块的事实外,chunk 的概念可能很难想象。根据我们拥有的信息,我们可以从图中推断出2种 chunk:

  • 异步 chunk:名为 async-* 的 chunk;这些块有什么共同点?它们都是通过 import() 函数创建的。
  • 初始/主 chunk:上图中只有一个 chunk 符合此类别。什么是主 chunk?它像任何其他类型的 chunk 一样,包含在应用程序中使用的模块,但这种类型的 chunk 还包含许多所谓的运行时代码;这是将所有生成的 chunk 连接在一起以使我们的应用程序正常工作的代码;例如,运行时代码包含加载异步 chunk 并将它们整合到应用程序中的逻辑;通常,在定义 entry 对象中的项目时可以识别这种类型的 chunk(例如,{entry:{main:'./index.js'}})。

现在回到 chunks 选项,它接受4个值:

  • 异步 - 缓存组只考虑异步 chunk(这是默认行为); 因此,我们不仅可以指定模块必须成为一部分的 chunk 的数量,还可以指定 chunk 的类型
  • 初始 - 只考虑初始/主要 chunk
  • 全部 - 任何 chunk
  • 一个函数,用于确定哪个 chunk 将被考虑;在这里,我们可以使用许多 chunk 的属性来过滤 chunk:它的名字,它的组成模块等。

值得强调的是 chunks 选项和 minChunks 之间的联系。我们使用 chunks 选项来过滤掉某些 chunk,然后 SplitChunksPlugin 检查剩余 chunk 的数量是否符合 minChunks。一个例子:

[译]深度解析Webpack的SplitChunksPlugin(万字预警)

如图所示,我们有一个新的主 chunk:a-initial。该 chunk 对应于配置文件 entry 中的 a-initial。因此,x 模块将从另一个 chunk 中请求。

让我们尝试来解决这样一个问题:我们希望仅在其组成模块在至少4个其他块中被要求时才创建新 chunk,而不管该 chunk 是否异步(基本上我们考虑所有 chunk)。以下是一种配置 SplitChunksPlugin 的方法,以便我们可以实现所需的结果:

optimization: {
  // 命令 webpack 不要压缩生成的代码
  minimize: false,
  splitChunks: {
    minSize: 0,
    chunks: 'all',
    minChunks: 4,
    cacheGroups: {
      // 禁用缓存组。
      default: false,
    },
  },
},

请注意,使用 chunks: 'all' 时,我们实际上是在告诉 SplitChunksPlugin:请考虑任何需要模块的 chunk,无论它是异步的还是初始的或任何其他类型。

构建完毕后,我们应该只看到生成了一个新的 chunk,毫不奇怪,它只包含模块 x

[译]深度解析Webpack的SplitChunksPlugin(万字预警)

我们将以几个小任务结束本节,旨在进一步了解此配置。注意:以下内容基于上图所示的 chunk 和它们的模块。

你是否发现以下配置有任何问题?(针对 chunksminChunks 组合)

optimization: {
  minimize: false,
  splitChunks: {
    minSize: 0,
    chunks: 'async',
    minChunks: 4,
    cacheGroups: {
      // 禁用这个缓存组。
      default: false,
    },
  },
},

问题在于没有模块出现在4个异步 chunk 中 - 因此我们最终只会得到重复的代码,因为没有创建单独的 chunk!最接近的是3,我们在这里谈论的是 x 模块。为了将 x 的代码正确提取到新块中,我们将不得不将 minChunks: 4 更改为 minChunks: 3

下一个配置怎么样?我们在这里要求 webpack 做什么?

optimization: {
  minimize: false,
  splitChunks: {
    minSize: 0,
    chunks: 'initial',
    minChunks: 4,
    cacheGroups: {
      // 禁用这个缓存组。
      default: false,
    },
  },
},

我们告诉 webpack 仅在它们包含来自 node_modules 的模块(默认的 defaultVendors)并且出现在至少4个初始 chunk 中时才拆分 chunk(即创建新 chunk)。在我们的情况下,只有一个初始 chunk 需要这些模块:a-initial 块,它需要模块 xz(还有主 chunk,但它只通过 import() 函数需要模块)。因此,这里的更正是将 minChunks: 4 更改为 minChunks: 1

总结

虽然这段学习经历充满挑战,但我相信这些努力是值得的。

快速回顾一下这个插件要解决的一个问题:代码重复 - 一个模块被复制到多个 chunk 中,特别是当模块有数百行代码时,这时成本会很高昂。解决这个问题的方法是将这样的模块集中在一起,以便它们可以被多个使用者重复使用。这可以通过将这些模块放到单独的 chunk 中来实现,该 chunk 仅在加载一次(作为 HTTP 请求从网络中获取)。

现在出现了一个问题:基于什么标准创建这些单独的 chunk? - 这就是 SplitChunksPlugin 发挥作用的地方 - 它决定如何正确地组织被多个地方使用的模块。

感谢阅读!