Vite - 代码分割策略

16,011 阅读4分钟

来自掘金小册 深入浅出 Vite 的学习实践与总结

代码分割解决的问题

首先看看传统单 chunk 打包模式的问题:

  1. 无法做到按需加载
  • 所有代码都打包在一起(一个 chunk),那么页面初始化需要的代码和路由组件的代码全都打包在一起了
  1. 线上缓存复用率低
  • 一般 chunk 的名称是根据文件内容生成的 hash 值,那么每次修改代码,chunk 名称都会发生变化,这样浏览器就没办法利用本地缓存了

所以进行代码分割就是为了解决这些问题,提高页面加载性能。

Vite的默认拆包策略

2.9 版本之前

  • 一个 chunk 对应一个 CSS 文件
  • 业务代码会打包成一个 chunk
  • 第三方依赖包打包成一个 chunk


2.9 版本之后

  • 变化就在于第三方的包和业务代码都在一个 chunk 中了


如果想要更细粒度地控制代码分割策略,就得使用 manualChunks 配置,使用方式大概如下:

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        /**
         * 1.以对象的方式使用
         * 将 lodash 模块打包成一个 chunk,名称是 lodash
         */
         manualChunks: {
           lodash: ['lodash'],
         },

        /**
         * 2.以函数的形式使用
         * 将第三方包全部打包在一个chunk中,名称叫 vendor
         */
        manualChunks(id, { getModuleInfo, getModuleIds }) {
          if (id.includes('node_modules')) {
            return 'vendor';
          }
        },
      },
    },
  },
});

在 Vite 的源码中,在没有配置manualChunks时,默认拆包策略就是这样的:

function splitVendorChunk(
  options: { cache?: SplitVendorChunkCache } = {}
): GetManualChunk {
  const cache = options.cache ?? new SplitVendorChunkCache()
  return (id, { getModuleInfo }) => {
    // 是第三方包 && 非 CSS 模块 && 是初始化需要的 chunk
    if (
      id.includes('node_modules') &&
      !isCSSRequest(id) &&
      staticImportedByEntry(id, getModuleInfo, cache.cache)
    ) {
      return 'vendor'
    }
  }
}

这个默认拆包策略是以插件的方式存在的,由于 2.9之后就不使用该策略了,所以如果还要用的话就得自己导入插件进行使用:

import { splitVendorChunkPlugin } from 'vite'
export default defineConfig({
  plugins: [splitVendorChunkPlugin()]
})

vite-plugin-chunk-split源码学习

模块循环加载(阮一峰著)

vite-plugin-chunk-split 是一个 Vite 插件,支持多种拆包策略,可避免手动操作manualChunks潜在的循环依赖问题。

我将源码改动了一点点、去除了一些边界处理啥的,然后添加了大量的注释,下面来看看源码:

我的代码仓库地址

/**
 * chunk 分割插件
 */
export default function chunkSplit(params?: ChunkSplit): Plugin {
  const { strategy = 'default', customSplitting = {} } = params;
  let manualChunks: ManualChunksOption;

  // 所有文件打包到一起,也就是一个 chunk
  if (strategy === 'all-in-one') {
    manualChunks = wrapCustomSplitConfig(() => {
      return null;
    }, customSplitting);
  }

  // 默认拆包方式
  if (strategy === 'default') {
    manualChunks = wrapCustomSplitConfig((id, { getModuleInfo }) => {
      if (id.includes('node_modules') && !isCSSIdentifier(id)) {
        if (staticImportedScan(id, getModuleInfo)) {
          return 'vendor';
        }
      }
    }, customSplitting);
  }

  // 实现不打包的效果,一个文件一个 chunk
  if (strategy === 'unbundle') {
    manualChunks = wrapCustomSplitConfig(
      (id, { getModuleInfo }): string | undefined => {
        // 针对第三方的包,将静态模块和动态模块进行拆分
        if (id.includes('node_modules') && !isCSSIdentifier(id)) {
          if (staticImportedScan(id, getModuleInfo)) {
            return 'vendor';
          } else {
            return 'async-vendor';
          }
        }

        const cwd = process.cwd();
        // 不是第三方包 && 不是 CSS 相关模块
        if (!id.includes('node_modules') && !isCSSIdentifier(id)) {
          const extname = path.extname(id);
          return path.relative(cwd, id).replace(extname, '');
        }

        return;
      },
      customSplitting
    );
  }

  return {
    name: 'vite-plugin-chunk-split',
    config() {
      return {
        build: {
          rollupOptions: {
            output: {
              manualChunks,
            },
          },
        },
      };
    },
  };
}

核心逻辑就在函数wrapCustomSplitConfig中:

/**
 *
 * @param manualChunks rollup output.manualChunks 的选项值
 * @param customOptions 自定义分割逻辑
 * @returns output.manualChunks
 */
const wrapCustomSplitConfig = (
  manualChunks: GetManualChunk,
  customOptions: CustomSplitting
): ManualChunksOption => {
  /** 模块入口文件路径 */
  const depsInGroup: Record<string, string[]> = {};

  const groups = Object.keys(customOptions);
  for (const group of groups) {
    // 获取自定义规则的 value
    const packageInfo = customOptions[group];

    depsInGroup[group] = packageInfo
      // 筛选出值为字符串的,也就是模块名称
      .filter((item): boolean => typeof item === 'string')
      .map((item) => resolveEntry(item as string));
  }

  // 返回 manualChunks 处理函数
  return (moduleId, { getModuleIds, getModuleInfo }) => {
    /**
     * 用来判断是否为某个模块的间接依赖
     * @param id 模块 id
     * @param depPaths 模块文件路径
     * @param importChain 引用链
     * @returns 是否命中 depPaths 中的路径,存在依赖关系
     */
    const isDepInclude = (
      id: string,
      depPaths: string[],
      importChain: string[]
    ): boolean | undefined => {
      const key = `${id}-${depPaths.join('|')}`;

      /**
       * 存在循环引用,不能打包到一起
       * 例如 importChain = [A, B, C],而当前 id 为 A,则产生了循环依赖
       */
      if (importChain.includes(id)) {
        cache.set(key, false);
        return false;
      }

      // 取缓存数据
      if (cache.has(key)) {
        return cache.get(key);
      }

      // 命中 depPaths 中的包,存在依赖关系,可以打包到一起
      if (depPaths.includes(id)) {
        importChain.forEach((item) =>
          cache.set(`${item}-${depPaths.join('|')}`, true)
        );
        return true;
      }

      const moduleInfo = getModuleInfo(id);
      // 没有获取到模块信息 || 模块没有被引用
      if (!moduleInfo || !moduleInfo.importers) {
        cache.set(key, false);
        return false;
      }

      /**
       * 向上递归查找引用者,看看该模块是不是命中 depPaths 中的包
       * importers 为当前模块的引用者列表
       */
      const isInclude = moduleInfo.importers.some((importer) =>
        isDepInclude(importer, depPaths, importChain.concat(id))
      );
      cache.set(key, isInclude);

      return isInclude;
    };

    for (const group of groups) {
      const deps = depsInGroup[group];
      /** 自定义的分割策略信息 */
      const packageInfo = customOptions[group];

      // 非 CSS 相关模块才进行处理
      if (!isCSSIdentifier(moduleId)) {
        // 是第三方依赖 && 该模块被 deps 中的包进行了引用
        if (
          moduleId.includes('node_modules') &&
          isDepInclude(moduleId, deps, [])
        ) {
          return group;
        }

        // 正则表达式的处理
        for (const rule of packageInfo) {
          // 符合正则匹配,则打包到 group chunk 中
          if (rule instanceof RegExp && rule.test(moduleId)) {
            return group;
          }
        }
      }
    }

    // 默认 chunk 处理
    return manualChunks(moduleId, { getModuleIds, getModuleInfo });
  };
};