普通视图

发现新文章,点击刷新页面。
昨天以前首页

拆包的艺术:Webpack SplitChunksPlugin 执行流程全流程揭秘 🤩🤩🤩

作者 Moment
2025年4月13日 11:28

面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:yunmz777

SplitChunks 原理的实现,它的源码位置在 webpack/lib/optimize/SplitChunksPlugin.js,进入到源码,我们首先看到 SplitChunks 的 class 定义:

module.exports = class SplitChunksPlugin {
    constructor(options = {}) {
        const defaultSizeTypes = options.defaultSizeTypes || [            "javascript",            "unknown"        ];
        const fallbackCacheGroup = options.fallbackCacheGroup || {};
        const minSize = normalizeSizes(options.minSize, defaultSizeTypes);
        const minSizeReduction = normalizeSizes(
            options.minSizeReduction,
            defaultSizeTypes
        );
        const maxSize = normalizeSizes(options.maxSize, defaultSizeTypes);

        /** @type {SplitChunksOptions} */
        //options的初始化逻辑,概括为我们的配置优先,如果没有则采用默认规则里的相关项
        this.options = {
            chunksFilter: normalizeChunksFilter(options.chunks || "all"),
            defaultSizeTypes,
            minSize,
            minSizeReduction,
            minRemainingSize: mergeSizes(
                normalizeSizes(options.minRemainingSize, defaultSizeTypes),
                minSize
            ),
            enforceSizeThreshold: normalizeSizes(
                options.enforceSizeThreshold,
                defaultSizeTypes
            ),
            maxAsyncSize: mergeSizes(
                normalizeSizes(options.maxAsyncSize, defaultSizeTypes),
                maxSize
            ),
            maxInitialSize: mergeSizes(
                normalizeSizes(options.maxInitialSize, defaultSizeTypes),
                maxSize
            ),
            minChunks: options.minChunks || 1,
            maxAsyncRequests: options.maxAsyncRequests || 1,
            maxInitialRequests: options.maxInitialRequests || 1,
            hidePathInfo: options.hidePathInfo || false,
            filename: options.filename || undefined,
            getCacheGroups: normalizeCacheGroups(
                options.cacheGroups,
                defaultSizeTypes
            ),
            getName: options.name ? normalizeName(options.name) : defaultGetName,//这里就是我们提到的如果name为true则使用chunks配置的name
            automaticNameDelimiter: options.automaticNameDelimiter,
            usedExports: options.usedExports,
            fallbackCacheGroup: {
                chunksFilter: normalizeChunksFilter(
                    fallbackCacheGroup.chunks || options.chunks || "all"
                ),
                minSize: mergeSizes(
                    normalizeSizes(fallbackCacheGroup.minSize, defaultSizeTypes),
                    minSize
                ),
                maxAsyncSize: mergeSizes(
                    normalizeSizes(fallbackCacheGroup.maxAsyncSize, defaultSizeTypes),
                    normalizeSizes(fallbackCacheGroup.maxSize, defaultSizeTypes),
                    normalizeSizes(options.maxAsyncSize, defaultSizeTypes),
                    normalizeSizes(options.maxSize, defaultSizeTypes)
                ),
                maxInitialSize: mergeSizes(
                    normalizeSizes(fallbackCacheGroup.maxInitialSize, defaultSizeTypes),
                    normalizeSizes(fallbackCacheGroup.maxSize, defaultSizeTypes),
                    normalizeSizes(options.maxInitialSize, defaultSizeTypes),
                    normalizeSizes(options.maxSize, defaultSizeTypes)
                ),
                automaticNameDelimiter:
                    fallbackCacheGroup.automaticNameDelimiter ||
                    options.automaticNameDelimiter ||
                    "~"
            }
        };

        /** @type {WeakMap<CacheGroupSource, CacheGroup>} */
        //cacheGroup的数据结构是WeakMap,这样做的目的是缓存每一个CacheGroup后续使用
        this._cacheGroupCache = new WeakMap();
    }

准备阶段

SplitChunksPlugin 的执行过程中,准备阶段的核心工作是构建和维护 chunksInfoMap,它存储了所有关于代码分割的关键信息。可以理解为这是一个集中管理分割规则的数据结构,包含了每个 chunk 的大小、模块数量等信息。这些数据为后续的 minSizeminChunks 条件提供支持,chunksInfoMap 会不断更新,直到最终生成 results,这些 results 就是我们拆分后的最终 chunk 文件。

在这过程中,最重要的操作之一是 addModuleToChunksInfoMap,它负责将模块的分割信息添加到 chunksInfoMap 中。此外,多个辅助方法也在起作用,如处理 chunksSetchunksKey 等,这些方法帮助有效地管理和组织不同的 chunk 组合。

chunksKey 设置

在 SplitChunksPlugin 中,每个 chunk 都会被赋予一个唯一的标识符(即唯一索引)。这个过程通过以下方式实现:

const chunkIndexMap = new Map();
const ZERO = BigInt("0");
const ONE = BigInt("1");
const START = ONE << BigInt("31");
let index = START;
for (const chunk of chunks) {
  chunkIndexMap.set(chunk, index | BigInt((Math.random() * 0x7fffffff) | 0));
  index = index << ONE;
}

chunkIndexMap 是一个 Map,用于存储每个 chunk 和其对应的唯一索引。这个索引是通过以下几步生成的:

  1. BigInt 类型:使用 BigInt 来确保可以为每个 chunk 分配一个唯一且大范围的标识符,避免数值溢出。

  2. START 常量:START 是一个常量,表示索引的起始值,ONE << BigInt("31") 将 1 向左移 31 位,得到一个大整数作为起点。

  3. 递增的索引:index = index << ONE 每次将索引左移,这样生成的索引每次都会变大,确保每个 chunk 都有一个唯一的标识符。

通过 index | BigInt((Math.random() * 0x7fffffff) | 0) 这行代码,给每个 chunk 分配一个 随机数,避免出现重复的索引。这样,即使是同一个构建过程中,多个 chunk 也能拥有不同的索引。

接下来是生成 chunkKey,它是一个唯一的标识符,用于表示一组具有相同依赖关系的 chunks。它通过以下函数生成:

const getKey = (chunks) => {
  const iterator = chunks[Symbol.iterator]();
  let result = iterator.next();
  if (result.done) return ZERO;
  const first = result.value;
  result = iterator.next();
  if (result.done) return first;
  let key = chunkIndexMap.get(first) | chunkIndexMap.get(result.value);
  while (!(result = iterator.next()).done) {
    const raw = chunkIndexMap.get(result.value);
    key = key ^ raw;
  }
  return key;
};

迭代 chunks 集合:getKey 函数接受一个包含多个 chunks 的集合作为参数,并通过迭代器遍历这些 chunks。

生成 key:每个 chunk 的索引值来自于之前生成的 chunkIndexMap,通过按位操作(|^)将每个 chunk 的索引值合并成一个唯一的 key:

  1. 按位 |(或):用于合并两个 chunk 的索引,确保即使是同一组 chunk 也会生成唯一的 key。

  2. 按位 ^(异或):用于进一步结合多个 chunk 的索引,确保如果有更多的 chunk,它们之间的关系也能通过 key 表示出来。

最终 getKey 返回的 key 唯一地表示这组 chunks,并且这个 key 对于具有相同依赖的 chunks 来说是相同的。

chunkKeysSplitChunksPlugin 中的作用是为一组具有相同依赖关系的 chunks 生成唯一标识符。通过为每个 chunk 分配唯一的索引,并使用位运算合并这些索引,chunkKeys 确保共享依赖的 chunk 可以被正确分组。这样,当多个 chunk 共享相同的模块时,它们会生成相同的 chunkKey,使得插件能够识别出这些 chunk 之间的关系,并优化它们的拆分策略。chunkKeys 使得 Webpack 能够高效地管理和组织 chunks,避免重复的模块打包,并确保共享模块能够提取到独立的 chunk,从而提升性能和缓存命中率。

处理应用次数

count 在这里表示 chunksSet 集合中包含的 chunk 数量。

这个函数 groupChunkSetsByCount 的作用是将 chunk 集合按照其中包含的 chunk 数量进行分组。具体来说:

  1. 它接收一个 chunk 集合的迭代器 chunkSets 作为参数

  2. 创建一个 Map chunkSetsByCount,键是 chunk 数量,值是包含相同数量 chunk 的集合数组

  3. 遍历每个 chunksSet,获取其大小(即包含的 chunk 数量)

  4. chunksSet 添加到对应数量的数组中

chunkSets 是通过 getChunkSetsInGraph()getExportsChunkSetsInGraph() 函数获取的,实际上就是我们项目中编写的模块。这些函数会分析整个模块依赖图,找出模块与 chunks 之间的关系。

这种分组方式在后续处理中很有用,特别是在检查子集关系时。因为只有较小集合才可能是较大集合的子集,所以按照大小分组可以减少比较次数,提高性能。

例如,如果有一些 chunk 集合,分别包含 2、3、2、5、3 个 chunk,这个函数会将它们分组为:

  • 键为 2 的数组:包含 2 个 chunk 的集合

  • 键为 3 的数组:包含 3 个 chunk 的集合

  • 键为 5 的数组:包含 5 个 chunk 的集合

下面这段代码就是具体处理合并同 key 的 chunk 并处理子集的逻辑:

const createGetCombinations = (
  chunkSets,
  singleChunkSets,
  chunkSetsByCount
) => {
  /** @type {Map<bigint | Chunk, (Set<Chunk> | Chunk)[]>} */
  const combinationsCache = new Map();

  return (key) => {
    const cacheEntry = combinationsCache.get(key);
    if (cacheEntry !== undefined) return cacheEntry;
    if (key instanceof Chunk) {
      const result = [key];
      combinationsCache.set(key, result);
      return result;
    }
    const chunksSet =
      /** @type {Set<Chunk>} */
      (chunkSets.get(key));
    /** @type {(Set<Chunk> | Chunk)[]} */
    const array = [chunksSet];
    for (const [count, setArray] of chunkSetsByCount) {
      // "equal" is not needed because they would have been merge in the first step
      if (count < chunksSet.size) {
        for (const set of setArray) {
          if (isSubset(chunksSet, set)) {
            array.push(set);
          }
        }
      }
    }
    for (const chunk of singleChunkSets) {
      if (chunksSet.has(chunk)) {
        array.push(chunk);
      }
    }
    combinationsCache.set(key, array);
    return array;
  };
};

createGetCombinations 函数创建一个高效的组合查找器,它利用 groupChunkSetsByCount 预先分组的数据结构,快速定位并返回特定 chunk 集合的所有可能子集组合。通过智能缓存机制避免重复计算,且只比较小于当前集合大小的其他集合,显著减少了比较操作次数。这个函数是 SplitChunksPlugin 核心算法的关键部分,它帮助 webpack 在复杂项目中快速找到最佳代码分割方案,确定哪些模块组合应该被提取到共享 chunk 中,从而优化最终打包结果的体积和加载性能。

拆分缓存组

/**
 * @param {CacheGroup} cacheGroup the current cache group
 * @param {number} cacheGroupIndex the index of the cache group of ordering
 * @param {Chunk[]} selectedChunks chunks selected for this module
 * @param {bigint | Chunk} selectedChunksKey a key of selectedChunks
 * @param {Module} module the current module
 * @returns {void}
 */
const addModuleToChunksInfoMap = (
  cacheGroup,
  cacheGroupIndex,
  selectedChunks,
  selectedChunksKey,
  module
) => {
  // Break if minimum number of chunks is not reached
  if (selectedChunks.length < /** @type {number} */ (cacheGroup.minChunks))
    return;
  // Determine name for split chunk

  const name =
    /** @type {string} */
    (
      /** @type {GetName} */
      (cacheGroup.getName)(module, selectedChunks, cacheGroup.key)
    );
  // Check if the name is ok
  const existingChunk = compilation.namedChunks.get(name);
  if (existingChunk) {
    const parentValidationKey = `${name}|${
      typeof selectedChunksKey === "bigint"
        ? selectedChunksKey
        : selectedChunksKey.debugId
    }`;
    const valid = alreadyValidatedParents.get(parentValidationKey);
    if (valid === false) return;
    if (valid === undefined) {
      // Module can only be moved into the existing chunk if the existing chunk
      // is a parent of all selected chunks
      let isInAllParents = true;
      /** @type {Set<ChunkGroup>} */
      const queue = new Set();
      for (const chunk of selectedChunks) {
        for (const group of chunk.groupsIterable) {
          queue.add(group);
        }
      }
      for (const group of queue) {
        if (existingChunk.isInGroup(group)) continue;
        let hasParent = false;
        for (const parent of group.parentsIterable) {
          hasParent = true;
          queue.add(parent);
        }
        if (!hasParent) {
          isInAllParents = false;
        }
      }
      const valid = isInAllParents;
      alreadyValidatedParents.set(parentValidationKey, valid);
      if (!valid) {
        if (!alreadyReportedErrors.has(name)) {
          alreadyReportedErrors.add(name);
          compilation.errors.push(
            new WebpackError(
              "SplitChunksPlugin\n" +
                `Cache group "${cacheGroup.key}" conflicts with existing chunk.\n` +
                `Both have the same name "${name}" and existing chunk is not a parent of the selected modules.\n` +
                "Use a different name for the cache group or make sure that the existing chunk is a parent (e. g. via dependOn).\n" +
                'HINT: You can omit "name" to automatically create a name.\n' +
                "BREAKING CHANGE: webpack < 5 used to allow to use an entrypoint as splitChunk. " +
                "This is no longer allowed when the entrypoint is not a parent of the selected modules.\n" +
                "Remove this entrypoint and add modules to cache group's 'test' instead. " +
                "If you need modules to be evaluated on startup, add them to the existing entrypoints (make them arrays). " +
                "See migration guide of more info."
            )
          );
        }
        return;
      }
    }
  }
  // Create key for maps
  // When it has a name we use the name as key
  // Otherwise we create the key from chunks and cache group key
  // This automatically merges equal names
  const key =
    cacheGroup.key +
    (name ? ` name:${name}` : ` chunks:${keyToString(selectedChunksKey)}`);
  // Add module to maps
  let info = /** @type {ChunksInfoItem} */ (chunksInfoMap.get(key));
  if (info === undefined) {
    chunksInfoMap.set(
      key,
      (info = {
        modules: new SortableSet(undefined, compareModulesByIdentifier),
        cacheGroup,
        cacheGroupIndex,
        name,
        sizes: {},
        chunks: new Set(),
        reusableChunks: new Set(),
        chunksKeys: new Set(),
      })
    );
  }
  const oldSize = info.modules.size;
  info.modules.add(module);
  if (info.modules.size !== oldSize) {
    for (const type of module.getSourceTypes()) {
      info.sizes[type] = (info.sizes[type] || 0) + module.size(type);
    }
  }
  const oldChunksKeysSize = info.chunksKeys.size;
  info.chunksKeys.add(selectedChunksKey);
  if (oldChunksKeysSize !== info.chunksKeys.size) {
    for (const chunk of selectedChunks) {
      info.chunks.add(chunk);
    }
  }
};

这个函数是 SplitChunksPlugin 的核心部分,负责将模块添加到代码分割的信息映射表中。它实现了"哪些模块应该被提取到哪些共享 chunk 中"的决策逻辑。下面是详细解析:

const addModuleToChunksInfoMap = (
  cacheGroup, // 当前使用的缓存组配置
  cacheGroupIndex, // 缓存组的索引(用于排序)
  selectedChunks, // 为当前模块选择的 chunks
  selectedChunksKey, // 这些 chunks 的唯一标识键
  module // 当前处理的模块
) => {
  // ...
};

首先第一步是 minChunks 验证:

// 如果选中的 chunks 数量少于 minChunks 配置,直接返回
if (selectedChunks.length < /** @type {number} */ (cacheGroup.minChunks))
  return;

这确保了只有当模块被足够多的 chunks 引用时,才会被考虑提取到共享 chunk。

第二步确定分割 chunk 的名称:

const name = /** @type {string} */ (
  /** @type {GetName} */ (cacheGroup.getName)(
    module,
    selectedChunks,
    cacheGroup.key
  )
);

根据配置的命名函数和当前上下文生成新 chunk 的名称。

第三步名称冲突检查

const existingChunk = compilation.namedChunks.get(name);
if (existingChunk) {
  // ... 一系列验证逻辑
}

如果生成的名称已经存在,需要验证现有 chunk 是否可以安全地重用:

  • 创建一个唯一的验证键 parentValidationKey

  • 检查是否已经验证过这个键

  • 验证现有 chunk 是否是所有选中 chunks 的父级

  • 如果验证失败,报错并返回

第四步创建唯一键并准备添加到映射:

const key =
  cacheGroup.key +
  (name ? ` name:${name}` : ` chunks:${keyToString(selectedChunksKey)}`);

为 chunksInfoMap 创建一个唯一键,根据是否有名称采用不同的生成策略。

第五步获取或创建 ChunksInfoItem:

let info = /** @type {ChunksInfoItem} */ (chunksInfoMap.get(key));
if (info === undefined) {
  chunksInfoMap.set(
    key,
    (info = {
      /* 初始化新的 ChunksInfoItem */
    })
  );
}

如果这个键对应的信息不存在,创建一个新的 ChunksInfoItem 对象,包含模块集合、大小信息、chunk 集合等。

第六步添加模块并更新大小信息:

const oldSize = info.modules.size;
info.modules.add(module);
if (info.modules.size !== oldSize) {
  for (const type of module.getSourceTypes()) {
    info.sizes[type] = (info.sizes[type] || 0) + module.size(type);
  }
}

将当前模块添加到模块集合,并更新各类型资源的大小信息。

第七步更新 chunks 信息:

const oldChunksKeysSize = info.chunksKeys.size;
info.chunksKeys.add(selectedChunksKey);
if (oldChunksKeysSize !== info.chunksKeys.size) {
  for (const chunk of selectedChunks) {
    info.chunks.add(chunk);
  }
}

添加 chunks 的唯一标识键,并在必要时更新 chunks 集合。

这个函数是 SplitChunksPlugin 的核心,它:

  1. 根据缓存组配置,确定哪些模块应该被提取到共享 chunk

  2. 维护 chunksInfoMap,这是后续创建实际分割 chunks 的基础数据结构

  3. 处理模块、chunks 和大小信息的关联关系

  4. 解决命名冲突和验证现有 chunk 是否可重用

  5. 为最终的代码分割决策准备必要的信息

通过这个函数的执行,webpack 能够准确地收集所有满足分割条件的模块组合信息,从而在后续流程中做出最优的代码分割决策。

模块分组阶段

在完成准备工作后,接下来是核心的分组优化阶段。插件会遍历所有的模块,将符合条件的模块通过 addModuleToChunksInfoMap 方法存储到 chunksInfoMap 中,以便为后续的代码分割决策提供基础数据。

for (const module of compilation.modules) {
  // Get cache group
  const cacheGroups = this.options.getCacheGroups(module, context);
  if (!Array.isArray(cacheGroups) || cacheGroups.length === 0) {
    continue;
  }

  // Prepare some values (usedExports = false)
  const getCombs = memoize(() => {
    const chunks = chunkGraph.getModuleChunksIterable(module);
    const chunksKey = getKey(chunks);
    return getCombinations(chunksKey);
  });

  // Prepare some values (usedExports = true)
  const getCombsByUsedExports = memoize(() => {
    // fill the groupedByExportsMap
    getExportsChunkSetsInGraph();
    /** @type {Set<Set<Chunk> | Chunk>} */
    const set = new Set();
    const groupedByUsedExports =
      /** @type {Iterable<Chunk[]>} */
      (groupedByExportsMap.get(module));
    for (const chunks of groupedByUsedExports) {
      const chunksKey = getKey(chunks);
      for (const comb of getExportsCombinations(chunksKey)) set.add(comb);
    }
    return set;
  });

  let cacheGroupIndex = 0;
  for (const cacheGroupSource of cacheGroups) {
    const cacheGroup = this._getCacheGroup(cacheGroupSource);

    const combs = cacheGroup.usedExports ? getCombsByUsedExports() : getCombs();
    // For all combination of chunk selection
    for (const chunkCombination of combs) {
      // Break if minimum number of chunks is not reached
      const count =
        chunkCombination instanceof Chunk ? 1 : chunkCombination.size;
      if (count < /** @type {number} */ (cacheGroup.minChunks)) continue;
      // Select chunks by configuration
      const { chunks: selectedChunks, key: selectedChunksKey } =
        getSelectedChunks(
          chunkCombination,
          /** @type {ChunkFilterFunction} */
          (cacheGroup.chunksFilter)
        );

      addModuleToChunksInfoMap(
        cacheGroup,
        cacheGroupIndex,
        selectedChunks,
        selectedChunksKey,
        module
      );
    }
    cacheGroupIndex++;
  }
}

这段代码是 SplitChunksPlugin 的核心分组阶段,它遍历所有模块并确定它们应该如何被分配到不同的共享 chunks 中。下面是详细流程:

首先是获取模块的缓存组:

const cacheGroups = this.options.getCacheGroups(module, context);
if (!Array.isArray(cacheGroups) || cacheGroups.length === 0) {
    continue;
}

根据配置确定该模块适用哪些缓存组。如果没有匹配的缓存组,则跳过该模块。

这里有两种准备组合的方式,根据是否启用 usedExports 选项:

// 标准方式
const getCombs = memoize(() => {
  const chunks = chunkGraph.getModuleChunksIterable(module);
  const chunksKey = getKey(chunks);
  return getCombinations(chunksKey);
});

// 基于导出使用情况的方式
const getCombsByUsedExports = memoize(() => {
  // ... 获取按导出分组的 chunks 组合
});

这两个函数都通过 memoize 实现缓存,避免重复计算。它们的作用是找出所有包含当前模块的 chunks 组合。

接着遍历缓存组并处理每种组合

let cacheGroupIndex = 0;
for (const cacheGroupSource of cacheGroups) {
  const cacheGroup = this._getCacheGroup(cacheGroupSource);

  const combs = cacheGroup.usedExports ? getCombsByUsedExports() : getCombs();
  // ...
}

对每个适用的缓存组,SplitChunksPlugin 先获取其规范化配置,然后根据是否启用 usedExports 选择相应的 模块-chunks 组合获取方式,以确定哪些模块组合满足分割条件。

遍历所有可能的 chunk 组合

for (const chunkCombination of combs) {
  // 检查 minChunks 条件
  const count = chunkCombination instanceof Chunk ? 1 : chunkCombination.size;
  if (count < cacheGroup.minChunks) continue;

  // 根据配置过滤 chunks
  const { chunks: selectedChunks, key: selectedChunksKey } = getSelectedChunks(
    chunkCombination,
    cacheGroup.chunksFilter
  );

  // 添加到 chunksInfoMap
  addModuleToChunksInfoMap(
    cacheGroup,
    cacheGroupIndex,
    selectedChunks,
    selectedChunksKey,
    module
  );
}

对每个组合:

  1. 检查是否满足 minChunks 条件(模块至少要在多少个 chunks 中出现)

  2. 根据缓存组的 chunksFilter(如 "initial"、"async" 或 "all")过滤 chunks

  3. 将符合条件的模块和 chunks 信息添加到 chunksInfoMap

这个分组阶段的核心目的是:

  1. 收集信息:确定哪些模块被哪些 chunks 引用,以及它们如何组合

  2. 应用规则:根据 SplitChunks 配置(如 minChunks, chunksFilter)过滤组合

  3. 构建数据结构:将所有符合条件的模块-chunks 组合添加到 chunksInfoMap

chunksInfoMap 将在后续阶段被用于实际创建共享 chunks。这个阶段不进行实际的 chunk 创建,而是准备决策所需的所有信息。

通过这个复杂但高效的流程,webpack 能够智能地决定哪些模块应该被提取到共享 chunks 中,从而达到代码分割的优化目标。

20250413104511

在 SplitChunksPlugin 的逻辑里,有个很重要的判断,就是看一个模块是不是被多个 chunk 引用了,也就是 "chunks 数量 ≥ minChunks?" 这个条件。

简单来说,minChunks 的意思就是:这个模块得被多少个地方用到,Webpack 才觉得它“值”得被单独拆出去。比如你设置了 minChunks: 2,那一个模块至少被两个 chunk 用了,才会被考虑拆出来当公共模块。

为啥要这么干?因为如果一个模块只在一个地方用,没必要拆啊,拆出来还多一次网络请求,反而拖慢加载速度。所以这个判断就是为了避免乱拆,只提取真正“多人共用”的模块,这样打包出来才更高效。

也可以理解为,用这个条件来控制拆分的“尺度”,想多拆就设低点,想少拆就设高点~

依次检查阶段

在模块分组阶段,我们根据缓存组配置将模块按共享关系初步归类,并存入 chunksInfoMap。而在分组优化阶段,webpack 会进一步筛选 chunksInfoMap 中的每个条目,确保它们严格符合用户设定的 minSize、minChunks 等约束条件,对不满足条件的分组进行剔除,最终只保留真正有价值且符合所有优化规则的代码分割方案。

// Filter items were size < minSize
for (const [key, info] of chunksInfoMap) {
  if (removeMinSizeViolatingModules(info)) {
    chunksInfoMap.delete(key);
  } else if (
    !checkMinSizeReduction(
      info.sizes,
      info.cacheGroup.minSizeReduction,
      info.chunks.size
    )
  ) {
    chunksInfoMap.delete(key);
  }
}

/**
 * @typedef {object} MaxSizeQueueItem
 * @property {SplitChunksSizes} minSize
 * @property {SplitChunksSizes} maxAsyncSize
 * @property {SplitChunksSizes} maxInitialSize
 * @property {string} automaticNameDelimiter
 * @property {string[]} keys
 */

/** @type {Map<Chunk, MaxSizeQueueItem>} */
const maxSizeQueueMap = new Map();

while (chunksInfoMap.size > 0) {
  // Find best matching entry
  let bestEntryKey;
  let bestEntry;
  for (const pair of chunksInfoMap) {
    const key = pair[0];
    const info = pair[1];
    if (bestEntry === undefined || compareEntries(bestEntry, info) < 0) {
      bestEntry = info;
      bestEntryKey = key;
    }
  }

  const item = /** @type {ChunksInfoItem} */ (bestEntry);
  chunksInfoMap.delete(/** @type {string} */ (bestEntryKey));

  /** @type {Chunk["name"] | undefined} */
  let chunkName = item.name;
  // Variable for the new chunk (lazy created)
  /** @type {Chunk | undefined} */
  let newChunk;
  // When no chunk name, check if we can reuse a chunk instead of creating a new one
  let isExistingChunk = false;
  let isReusedWithAllModules = false;
  if (chunkName) {
    const chunkByName = compilation.namedChunks.get(chunkName);
    if (chunkByName !== undefined) {
      newChunk = chunkByName;
      const oldSize = item.chunks.size;
      item.chunks.delete(newChunk);
      isExistingChunk = item.chunks.size !== oldSize;
    }
  } else if (item.cacheGroup.reuseExistingChunk) {
    outer: for (const chunk of item.chunks) {
      if (chunkGraph.getNumberOfChunkModules(chunk) !== item.modules.size) {
        continue;
      }
      if (
        item.chunks.size > 1 &&
        chunkGraph.getNumberOfEntryModules(chunk) > 0
      ) {
        continue;
      }
      for (const module of item.modules) {
        if (!chunkGraph.isModuleInChunk(module, chunk)) {
          continue outer;
        }
      }
      if (!newChunk || !newChunk.name) {
        newChunk = chunk;
      } else if (chunk.name && chunk.name.length < newChunk.name.length) {
        newChunk = chunk;
      } else if (
        chunk.name &&
        chunk.name.length === newChunk.name.length &&
        chunk.name < newChunk.name
      ) {
        newChunk = chunk;
      }
    }
    if (newChunk) {
      item.chunks.delete(newChunk);
      chunkName = undefined;
      isExistingChunk = true;
      isReusedWithAllModules = true;
    }
  }

  const enforced =
    item.cacheGroup._conditionalEnforce &&
    checkMinSize(item.sizes, item.cacheGroup.enforceSizeThreshold);

  const usedChunks = new Set(item.chunks);

  // Check if maxRequests condition can be fulfilled
  if (
    !enforced &&
    (Number.isFinite(item.cacheGroup.maxInitialRequests) ||
      Number.isFinite(item.cacheGroup.maxAsyncRequests))
  ) {
    for (const chunk of usedChunks) {
      // respect max requests
      const maxRequests = /** @type {number} */ (
        chunk.isOnlyInitial()
          ? item.cacheGroup.maxInitialRequests
          : chunk.canBeInitial()
          ? Math.min(
              /** @type {number} */
              (item.cacheGroup.maxInitialRequests),
              /** @type {number} */
              (item.cacheGroup.maxAsyncRequests)
            )
          : item.cacheGroup.maxAsyncRequests
      );
      if (Number.isFinite(maxRequests) && getRequests(chunk) >= maxRequests) {
        usedChunks.delete(chunk);
      }
    }
  }

  outer: for (const chunk of usedChunks) {
    for (const module of item.modules) {
      if (chunkGraph.isModuleInChunk(module, chunk)) continue outer;
    }
    usedChunks.delete(chunk);
  }

  // Were some (invalid) chunks removed from usedChunks?
  // => readd all modules to the queue, as things could have been changed
  if (usedChunks.size < item.chunks.size) {
    if (isExistingChunk) usedChunks.add(/** @type {Chunk} */ (newChunk));
    if (
      /** @type {number} */ (usedChunks.size) >=
      /** @type {number} */ (item.cacheGroup.minChunks)
    ) {
      const chunksArr = Array.from(usedChunks);
      for (const module of item.modules) {
        addModuleToChunksInfoMap(
          item.cacheGroup,
          item.cacheGroupIndex,
          chunksArr,
          getKey(usedChunks),
          module
        );
      }
    }
    continue;
  }

  // Validate minRemainingSize constraint when a single chunk is left over
  if (
    !enforced &&
    item.cacheGroup._validateRemainingSize &&
    usedChunks.size === 1
  ) {
    const [chunk] = usedChunks;
    const chunkSizes = Object.create(null);
    for (const module of chunkGraph.getChunkModulesIterable(chunk)) {
      if (!item.modules.has(module)) {
        for (const type of module.getSourceTypes()) {
          chunkSizes[type] = (chunkSizes[type] || 0) + module.size(type);
        }
      }
    }
    const violatingSizes = getViolatingMinSizes(
      chunkSizes,
      item.cacheGroup.minRemainingSize
    );
    if (violatingSizes !== undefined) {
      const oldModulesSize = item.modules.size;
      removeModulesWithSourceType(item, violatingSizes);
      if (item.modules.size > 0 && item.modules.size !== oldModulesSize) {
        // queue this item again to be processed again
        // without violating modules
        chunksInfoMap.set(/** @type {string} */ (bestEntryKey), item);
      }
      continue;
    }
  }

  // Create the new chunk if not reusing one
  if (newChunk === undefined) {
    newChunk = compilation.addChunk(chunkName);
  }
  // Walk through all chunks
  for (const chunk of usedChunks) {
    // Add graph connections for splitted chunk
    chunk.split(newChunk);
  }

  // Add a note to the chunk
  newChunk.chunkReason =
    (newChunk.chunkReason ? `${newChunk.chunkReason}, ` : "") +
    (isReusedWithAllModules ? "reused as split chunk" : "split chunk");
  if (item.cacheGroup.key) {
    newChunk.chunkReason += ` (cache group: ${item.cacheGroup.key})`;
  }
  if (chunkName) {
    newChunk.chunkReason += ` (name: ${chunkName})`;
  }
  if (item.cacheGroup.filename) {
    newChunk.filenameTemplate = item.cacheGroup.filename;
  }
  if (item.cacheGroup.idHint) {
    newChunk.idNameHints.add(item.cacheGroup.idHint);
  }
  if (!isReusedWithAllModules) {
    // Add all modules to the new chunk
    for (const module of item.modules) {
      if (!module.chunkCondition(newChunk, compilation)) continue;
      // Add module to new chunk
      chunkGraph.connectChunkAndModule(newChunk, module);
      // Remove module from used chunks
      for (const chunk of usedChunks) {
        chunkGraph.disconnectChunkAndModule(chunk, module);
      }
    }
  } else {
    // Remove all modules from used chunks
    for (const module of item.modules) {
      for (const chunk of usedChunks) {
        chunkGraph.disconnectChunkAndModule(chunk, module);
      }
    }
  }

  if (
    Object.keys(item.cacheGroup.maxAsyncSize).length > 0 ||
    Object.keys(item.cacheGroup.maxInitialSize).length > 0
  ) {
    const oldMaxSizeSettings = maxSizeQueueMap.get(newChunk);
    maxSizeQueueMap.set(newChunk, {
      minSize: oldMaxSizeSettings
        ? combineSizes(
            oldMaxSizeSettings.minSize,
            item.cacheGroup._minSizeForMaxSize,
            Math.max
          )
        : item.cacheGroup.minSize,
      maxAsyncSize: oldMaxSizeSettings
        ? combineSizes(
            oldMaxSizeSettings.maxAsyncSize,
            item.cacheGroup.maxAsyncSize,
            Math.min
          )
        : item.cacheGroup.maxAsyncSize,
      maxInitialSize: oldMaxSizeSettings
        ? combineSizes(
            oldMaxSizeSettings.maxInitialSize,
            item.cacheGroup.maxInitialSize,
            Math.min
          )
        : item.cacheGroup.maxInitialSize,
      automaticNameDelimiter: item.cacheGroup.automaticNameDelimiter,
      keys: oldMaxSizeSettings
        ? oldMaxSizeSettings.keys.concat(item.cacheGroup.key)
        : [item.cacheGroup.key],
    });
  }

  // remove all modules from other entries and update size
  for (const [key, info] of chunksInfoMap) {
    if (isOverlap(info.chunks, usedChunks)) {
      // update modules and total size
      // may remove it from the map when < minSize
      let updated = false;
      for (const module of item.modules) {
        if (info.modules.has(module)) {
          // remove module
          info.modules.delete(module);
          // update size
          for (const key of module.getSourceTypes()) {
            info.sizes[key] -= module.size(key);
          }
          updated = true;
        }
      }
      if (updated) {
        if (info.modules.size === 0) {
          chunksInfoMap.delete(key);
          continue;
        }
        if (
          removeMinSizeViolatingModules(info) ||
          !checkMinSizeReduction(
            info.sizes,
            info.cacheGroup.minSizeReduction,
            info.chunks.size
          )
        ) {
          chunksInfoMap.delete(key);
          continue;
        }
      }
    }
  }
}

这段代码是 SplitChunksPlugin 中至关重要的分组优化和实际执行阶段,主要包含筛选、排序、创建和连接新 chunks 的完整流程。我将详细解析其执行过程:

1. 初始筛选阶段

// 筛选不满足大小要求的项
for (const [key, info] of chunksInfoMap) {
  if (removeMinSizeViolatingModules(info)) {
    chunksInfoMap.delete(key);
  } else if (
    !checkMinSizeReduction(
      info.sizes,
      info.cacheGroup.minSizeReduction,
      info.chunks.size
    )
  ) {
    chunksInfoMap.delete(key);
  }
}

这一步完成两项重要筛选:

  • 通过 removeMinSizeViolatingModules 移除不满足 minSize 的模块

  • 检查分割后的大小减少是否满足 minSizeReduction 的要求

  • 不满足条件的分组会被直接从 chunksInfoMap 中删除

2. 迭代处理分组

接下来,代码进入一个 while 循环,直到处理完 chunksInfoMap 中的所有条目:

while (chunksInfoMap.size > 0) {
  // 处理逻辑...
}

3. 选择最佳分组

// 找到最佳匹配项
let bestEntryKey;
let bestEntry;
for (const pair of chunksInfoMap) {
  const key = pair[0];
  const info = pair[1];
  if (bestEntry === undefined || compareEntries(bestEntry, info) < 0) {
    bestEntry = info;
    bestEntryKey = key;
  }
}

const item = bestEntry;
chunksInfoMap.delete(bestEntryKey);

这一步使用 compareEntries 函数比较所有分组,找出最优先处理的项。比较标准包括:

  1. 缓存组优先级 (priority)

  2. chunks 数量

  3. 可减少的大小

  4. 缓存组索引

  5. 模块数量和标识符

4. 检查 chunk 复用可能性

let chunkName = item.name;
let newChunk;
let isExistingChunk = false;
let isReusedWithAllModules = false;

// 尝试复用已命名的 chunk
if (chunkName) {
  const chunkByName = compilation.namedChunks.get(chunkName);
  if (chunkByName !== undefined) {
    // 复用逻辑...
  }
}
// 尝试复用现有 chunk
else if (item.cacheGroup.reuseExistingChunk) {
  // 复杂的复用查找逻辑...
}

这部分实现了两种 chunk 复用策略:

  • 如果有名称,尝试复用同名 chunk

  • 如果配置了 reuseExistingChunk,查找完全匹配模块集的现有 chunk

5. 应用限制条件

const enforced = item.cacheGroup._conditionalEnforce &&
                 checkMinSize(item.sizes, item.cacheGroup.enforceSizeThreshold);

const usedChunks = new Set(item.chunks);

// 检查 maxRequests 条件
if (!enforced && (Number.isFinite(...) || Number.isFinite(...))) {
    // 移除超过请求数限制的 chunks
}

// 移除不包含任何分割模块的 chunks
outer: for (const chunk of usedChunks) {
    // 检查逻辑...
}

这里应用了几项重要限制:

  • 检查 enforceSizeThreshold 以决定是否强制执行

  • 应用 maxInitialRequestsmaxAsyncRequests 限制

  • 移除那些实际上不需要分割的 chunks

6. 处理 chunks 变更与重新验证

// 如果移除了一些 chunks,重新处理这些模块
if (usedChunks.size < item.chunks.size) {
    // 重新添加到队列...
    continue;
}

// 验证 minRemainingSize 约束
if (!enforced && item.cacheGroup._validateRemainingSize && usedChunks.size === 1) {
    // 验证剩余大小...
}

这部分处理两种特殊情况:

  • 当可用 chunks 减少时,重新将模块添加到处理队列

  • 当只剩一个 chunk 时,验证剩余模块大小是否满足 minRemainingSize

7. 创建或连接新 chunk

// 如果不复用,创建新 chunk
if (newChunk === undefined) {
    newChunk = compilation.addChunk(chunkName);
}

// 为所有使用的 chunks 建立分割关系
for (const chunk of usedChunks) {
    chunk.split(newChunk);
}

// 设置 chunk 的各种属性
newChunk.chunkReason = ...;
if (item.cacheGroup.filename) {
    newChunk.filenameTemplate = item.cacheGroup.filename;
}

这一步完成 chunk 的实际创建或连接:

  • 如果需要,创建新的 chunk

  • 建立原始 chunks 与新 chunk 的分割关系

  • 设置各种元数据(原因、文件名模板等)

8. 移动模块到新 chunk

if (!isReusedWithAllModules) {
  // 将所有模块添加到新 chunk
  for (const module of item.modules) {
    if (!module.chunkCondition(newChunk, compilation)) continue;
    // 添加模块到新 chunk
    chunkGraph.connectChunkAndModule(newChunk, module);
    // 从原 chunks 中移除模块
    for (const chunk of usedChunks) {
      chunkGraph.disconnectChunkAndModule(chunk, module);
    }
  }
} else {
  // 从原 chunks 中移除所有模块
  for (const module of item.modules) {
    for (const chunk of usedChunks) {
      chunkGraph.disconnectChunkAndModule(chunk, module);
    }
  }
}

根据复用情况,有两种处理方式:

  • 常规情况:将模块连接到新 chunk,并从原 chunks 断开连接
  • 完全复用情况:只需从原 chunks 断开连接

9. 设置 maxSize 参数

if (Object.keys(...).length > 0 || Object.keys(...).length > 0) {
    // 设置 maxSize 相关参数...
}

为支持后续的 maxSize 处理,设置相关参数。

10. 更新其他分组

// 从其他分组中移除已处理的模块并更新大小
for (const [key, info] of chunksInfoMap) {
  if (isOverlap(info.chunks, usedChunks)) {
    // 更新模块和总大小...
  }
}

最后,这一步处理连锁效应:

  • 检查其他分组是否与当前处理的 chunks 有重叠

  • 移除这些分组中已处理的模块

  • 更新大小信息,可能会导致其他分组因不满足条件而被删除

执行流程总结

20250413111822

SplitChunksPlugin 的执行流程可以概括为以下关键步骤:

  1. 初始筛选:首先清理 chunksInfoMap,移除不满足 minSize 或 minSizeReduction 条件的分组。

  2. 优先级处理:循环处理 chunksInfoMap 中的条目,每次选取优先级最高的分组(基于 priority、chunks 数量和大小)。

  3. 尝试复用:检查是否可以复用现有 chunk,优先使用同名 chunk 或符合条件的已有 chunk。

  4. 应用限制:过滤不符合 maxInitialRequests 和 maxAsyncRequests 限制的 chunks。

  5. 创建分割:创建新 chunk(或使用复用的 chunk),建立与原 chunks 的分割关系。

  6. 移动模块:将模块从原 chunks 移动到新 chunk,更新相应的连接关系。

  7. 更新影响:处理对其他分组的连锁影响,移除已处理的模块,可能导致其他分组不再满足条件被删除。

  8. 循环继续:重复以上过程直到 chunksInfoMap 为空,完成所有可能的代码分割。

本质上,Webpack 在这一阶段的工作就是:找出最值得拆分的模块组合 → 检查各种限制条件 → 创建或复用 chunk → 转移模块 → 更新受影响的其他分组,不断循环直到处理完所有符合条件的组合。

整个流程确保了按照用户配置的优先级和条件智能地创建共享 chunks,优化最终的 bundle 结构和大小。

总结

SplitChunks 的作用就是帮我们把多个模块打包成更合理的共享 chunk,避免重复打包、提高缓存效率。Webpack 会先根据配置(比如 minSize、minChunks 等)过滤掉不需要拆的模块,然后把“被多个 chunk 引用”的模块挑出来,认为它们值得被单独提取。接着,它会通过一系列判断(比如 chunk 是否能复用、是否超过请求数量限制)来决定是创建新 chunk,还是复用旧的。每当一个 chunk 被生成,它还会更新其他相关分组,避免重复拆包。整个过程其实就是“挑模块 ➜ 检查规则 ➜ 创建 chunk ➜ 移模块 ➜ 循环处理”,直到没有模块再需要拆为止。

总之,它的目标就是:提取公共代码,优化加载性能,让你的项目打包结果又小又高效~ 💥

❌
❌