从vite的角度来讲,其实就是怎么对程序进行分包,能让程序加载的更快。那我们就一起来分析下吧:
问题背景
“分包”在前端开发中,是指将一个大的代码库或应用程序拆分成多个较小的、独立的代码包或模块的过程。这些较小的包可以独立加载和运行,通常是在用户需要它们时才加载。这样做的主要目的是提高应用程序的性能和可维护性。
分包的核心概念:
- 代码拆分:将应用程序的代码分成逻辑上独立的部分。例如,一个电子商务网站可以将购物车、产品展示和用户账户管理等功能模块分别打包。
- 按需加载:当用户访问应用程序的某个部分时,只有与该部分相关的代码包会被加载,而其他部分的代码不会立即加载。这减少了初始加载时间,提高了页面的响应速度。
- 性能优化:分包减少了每次加载页面时所需下载的资源量,从而提高了页面的加载速度,特别是在网络条件不佳或应用程序规模很大的情况下。
- 可维护性:代码被拆分成多个包后,每个包可以独立开发、测试和部署。这样一来,维护和更新代码时的风险和复杂度都会降低。
简单来说就是拆分代码,如果我们把一个项目的资源看做一颗庞大的依赖树,分包就是将这颗依赖树拆分的过程。最简单的例子:假设 AB 模块共用一个依赖 C,分包的难点就在于C应该放在哪个包里:单独拆 || 跟A一起 || 跟B一起。
分包的关注点
性能
- 首屏性能
-
- 资源数量
- 资源体积上限
- 缓存命中率
-
- 变更时的缓存失效率
- 交互性能
-
-
异步资源体积
-
预加载策略
-
举两个极端的例子:
- 不分包:需要整个资源包下载之后才能开始执行,拖慢首屏速度;就算改动文案也会导致整个资源失效。
- 极端分包(异步加载的模块一旦被多处复用就拆分)(vite 的默认策略):看似美好,但大量小文件会严重拖慢加载速度。
如果项目规模非常大,入口和动态路由很多,可能会产生非常多的小文件,这会拖慢页面的加载速度:
提高缓存命中率
我的方案主要是从提高缓存命中率的角度去解决这个问题。现在vite的默认分包策略,在实际的项目中,缓存命中率还有较大的优化空间,下面我们来分析一下。
现状存在的问题
默认分包策略
默认分包策略会按照动态import进行分包,如下图。index入口文件和动态import入口文件中会存在大量依赖。一旦我们的程序更新,几乎所有程序内容都需要重新加载。缓存命中率明显不足。
vite自带的splitVendorChunk
这个自带的分包插件会分析入口文件的静态依赖树,并将node_modules中的依赖包,打入vendor文件中,大体结构如下图。
这种分包方式只会让被入口文件静态引用的依赖被打包进vendor中。route1和route2中的特有依赖,不会被拆分。在route1或route2更新后,依赖部分即使没有变化仍然需要重新加载,所以缓存命中率有较大优化空间。
解决方案
大体思路是顺着splitVendorChunk的思路把依赖继续拆分干净。其中比较重要的原则,先在下面列出:
- 只要被入口文件静态引用的依赖,就会被打包到common vendor中。
- 多个动态路由引用的相同依赖,会打包到同一个shared vendor中。
- 只有一个动态路由引用的依赖,打包到自己路由的vendor中。
- 动态路由作为动态入口,单独拆分为一个文件。
- 多个动态路由引用的相同业务代码,单独拆分为一个文件。
其中4,5是vite的默认逻辑,其中第5点,上述图例为了简单所以没有单独画出来,理解即可。
那么根据这五点可以绘制出一张依赖图:
上图里面的 route 和 business code是一个文件,index和index route也是一个文件,为了方便理解组成,所以都单独绘制出来了。
可以看到所有依赖都被拆分出来了,不管是入口文件的静态依赖,还是动态入口文件的静态依赖都被拆分,并且依赖可以被不同入口文件共享。在直接访问其中一个动态路由的时候,不需要下载无用的代码。这比简单的把所有依赖文件打包进同一个vender文件,显然来的更好。
实现方法
想要实现上述策略的核心是:
- 模块是否被入口文件静态依赖
- 模块被哪些动态入口文件静态依赖
然后根据下面流程图进行chunkname的分配:
保留能力
众所周知vite的自定义分包,需要配置build.rollupOptions.output.manualChunks来完成,这个配置除了函数,也可以配置将某些包,打包到同一个chunk中:
({
manualChunks: {
lodash: ['lodash']
}
});
在扩展了分包逻辑后,最好是保留rollup的这部分能力:
const SPLIT_EXPERIENCE_MODULES = {
react: ["react", "react-dom", "react-router-dom"],
antd: ["antd"],
};
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
],
build: {
rollupOptions: {
output: {
manualChunks: manualChunks(SPLIT_EXPERIENCE_MODULES)
}
}
}
})
这里配置对象的key是要分配给它们的chunkname,value数组是需要对moduleId匹配的字符串或者正则,如果需要进行更精确更复杂的匹配,推荐使用正则表达式。
小文件问题如何解决
当我的应用程序变得无比复杂时,入口和动态路由已经变得很多,这时vite的共享代码策略会产生很多非常小的文件,工程化中成为小块(small chunk)。过多的小文件会让页面的加载性能变得相对较慢,这个问题在过去没有较好的方式解决。
在rollup 4.8.0版本的更新中,引入了一个实验性的配置:experimentalMinChunkSize。
这一配置补齐了rollup在构建时没有合并小块(chunk)的能力的短板,这也会让vite的使用者获得对应的能力。
可能是由于目前仍然是实验性的配置,这一部分并没有在vite的文档中提及。
由于依赖rollup v4.8.0的特性,所以vite版本需要大于v5.2.0。
由于是实验性的特性,在使用的时候需要开发者对其合并逻辑具有基本的了解。下面我来介绍一下其中的逻辑:
我们只能在合并后以任何允许的顺序加载任何入口点都不会触发不应触发的副作用的情况下安全地合并块。虽然副作用通常是全局函数调用、全局变量突变或可能引发的错误之类的事情,但细节在这里并不重要,我们只是将没有副作用的块(纯块)与其他块区分开来。作为第一步,我们为每个具有副作用的预生成块分配一个标签。即,如果加载了非纯块“A”,则会产生副作用“A”。现在要确定加载块的副作用,还必须考虑其依赖项的副作用。因此,如果 A 依赖于 B(A -> B)并且两者都有副作用,则加载 A 会触发效果 AB。现在从上一步我们知道每个块都是由依赖于它并导致它加载的入口点唯一确定的,我们将其称为其依赖入口点(dependent entry points)。例如如果 X -> A 且 Y -> A,则 A 的依赖入口点为 XY。从这个想法开始,我们可以确定一组块(因此是一组副作用),如果某个块已加载,则必须触发这些块。基本上,它是给定块的依赖入口点加载的所有块的交集。我们将相应的副作用称为该块的相关副作用。示例:X -> ABC,Y -> ADE,A-> F,B -> D然后考虑到依赖关系,X -> ABCDF,Y -> ADEF交集为 ADF。因此我们知道,当加载 A 时,D 和 F 也必须在内存中,即使 D 和 A 都不是对方的依赖项。如果都有副作用,我们将 ADF 称为 A 的相关副作用。合并块时,相关副作用需要保持不变。相反,我们有 A 的依赖副作用,它表示如果我们直接加载 A 会触发的副作用。在此示例中,依赖副作用为 AF。对于入口块,依赖和相关副作用相同。
有了这些概念,如果每个入口的相关副作用不变,则可以合并块。因此,如果a) 每个块的依赖副作用是另一个块的相关副作用的子集,因此不会为任何条目触发其他副作用,或者b) 块 A 的依赖入口点是块 B 的依赖入口点的子集,而 A 的依赖副作用是 B 的相关副作用的子集,我们可以合并两个块。因为在这种情况下,每当加载 A 时,也会加载 B。但有些情况下,加载 B 而未加载 A。因此,如果我们合并这些块,A 的所有依赖副作用都将添加到 B 的相关副作用中,并且由于后者不允许更改,前者需要是后者的子集。
将小块合并到其他块时的另一个考虑因素是避免加载过多的额外代码。当小块的依赖入口是其他块的依赖入口的子集时,就可以实现这一点。因为当加载小块时,其他块无论如何都会加载/在内存中,因此最多在加载其他块时,小块的额外大小会被不必要地加载。因此,算法分两次执行合并:1. 首先,如果 A 的依赖入口是 B 的依赖入口的子集,并且 A 的依赖副作用是 B 的相关副作用的子集,我们尝试将小块 A 仅合并到其他块 B 中。2. 只有这样,对于所有剩余的小块,我们才会按照规则 (a) 寻找任意合并,从最小的块开始寻找可能的合并目标。
下面是rollup的实现代码,可以看到有判断依赖入口和副作用是否为另一个块的子集:
接下来可以看一下合并小块的效果,这里以我的测试项目为例:
可以看到,在开启这个配置的时候没有utils文件,说明小chunk确实已经被合并了,屏蔽掉这个配置之后,仍然会输出utils文件,这太棒了。
如果小块在使用这个方法时没有合并,可以检查一下小块内是不是存在不合理的副作用,可以看下这些副作用是否可以去掉。如果确定小块内没有副作用,可以确认下执行代码压缩之前chunk的大小是否被experimentalMinChunkSize覆盖,因为在renderChunk的时候,压缩逻辑没有被执行,所以在使用时,可以尽量设置大一点。
效果验证
测试页面:qinyequan-kfx.kproxy.corp.kuaishou.com/qyq
为了较好的模拟用户网络,验证过程中始终将网络限速到Slow 4G。验证结果如下
方案组
无缓存
大约3100ms
有缓存
大概640ms
部分更新-更新about文件
大约1800ms
对照组-splitVendorChunkPlugin
无缓存
大约3040ms
有缓存
大约650ms
部分更新-更新about文件
大约2200ms
结论
由于依赖已经被拆分出去,在程序更新的时候具有较好的缓存命中率,所以性能优化了20%左右。具有较好的优化效果。
目前方案已经开源,并且已经被Awesome Vite收录,可以直接作为插件使用:
pnpm add vite-plugin-dynamic-chunk -D
import {
defineConfig,
} from 'vite'
import react from '@vitejs/plugin-react-swc'
import { dynamicChunkPlugin } from 'vite-plugin-dynamic-chunk';
const SPLIT_EXPERIENCE_MODULES = {
react: ["react", "react-dom", "react-router-dom"],
antd: ["antd"],
};
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
dynamicChunkPlugin({
dependencySplitOption: SPLIT_EXPERIENCE_MODULES, // 自定义分包map
splitDynamicImportDependency: true, // 是否需要分离动态入口的依赖,默认为true
experimentalMinChunkSize: 1000, // 最小chunk大小,低于这个大小的chunk根据rollup的逻辑进行合并,默认为1000
}),
],
})
仍然存在的问题
无论是默认方案还是我的解决方案,在我只更新about文件时,仍然会影响入口文件index的缓存命中。这是因为about文件的hash更新了,也会影响到index的hash。
那如果依赖内容更新了呢?那会让所有依赖这个文件的文件都会缓存失效。
如何优化呢?可不可以避免这种hash变化的传染呢?
我的方案是使用import map,将所有文件中的import 都替换成chunkname,就可以避免hash变化的传染。其实和webpack的manifest非常相似,起到的效果也异曲同工。
由于import map的兼容性,并没有esmodule好,所以我使用了es-module-shims作为兼容性的垫片,在html中。
html如下:
index入口js片段如下:
只更新about的情况,可以优化到不到1300ms。可以将性能优化41%!
这个方案只适用于现代浏览器的编译产物,不会对兼容版本的编译产物产生影响(实际上也无法造成影响),可以与legacy插件一起使用。
我们的C端活动需要兼容的版本是:IOS >= 12 Android >= 6,其实我们现代浏览器的编译产物已经足够了,不需要使用legacy插件,因为它们都兼容esmodule。
所以我们只需要合理设置vite的build.target即可:
import {
defineConfig,
} from 'vite'
export default defineConfig({
build: {
target: ['es2020', 'edge79', 'firefox67', 'chrome64', 'safari12'],
},
})
目前这个hash传染的解决方案也已经开源,也可以作为插件直接使用:
pnpm add vite-plugin-static-filehash -D
import {
defineConfig,
} from 'vite'
import react from '@vitejs/plugin-react-swc'
import { dynamicChunkPlugin } from 'vite-plugin-dynamic-chunk';
import { staticFilehashPlugin } from 'vite-plugin-static-filehash';
import testPlugin from './testplugin';
const SPLIT_EXPERIENCE_MODULES = {
react: ["react", "react-dom", "react-router-dom"],
antd: ["antd"],
};
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
dynamicChunkPlugin({
dependencySplitOption: SPLIT_EXPERIENCE_MODULES
}),
staticFilehashPlugin(),
],
})
写在最后
这只是对打包结果的一个小优化,但我相信会对现有的项目以及社区带来一定的帮助,希望这篇文章能对你有帮助,如果你对这两个插件有更优的想法环境来与我讨论,我也很希望能和业内优秀的工程师共同让这两个插件变得更完善。