跳到主要内容

产物拆包

2023年12月24日
柏拉文
越努力,越幸运

一、认识


一般来说,如果不对产物进行代码分割(或者拆包),全部打包到一个 chunk 中,会产生如下的问题:

  • 首屏加载的代码体积过大,即使是当前页面不需要的代码也会进行加载。

  • 线上缓存复用率极低,改动一行代码即可导致整个 bundle 产物缓存失效。

Vite 分包策略如下:

  • 默认分包策略: Vite 基于 RollupmanualChunksAPI 实现了应用拆包的策略为: 实现了自动 CSS 代码分割的能力,即实现一个 chunk 对应一个 css 文件; 对于 Async Chunk 而言 ,动态 import 的代码会被拆分成单独的 chunk; 对于普通的业务代码和第三方代码, 会打包到一个 Chunk

  • manualChunks: 针对更细粒度的拆包,Vite 的底层打包引擎 Rollup 提供了manualChunks,让我们能自定义拆包策略

  • vite-plugin-chunk-split:

二、默认拆包策略


Vite 基于 RollupmanualChunksAPI 实现了应用拆包的策略为: 实现了自动 CSS 代码分割的能力,即实现一个 chunk 对应一个 css 文件; 对于 Async Chunk 而言 ,动态 import 的代码会被拆分成单独的 chunk; 对于普通的业务代码和第三方代码, 会打包到一个 Chunk

三、分包策略


针对更细粒度的拆包,Vite 的底层打包引擎 Rollup 提供了manualChunks,让我们能自定义拆包策略。Rollup 会对每一个模块调用 manualChunks 函数,在 manualChunks 的函数入参中你可以拿到模块 id 及模块详情信息,经过一定的处理后返回 chunk 文件的名称,这样当前 id 代表的模块便会打包到你所指定的 chunk 文件中。

3.1 对象配置

export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// 将 Lodash 库的代码单独打包
lodash: ['lodash-es'],
// 将 React 相关库打包成单独的 chunk 中
'react-vendor': ['react', 'react-dom'],
// 将组件库的代码打包
library: ['antd', '@arco-design/web-react']
}
}
}
}
});

在对象格式的配置中,key代表 chunk 的名称,value为一个字符串数组,每一项为第三方包的包名。我们可以执行 npm run build尝试一下打包, 你可以看到原来的 vendor 大文件被拆分成了我们手动指定的几个小 chunk,每个 chunk 大概 200 KB 左右,是一个比较理想的 chunk 体积。这样,当第三方包更新的时候,也只会更新其中一个 chunkurl,而不会全量更新,从而提高了第三方包产物的缓存命中率。

3.2 函数配置

除了对象的配置方式之外,我们还可以通过函数进行更加灵活的配置,而 Vite 中的默认拆包策略也是通过函数的方式来进行配置的

function createMoveToVendorChunkFn() {
return (id) => {
if (id.includes('antd') || id.includes('@arco-design/web-react')) {
return 'library';
}
if (id.includes('lodash')) {
return 'lodash';
}
if (id.includes('react')) {
return 'react';
}
};
}

export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: createMoveToVendorChunkFn()
}
}
}
});

Rollup 会对每一个模块调用 manualChunks 函数,在 manualChunks 的函数入参中你可以拿到模块 id 及模块详情信息,经过一定的处理后返回 chunk 文件的名称,这样当前 id 代表的模块便会打包到你所指定的 chunk 文件中。但是 Vite 这样的默认配置存在一定的问题, manualChunks逻辑过于简单粗暴,仅仅通过路径 id 来决定打包到哪个 chunk 中,而漏掉了间接依赖的情况。比如说: 像 object-assign 这种 react 本身的依赖并没有打包进react-vendor中,而是打包到另外的 chunk 当中,从而导致如下的循环依赖关系:

Preview

解决思路: 如果针对像 object-assign 这种间接依赖,我们也能识别出它属于 react 的依赖,将其自动打包到react-vendor中,这样就可以避免循环引用的问题。解决思路如下:

  1. 确定 react 相关包的入口路径。

  2. manualChunks 中拿到模块的详细信息,向上追溯它的引用者,如果命中 react 的路径,则将模块放到 react-vendor中。

import { defineConfig } from 'vite';
import inspect from 'vite-plugin-inspect';
import react from '@vitejs/plugin-react-swc';

const baseMap: { [key: string]: string } = {
development: '/',
production: '/test/vite/cssEngineering/preprocessor/dist'
};

const chunkGroups = {
'react-vendor': ['react', 'react-dom']
};

function createMoveToVendorChunkFn() {
const cache = new Map();

// 递归向上查找引用者,检查是否命中 chunkGroups 声明的包
const isDepInclude = (id, depPaths, importChain, getModuleInfo) => {
const key = `${id}-${depPaths.join('|')}`;
// 出现循环依赖,不考虑
if (importChain.includes(id)) {
cache.set(key, false);
return false;
}
// 验证缓存
if (cache.has(key)) {
return cache.get(key);
}
// 命中依赖列表
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;
}

// 核心逻辑,递归查找上层引用者
const isInclude = moduleInfo.importers.some(importer =>
isDepInclude(importer, depPaths, importChain.concat(id), getModuleInfo)
);
// 设置缓存
cache.set(key, isInclude);
return isInclude;
};

return (id, getModuleInfo) => {
for (const group of Object.keys(chunkGroups)) {
const deps = chunkGroups[group];

if (
id.includes('node_modules') &&
isDepInclude(id, deps, [], getModuleInfo)
) {
return group;
}
}
};
}

export default defineConfig({
base: baseMap[process.env.NODE_ENV],
plugins: [react(), inspect()],
build: {
rollupOptions: {
output: {
manualChunks: createMoveToVendorChunkFn()
}
}
}
});

四、vite-plugin-chunk-split


vite-plugin-chunk-splitVite 终极分包策略

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import { chunkSplitPlugin } from 'vite-plugin-chunk-split';

const baseMap: { [key: string]: string } = {
development: '/',
production: '/test/vite/cssEngineering/preprocessor/dist'
};

export default defineConfig({
base: baseMap[process.env.NODE_ENV],
plugins: [react(), chunkSplitPlugin({
customSplitting: {
lodash: ['lodash'],
'react-vendor': ['react'],
}
})],
});