深入理解 Webpack5【二】:揭开前端编译优化的利刃 SplitChunkPlugin

深入理解 Webpack5【二】:揭开前端编译优化的利刃 SplitChunkPlugin

为了深入学习 Webpack,作者想从对于前端程序员来说最熟悉的 Loaders 和 Plugins 来进行深入剖析,从而踏入 Webpack 世界的大门。本文也是这个系列中的第二篇文章。

Plugins 可以在 Webpack 构建生命周期的各个阶段中运行。插件的核心是"钩子",这是 Webpack 的关键概念,整个插件机制就是基于钩子回调来影响编译状态的。Webpack 使用 tapable 来实现灵活多样的钩子机制,这里就不做多介绍。可见我之前发过的关于 tapable 源码解析的文章。

Webpack 构建的生命周期内存在着 200+ 个 Hooks,本文不可能对其倾尽笔墨,就以前端编译性能优化的第一大利刃 SplitChunkPlugin 为例子来窥探一下 Plugins 的威力究竟有多大吧。

Hi~我是盐焗乳鸽还要香锅。这是【深入理解 Webpack5 系列】文章的第二篇,希望这篇文章能给所有读者一些收获和感悟,本人也会继续深入学习 Webpack,继续输出更多有价值有深度的文章。

重要概念

首先我们要知道,在 Webpack 中,所有原始资源都以 Module 作为单元进行流转,但它只是在 Webpack 前半段解决资源【如何读】的问题,而在打包阶段,Chunk 则是输出阶段的基础组织单位。

SplitChunkPlugin 根据配置的优化规则,将一系列的 Chunks 分离并合并,重新生成一批性能更高的 chunks(拆成更细的 chunks 更加充分利用浏览器并行加载的特性、避免重复打包公共 chunk)。

  • Chunk:封装一个或者多个Module
  • ChunkGroup:由一个或者多个Chunk组成,一个ChunkGroup可以是其它ChunkGroup的 parent 或者 child
  • EntryPoint:是入口类型的ChunkGroup,包含了入口Chunk

开始

首先,我们要明白 SplitChunkPlugin 的默认规则,通过webpack5 SplitChunkPlugin 官网我们可以知道,默认规则如下:

js 复制代码
module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 20000,
      minRemainingSize: 0,
      minChunks: 1,
      maxAsyncRequests: 30,
      maxInitialRequests: 30,
      enforceSizeThreshold: 50000,
      cacheGroups: {
        defaultVendors: {
          test: /[\/]node_modules[\/]/,
          priority: -10,
          reuseExistingChunk: true,
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

简单来说,默认情况(Webpack 的默认配置)下 Webpack 会根据下述条件自动进行代码块分割:

  • 共享模块(至少被引用 2 次)或者 node_modules 模块
  • 新代码块大于 20kb
  • 按需加载块时的最大并行请求数将低于或等于 30
  • 初始页面加载时的最大并行请求数将低于或等于 30

「注意」 :默认情况下只有 【按需加载模块】 会根据上方条件进行打包优化。而且异步加载的文件会被拆分出一个独立 chunk。

全部是同步加载

js 复制代码
// a.js
export const a = 1;

// index.js
import { a } from "./a.js";
import chunk from "lodash-es/chunk.js";

console.log(a);
console.log(chunk([1, 2, 3], 2));

由于没有按需加载的模块,因此全部打包入一个 Chunk 中:

异步加载 loadsh/chunk

js 复制代码
// a.js
export const a = 1;

// index.js
import { a } from "./a.js";

import("lodash-es/chunk.js").then(res => {
 console.log(res.default([1, 2, 3]));
});

console.log(a);

由于loadsh是异步加载的,并且成功匹配上cacheGroup.test中的node_modules,因此被拆分出来了独立的 Chunk。

我们平时使用 React.Lazy 异步加载的模块会被拆分出来。

公共模块(大小大于 20KB)

js 复制代码
// a.js
import common from "./common";
console.log("===a.js", common);
export const a = 2;
// b.js
import common from "./common";
console.log("===b.js", common);
export const b = 2;


// index.js
// 默认情况下只会对异步加载模块生效,这里必须要异步加载。
import("./a").then(res => {
 console.log(res);
});
import("./b").then(res => {
 console.log(res);
});

原理

这里以:例三 公共模块作为基础,在 index.js 中引入一个 lodash 作为例子进行分析 SplitChunksPlugin 的原理。首先了解了上面的知识后,先抛出一个问题:【以下例子最终会生成多少个 chunks 呢?

javascript 复制代码
// a.js
import common from "./common";
console.log("===a.js", common);
export const a = 2;
// b.js
import common from "./common";
console.log("===b.js", common);
export const b = 2;


// index.js
import "lodash"; // 引入lodash
// 默认情况下只会对异步加载模块生效,这里必须要异步加载。
import("./a").then(res => {
 console.log(res);
});
import("./b").then(res => {
 console.log(res);
});

配置文件:

js 复制代码
// webpack.config.js
optimization: {
      splitChunks: {
          cacheGroups: {
              lodashVender: {
                  test: /[\/]node_modules[\/]lodash/,
                  reuseExistingChunk: true,
                  chunks: "all",
                  filename: "lodashVender.js"
              }
          }
      }
  }

稍微思考一下可以知道:一共会生成 5 个 Chunks!!! 下面将深入浅出地讲解一下原理:

在这个例子中,在seal阶段首先会生成 3 个 Chunk

【目的】:接下来分析一下,SplitChunkPlugin 是如何把 Chunk a 和 b 中的 common 以及 initial chunk 中的 lodash 给分割出来 Chunk吧。

开始

原理图如上,具体的链接可以点击这里查看:xfjhqij9zq.feishu.cn/docx/ZLwcdC...

首先,SplitChunkPlugin 绑定在了 optimizeChunks Hooks 中:

js 复制代码
 apply(compiler) {
  compiler.hooks.thisCompilation.tap("SplitChunksPlugin", compilation => {
   let alreadyOptimized = false;
   compilation.hooks.optimizeChunks.tap(
    {
     name: "SplitChunksPlugin",
     stage: STAGE_ADVANCED
    },
    chunks => {}
    )
  }
}

我们可以整体把 SplitChunkPlugin 按照 log.time 分成 4 大部份:

js 复制代码
chunks => {
  logger.time("prepare");
  //...
  logger.timeEnd("prepare");

  logger.time("modules");
  for (const module of compilation.modules) {
      //...
  }
  logger.timeEnd("modules");

  logger.time("queue");
  for (const [key, info] of chunksInfoMap) {
      //...
  }
  while (chunksInfoMap.size > 0) {
      //...
  }
  logger.timeEnd("queue");

  logger.time("maxSize");
  for (const chunk of Array.from(compilation.chunks)) {
      //...
  }
  logger.timeEnd("maxSize");
}

modules 阶段

prepare 阶段是初始化一些后面需要用到的变量以及方法,跳过即可。直接来看到 modules 阶段。

对于 modules 阶段而言,一句话概括就是:「检测出符合切割条件的 chunk,记录进chunksInfoMap中」。接下来我们分步来解析一下在 modules 阶段中,到底发生了什么。

第一步:获取符合当前遍历到的module的cacheGroups。

js 复制代码
for (const module of compilation.modules) {
   // 获取有效的CacheGroups,比如没有匹配到cacheGroup的缓存组过滤掉
    let cacheGroups = this.options.getCacheGroups(module, context);
}

const normalizeCacheGroups = (cacheGroups, defaultSizeTypes) => {
  if (typeof cacheGroups === "object" && cacheGroups !== null) {
    const handlers = [];
    for (const key of Object.keys(cacheGroups)) {
      const option = cacheGroups[key];
      // 省略了一些情况,这里只看option是对象是情况!!!
      // option = cacheGroup[key]  如果是一个对象,先标准化获得source
      const source = createCacheGroupSource(option, key, defaultSizeTypes);
      handlers.push((module, context, results) => {
          if (
              checkTest(option.test, module, context) &&
              checkModuleType(option.type, module) &&
              checkModuleLayer(option.layer, module)
          ) {
              results.push(source);
          }
      });
}
  /**
   * 默认会有两个cacheGroup,default是一定会被push进results的,第二个是node_modules,如果test没有匹配上resource则不会被push进来
   * @param {Module} module the current module
   * @param {CacheGroupsContext} context the current context
   * @returns {CacheGroupSource[]} the matching cache groups
   */
  const fn = (module, context) => {
   /** @type {CacheGroupSource[]} */
   let results = [];
   for (const fn of handlers) {
    fn(module, context, results);
   }
   return results;
  };
  return fn;
 }
 return () => null;
};

这里的this.options.getCacheGroups在初始化阶段被赋予了normalizeCacheGroups方法,逻辑并不复杂,简单来说是将满足当前 module 的 CacheGroup 推入数组中。

而这的条件有3个,就是 CacheGroup.testlayertype。他们分别与当前 module 的绝对路径 module.matchResource || module.resource、类型 module.type、以及 module.layer进行判断。一般来说,正常情况下只会配置 test 属性,因此如果 CacheGroup.layer 为空,直接返回truetype同理。

按照我们上述的配置而言,cacheGroups一共有三个:

js 复制代码
cacheGroups: {
  defaultVendors: {
    test: /[\/]node_modules[\/]/,
    priority: -10,
    reuseExistingChunk: true,
  },
  default: {
    minChunks: 2,
    priority: -20,
    reuseExistingChunk: true,
  },
  lodashVender: {
      test: /[\/]node_modules[\/]lodash/,
      reuseExistingChunk: true,
      chunks: "all",
      filename: "lodashVender.js"
  }
},

而对于 index.js 模块而言,只有 default 符合,对于 lodash 模块而言,则有 defaultVendors 以及 lodashVender 符合。

(这里实际上只有 lodashVender 会生效,因为后面会对 chunks 的类型做校验,而前面说了默认只会对异步模块做分割,因此 defaultVendors 会被过滤掉~)

第二步:遍历有效的cacheGroup,获取chunk combination

js 复制代码
for (const module of compilation.modules) {
   // 获取有效的CacheGroups,比如没有匹配到cacheGroup的缓存组过滤掉
    let cacheGroups = this.options.getCacheGroups(module, context);
                
    for (const cacheGroupSource of cacheGroups) {
        // 标准化cacheGroupItem 子项
        const cacheGroup = this._getCacheGroup(cacheGroupSource);
        // ================阶段二:获取当前module的所用到的chunks
        /**
         * usedExports = true的时候开启Tree Shaking,没有引用的exports不会打包进来
         * 返回值:得到当前modules对应Chunk的数组
         */
        const combs = cacheGroup.usedExports
            ? getCombsByUsedExports()
            : getCombs();
    }
} 

什么是chunk combination呢?直观地来看可以理解为当前 module 所被包含的 chunks 们。这里获取的 chunks 会根据 cacheGroup.usedExports 有所不同。

cacheGroup.usedExports的作用:引用文档原话( Figure out which exports are used by modules to mangle export names, omit unused exports and generate more efficient code. )说白了就是分析导出的变量是否有被使用,给 Tree Shaking 使用

我们分两个场景来看一下:(代码有删减,想了解更多自行看源码)

  • 首先是开发环境(dev)下,usedExports默认是false:
js 复制代码
// Prepare some values (usedExports = false)
const getCombs = memoize(() => {
    const chunks = chunkGraph.getModuleChunksIterable(module);
    // 遍历chunks,获取其中的chunk,
    // 这里的chunksKey实际上就是一个Chunk。
    //(因为chunks是一个Set,这里使用Iterator来取)
    const chunksKey = getKey(chunks);
    return getCombinations(chunksKey);
});

const getCombinations = key => getCombinationsFactory()(key);

const getCombinationsFactory = memoize(() => {
    // 获取所有 modules 的 chunks集合
    const { chunkSetsInGraph, singleChunkSets } = getChunkSetsInGraph();
    return createGetCombinations(
        chunkSetsInGraph,
        singleChunkSets,
        getChunkSetsByCount()
    );
});

const createGetCombinations = (
    chunkSets,
    singleChunkSets,
    chunkSetsByCount
) => {
    /** @type {Map<bigint | Chunk, (Set<Chunk> | Chunk)[]>} */
    const combinationsCache = new Map();
    // 这里的key是chunkKey
    return key => {
        const cacheEntry = combinationsCache.get(key);
        if (cacheEntry !== undefined) return cacheEntry;
        // 如果是简单的Chunk,则包裹一层数组返回
        if (key instanceof Chunk) {
            const result = [key];
            combinationsCache.set(key, result);
            return result;
        }
        const chunksSet = 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;
    };
};

这里先介绍一下较重要的 getKey(chunk)getChunkSetsInGraph()方法。

js 复制代码
const getChunkSetsInGraph = memoize(() => {
    const chunkSetsInGraph = new Map();
    const singleChunkSets = new Set();
    for (const module of compilation.modules) {
        //一般只有一个Chunks,如果是公共模块,会被多个Chunks引用
        const chunks = chunkGraph.getModuleChunksIterable(module);
        const chunksKey = getKey(chunks);
        if (typeof chunksKey === "bigint") {
            if (!chunkSetsInGraph.has(chunksKey)) {
                chunkSetsInGraph.set(chunksKey, new Set(chunks));
            }
        } else {
            singleChunkSets.add(chunksKey);
        }
    }
    return { chunkSetsInGraph, singleChunkSets };
});

/**
 * 遍历chunks,获取其中的chunk。(因为chunks是一个Set,这里使用Iterator来取)
 * 如果只有一个Chunk,则直接返回该Chunk
 * 如果是多个Chunks,则返回他们Index的或作为新Key
 */
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;
};
  • getKeys:比较简单,作用是接收当前 module 被包含的所有 chunks,如果只有一个则直接返回chunk;如果有多个,则返回它们 index 的异或作为新 index。
  • getChunkSetsInGraph:返回{ chunkSetsInGraph, singleChunkSets },其中singleChunkSets是所有 chunks 的去重 chunks,chunkSetsInGraph则是如果有 module 被包含在多个chunks中,则以Map<index, Set<Chunk>>形式记录下来。

为了描述方便,下文开始我们把例子中的 3 个 chunk 分别命名为 initChunkchunkAchunkB

举个栗子,entry module 只获取了 1 个 initChunk,返回的 chunkKey 也是 initChunk,此时将会返回的 combination 是 [initChunk]

js 复制代码
if (key instanceof Chunk) {
    const result = [key];
    combinationsCache.set(key, result);
    return result;
}

common module 获取的是 chunkAchunkB,返回的 chunksKey 是每一个 chunks index 的异或。(在 prepare 阶段中会给每一个 chunks 赋值一个唯一 index),此时将会返回的 combination 是 [Set<chunkA,chunkB>, chunkA, chunkB]

js 复制代码
const chunksSet = chunkSets.get(key);
const array = [chunksSet];
for (const chunk of singleChunkSets) {
  if (chunksSet.has(chunk)) {
      array.push(chunk);
  }
}
return array;
  • 其次是生产环境(prod)下,usedExports默认是true,因此来到了 getCombsByUsedExports方法:
js 复制代码
const getCombsByUsedExports = memoize(() => {
    // fill the groupedByExportsMap
    getExportsChunkSetsInGraph();
    
    const set = new Set();
    const groupedByUsedExports = groupedByExportsMap.get(module)
    for (const chunks of groupedByUsedExports) {
        const chunksKey = getKey(chunks);
        for (const comb of getExportsCombinations(chunksKey))
            set.add(comb);
    }
    return set;
});

getExportsChunkSetsInGraph 方法在第一次运行的时候,会初始化好 groupedByExportsMap,逻辑这里就不细说了,以 Module: Array[Chunk] 的方式收集好全部的 modules。

然后对当前 module 的 chunks 进行遍历,通过 getExportsCombinations 获取 comb。让我们继续看看这次的 comb 跟开发环境下有什么区别吧:

js 复制代码
const getExportsCombinations = key => getExportsCombinationsFactory()(key);

const getExportsCombinationsFactory = memoize(() => {
    const { chunkSetsInGraph, singleChunkSets } =
        getExportsChunkSetsInGraph();
    return createGetCombinations(
        chunkSetsInGraph,
        singleChunkSets,
        getExportsChunkSetsByCount()
    );
});

兜兜转转还是回到 createGetCombinations,似乎也没什么区别😑。。。说笑了,其实区别在于 getExportsChunkSetsInGraph 的过程中,在收集 modules 和 chunks 的过程中,用到了 moduleGraph.getExportsInfo(module) 方法,实际上是获取了 mgm.exports,它记录了每一个 module 导出的对象,这里了解即可。

【重要】第三步:遍历chunk combination,记录下需要拆分的chunk、modules信息

js 复制代码
for (const module of compilation.modules) {
   // 获取有效的CacheGroups,比如没有匹配到cacheGroup的缓存组过滤掉
    let cacheGroups = this.options.getCacheGroups(module, context);
                
    for (const cacheGroupSource of cacheGroups) {
        // 标准化cacheGroupItem 子项
        const cacheGroup = this._getCacheGroup(cacheGroupSource);
        // ================阶段二:获取当前module的所用到的chunks
        /**
         * usedExports = true的时候开启Tree Shaking,没有引用的exports不会打包进来
         * 返回值:得到当前modules对应Chunk的数组
         */
        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;

            // 普通&默认情况是2,因此modules只有被引用次数至少是2,才会被拆分
            // 对于node_modules以及其他匹配到的cacheGroup,minChunks是1
            if (count < cacheGroup.minChunks) continue;
            // 根据配置项中的chunks,过滤掉不和预期的Chunk。
            // Select chunks by configuration
            const { chunks: selectedChunks, key: selectedChunksKey } =
                getSelectedChunks(
                    chunkCombination,
                    cacheGroup.chunksFilter
                );

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

看到了这里那么恭喜你已经半只脚踏入 webpack 拆包的领域了/

前面我们提到,当 combs 只有一个 initChunk 的时候(entry module),此时 count = 1,而此时的 cacheGroup.minChunks = 2,因此直接被过滤掉不需要拆包。

js 复制代码
default: {
  minChunks: 2,
  priority: -20,
  reuseExistingChunk: true,
},

这就是为什么默认情况下,模块被引用的数量必须大于 2 才会被拆分。 如果是遍历到 module = lodash 的时候,匹配到的 cacheGroup 并没有设置 minChunks,因此会直接采用默认的 1,因此也可以顺利通过过滤。

让我们切换视角来到 common module,此时 combs 包含了 chunkAchunkB,因此顺利通过第一道门槛,进入到 getSelectedChunks

js 复制代码
const getSelectedChunks = (chunks, chunkFilter) => {
    let entry = selectedChunksCacheByChunksSet.get(chunks);
    if (entry === undefined) {
        entry = new WeakMap();
        selectedChunksCacheByChunksSet.set(chunks, entry);
    }
    let entry2 =
        entry.get(chunkFilter)
    if (entry2 === undefined) {
        const selectedChunks = [];
        if (chunks instanceof Chunk) {
            if (chunkFilter(chunks)) selectedChunks.push(chunks);
        } else {
            for (const chunk of chunks) {
                if (chunkFilter(chunk)) selectedChunks.push(chunk);
            }
        }
        entry2 = {
            chunks: selectedChunks,
            key: getKey(selectedChunks)
        };
        entry.set(chunkFilter, entry2);
    }
    return entry2;
};

这里的关键其实就是去过滤符合条件的 chunks。还记得我们一开始说的 默认情况下只会对【异步模块】进行拆分,过滤逻辑就是在这里。

默认情况下cacheGroup.chunks = splitChunks.chunks = 'async',所有对应的 chunkFilter 如下:

js 复制代码
// chunks = initial
const INITIAL_CHUNK_FILTER = chunk => chunk.canBeInitial();
// chunks = async 
const ASYNC_CHUNK_FILTER = chunk => !chunk.canBeInitial();
// chunks = all
const ALL_CHUNK_FILTER = chunk => true;

由于 common module 依赖的 chunkAchunkB 都是 async chunk,因此还是有惊无险的通过了第二道门槛。得到了 selectedChunksselectedChunksKey

最后迎面而来的是第三道大门 addModuleToChunksInfoMap:

js 复制代码
const addModuleToChunksInfoMap = (
    cacheGroup,
    cacheGroupIndex,
    selectedChunks,
    selectedChunksKey,
    module
) => {
  // 继续对 minChunks 过滤
  if (selectedChunks.length < cacheGroup.minChunks) return;
  
  const name = cacheGroup.getName(module, selectedChunks, cacheGroup.key);
  const key =
              cacheGroup.key +
              (name
                  ? ` name:${name}`
                  : ` chunks:${keyToString(selectedChunksKey)}`);
  
  // chunksInfoMap:所有需要拆分的 chunks 信息
  let info = chunksInfoMap.get(key);
  if (info === undefined) {
      chunksInfoMap.set(
          key,
          (info = {
              modules: new SortableSet(
                  undefined,
                  compareModulesByIdentifier
              ),
              cacheGroup,
              cacheGroupIndex,
              name,
              sizes: {},
              chunks: new Set(),
              reuseableChunks: new Set(),
              chunksKeys: new Set()
          })
      );
  }
  const oldSize = info.modules.size;
  // 添加 module
  info.modules.add(module);
  // 更新 size
  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);
  // 更新 chunks
  if (oldChunksKeysSize !== info.chunksKeys.size) {
      for (const chunk of selectedChunks) {
          info.chunks.add(chunk);
      }
  }
}

这段逻辑比较简单,将需要拆分的 module 和 所包含的 chunkAchunkB 的信息放入map中。此时 chunksInfoMap 的数据如下:

再加上我们手动拆的 lodashVender,最终的数据如下:

queue 阶段

对于 queue 阶段而言,一句话概括就是:「创建新Chunk,断开链接,建立链接」

根据chunksInfoMap,创建新Chunk,新 Chunk 需要与 ChunkGroupmodules建立连接,旧ChunkChunkGroupmodules断开链接,做完了这些,后续打包的时候 webpack 就可以根据 ChunkGroup 的新结构进行文件生成。

第一步:进行chunk minSize的过滤

即使满足了前面众多条件被成功记录进了 chunksInfoMap中,也难免会有不必要的拆分。例如拆出来的包的体积微乎其微,这也算是一种过度浪费了。

js 复制代码
/**
 * 如果拆分过chunk的话,chunksInfoMap将记录下来。如果info.size < minSize则remove掉
 * 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);
    }
}


const removeMinSizeViolatingModules = info => {
    if (!info.cacheGroup._validateSize) return false;
    // 实际上是遍历 cacheGroup 中规定的 minSize,与当前 info.sizes 比较,如果有小于 minSize 则 remove。
    const violatingSizes = getViolatingMinSizes(
        info.sizes,
        info.cacheGroup.minSize
    );
    if (violatingSizes === undefined) return false;
    
    // remove module from infoMaps
    removeModulesWithSourceType(info, violatingSizes);
    return info.modules.size === 0;
};

这里是去检测该 info.size 是否小于 cacheGroup.minSize,如果小于则不拆包。默认的minSize如下:

js 复制代码
{
  unknown: 10000,
  javascript: 10000
}

除了这种情况,如果我们配置了 cacheGroup.minSizeReduction,也会进行过滤。什么是 minSizeReduction 呢?官网解释为:被拆分包的最小体积缩减。如果拆包后对原有主包的体积缩减并没有达到预期,那还不如不拆。

js 复制代码
const checkMinSizeReduction = (sizes, minSizeReduction, chunkCount) => {
 for (const key of Object.keys(minSizeReduction)) {
  const size = sizes[key];
  if (size === undefined || size === 0) continue;
  if (size * chunkCount < minSizeReduction[key]) return false;
 }
 return true;
};

因为后续拆包会将 module 从当前包含它的 chunks 中断开链接,因此 size * chunkCount 计算出来的则是 chunk 们的缩减体积。

问题1: info.size 是如何计算出来的呢?

在上文中的 addModuleToChunksInfoMap 中可以发现,info.size 是 info 中所有 module.size 的总和。案例中所有的 module 都是 NormalModule,因此直接来到 lib/NormalModule.js 中查看:

js 复制代码
// /lib/NormalModule.js
size(type) {
    // 由于是js文件,因此genertor是 JavascriptGenerator
    const size = Math.max(1, this.generator.getSize(this, type));
    return size;
}

// /lib/javascript/JavascriptGenerator.js
getSize(module, type) {
    const originalSource = module.originalSource();
    return originalSource.size();
}

// node_modules/webpack-sources/lib/Source.js
buffer() {
    // 这里的 source 就是用 fs 读取到的文件字符串
    const source = this.source();
    if (Buffer.isBuffer(source)) return source;
    return Buffer.from(source, "utf-8");
}

size() {
    return this.buffer().length;
}

由其可见, module.size实际上就是模块文本字符串的字节数大小。

第二步:遍历ChunkInfoMap,寻找本次拆分的bestEntry,创建新的Chunk

js 复制代码
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 compareEntries = (a, b) => {
    // 1. by priority
    const diffPriority = a.cacheGroup.priority - b.cacheGroup.priority;
    if (diffPriority) return diffPriority;
    // 2. by number of chunks
    const diffCount = a.chunks.size - b.chunks.size;
    if (diffCount) return diffCount;
    // 3. by size reduction
    const aSizeReduce = totalSize(a.sizes) * (a.chunks.size - 1);
    const bSizeReduce = totalSize(b.sizes) * (b.chunks.size - 1);
    const diffSizeReduce = aSizeReduce - bSizeReduce;
    if (diffSizeReduce) return diffSizeReduce;
    // 4. by cache group index
    const indexDiff = b.cacheGroupIndex - a.cacheGroupIndex;
    if (indexDiff) return indexDiff;
    // ...省略
};

在上述的 modules 阶段 中我们最终得到了要进行拆包两个 module:common modulelodash module。但是,他们之间在拆包过程中存在谁在前谁在后的顺序关系,这时候就需要判断优先级了。

compareEntries 函数在 SplitChunksPlugin 中起到了挑选最优 cache group 的作用。其中,ab 都代表一个 cache group,它们都包含了许多 Chunks 。这个函数用几个步骤来比较和区分 cache groups:

  1. 根据 cache group 的优先级 priority 比较。优先级高的 cache group 会被优先选择。
  2. 如果优先级相同,会比较 cache group 所得 chunk 的数目。数目多的优先。
  3. 如果数目也相同,比较的是经过此 cache group 后,能减少的总 module 大小。能减少更多的优先。
  4. 如果大小也相同,就根据 cache group 在配置中的顺序来决定优先级,顺序越前面优先级越高。

由于 common module 是默认配置,其 priority = -20 远比 lodash modulepriority = 0 要低,因此这里的 bestEntrylodash info

js 复制代码
// 下面开始拆,先把原来的 key 删除防止重复拆包
const item = bestEntry;
chunksInfoMap.delete(bestEntryKey);
let newChunk;

/* 创建一个备份,这是原有的chunks,因为下面addChunks会改变compilation.chunks**/
const usedChunks = new Set(item.chunks);

// 开始创建新的Chunk
if (newChunk === undefined) {
    newChunk = compilation.addChunk(chunkName);
}
// Walk through all chunks
// newChunk和chunks之间通过ChunkGroup建立链接
for (const chunk of usedChunks) {
    // Add graph connections for splitted chunk
    chunk.split(newChunk);
}

// Compilation.js

/**
 * 如果有名字的话,先判断是否已经有了,如果有的话直接复用。之后新增Chunk
 * ```js
 * // 通过以下代码来新增Chunk
 * this.chunks.add(newChunk)
 * ```
 */
addChunk(name) {
    if (name) {
        const chunk = this.namedChunks.get(name);
        if (chunk !== undefined) {
            return chunk;
        }
    }
    const chunk = new Chunk(name, this._backCompat);
    this.chunks.add(chunk);
    if (this._backCompat)
        ChunkGraph.setChunkGraphForChunk(chunk, this.chunkGraph);
    if (name) {
        this.namedChunks.set(name, chunk);
    }
    return chunk;
}

接下来省略了一系列过滤条件后,开始创建新的 Chunk。_backCompat这个变量99%的情况都是 ture 因为它是为了兼容 Webpack4 而存在的(Webpack4 不存在 ChunkGroup)。因此在新建完 Chunk 后,还会绑定在 ChunkGroup 上。

js 复制代码
split(newChunk) {
    for (const chunkGroup of this._groups) {
        chunkGroup.insertChunk(newChunk, this);
        newChunk.addGroup(chunkGroup);
    }
    for (const idHint of this.idNameHints) {
        newChunk.idNameHints.add(idHint);
    }
    newChunk.runtime = mergeRuntime(newChunk.runtime, this.runtime);
}

chunk.split(newChunk) 顾名思义,将原本的 initChunk 分割出来两个 chunks。但实际逻辑只是把 initChunk 以及 newChunk 通过 ChunkGroup 绑定起来。

真正的将 initChunk 以及 newChunk 更新到 ChunkGraph 上的逻辑在下面:

js 复制代码
// Add all modules to the new chunk
// 要分隔的modules全部链接到newChunk中,而断开跟原本用到的各个chunks的链接
for (const module of item.modules) {
    // Add module to new chunk
    chunkGraph.connectChunkAndModule(newChunk, module);
    // Remove module from used chunks
    for (const chunk of usedChunks) {
        chunkGraph.disconnectChunkAndModule(chunk, module);
    }
}

// ChunkGraph.js
connectChunkAndModule(chunk, module) {
    const cgm = this._getChunkGraphModule(module);
    const cgc = this._getChunkGraphChunk(chunk);
    cgm.chunks.add(chunk);
    cgc.modules.add(module);
}
disconnectChunkAndModule(chunk, module) {
    const cgm = this._getChunkGraphModule(module);
    const cgc = this._getChunkGraphChunk(chunk);
    cgc.modules.delete(module);
    // No need to invalidate cgc._modulesBySourceType because we modified cgc.modules anyway
    if (cgc.sourceTypesByModule) cgc.sourceTypesByModule.delete(module);
    cgm.chunks.delete(chunk);
}

完成了着一切,意味着 lodash module 已经和 initChunk 彻底分开,并且和 newChunk 建立起来了新链接。

到此为止,在 ChunkGroup 中,已经成功的拆出了一个 lodash 的 newChunk。接下来继续回到 chunkInfoMap 的循环,继续拆出 common chunk,最后一共拆成了 5 个 Chunks:

在拆分出来了 Chunks 后,在 Webpack 后续的 [seal 生命周期] 中就可以根据新的 ChunkGraph 结构去进行代码生成啦。

MaxSize 阶段

这个阶段主要是起到控制 chunk 分片大小的作用。详细来说,maxSize 参数告诉 Webpack 尝试将大于 maxSize 个字节的 chunks 分割成更小的部分,每个部分在大小上至少为 minSize(仅次于 maxSize )。

这里由于不是重要逻辑,就不对其进行分析了。

总结

原理图如上,具体的链接可以点击这里查看:xfjhqij9zq.feishu.cn/docx/ZLwcdC...

本文以解析 SplitChunksPlugin 的原理入手,让前端开发者了解到了 Webpack Plugins 的功能强大,也深入浅出地解读了作为前端优化编译性能的利器的原理。

  • SplitChunksPlugin 默认配置以及默认的作用对象:async chunk。
  • 如何根据 cacheGroup 来获取需要拆分的 chunks 和 modules。
  • 如何新建 Chunk 以及将 chunk、modules 和 ChunkGraph 关联在一起。

SplitChunksPlugin 是 Webpack 性能优化的一个重要工具,适当地使用可以帮助提高前端应用的加载速度,提升用户体验。需要注意的是,虽然模块分割的目标是提高应用的性能,但是过度的代码分割可能会导致大量的 HTTP 请求,从而造成性能下降,因此在使用时需要平衡考量。

Hi~我是盐焗乳鸽还要香锅。这是【深入理解 Webpack5 系列】文章的第二篇,希望这篇文章能给所有读者一些收获和感悟,本人也会继续深入学习 Webpack,继续输出更多有价值有深度的文章。

相关推荐
weixin_441018351 天前
webpack的热更新原理
前端·webpack·node.js
Java指南修炼2 天前
一个开源的大语言模型(LLM)服务工具,支持Llama 3.1、Phi 3、Mistral、Gemma 2 等, 87.4k star你必须拥有(附源码)
人工智能·后端·语言模型·开源·源码
南辞w2 天前
Webpack和Vite的区别
前端·webpack·node.js
等你许久_孟然2 天前
【webpack4系列】webpack构建速度和体积优化策略(五)
前端·webpack·node.js
一 乐3 天前
英语学习交流平台|基于java的英语学习交流平台系统小程序(源码+数据库+文档)
java·数据库·vue.js·学习·小程序·源码
Sam90293 天前
【Webpack--007】处理其他资源--视频音频
前端·webpack·音视频
等你许久_孟然3 天前
【webpack4系列】编写可维护的webpack构建配置(四)
前端·webpack·node.js
垂钓的小鱼13 天前
webpack的使用
webpack
Sam90293 天前
【Webpack--006】处理字体图标资源
前端·webpack·node.js
等你许久_孟然3 天前
【webpack4系列】webpack初识与构建工具发展(一)
前端·webpack