共计 7556 个字符,预计需要花费 19 分钟才能阅读完成。
一、背景
在生产环境下,为了进步页面加载性能,构建工具个别将我的项目的代码打包 (bundle) 到一起,这样上线之后只须要申请大量的 JS 文件,大大减少 HTTP 申请。当然,Vite 也不例外,默认状况下 Vite 利用底层打包引擎 Rollup 来实现我的项目的模块打包。
某种意义上来说,对线上环境进行我的项目打包是一个必须的操作。但随着前端工程的日渐简单,单份的打包产物体积越来越宏大,会呈现一系列利用加载性能问题,而代码宰割能够很好地解决它们。
当然,在理论的我的项目场景中,只用 Vite 默认的策略是不够的,咱们会更深刻一步,学习 Rollup 底层拆包的各种高级姿态,实现自定义拆包,同时我也会带大家通过理论案例复现 Rollup 自定义拆包常常遇到的坑——循环援用问题,一起剖析问题呈现的起因,也分享一些本人的解决思路和计划,让大家对 Vite 及 Rollup 的代码宰割有更加深刻的把握。
不过,在正式解说之前,给大家介绍几个业余概念:bundle、chunk、vendor。
- bundle:指的是整体的打包产物,蕴含 JS 和各种动态资源。
- chunk:指的是打包后的 JS 文件,是 bundle 的子集。
- vendor:是指第三方包的打包产物,是一种非凡的 chunk。
二、Code Splitting 解决的问题
在传统的单 chunk 打包模式下,当我的项目代码越来越宏大,最初会导致浏览器下载一个微小的文件,从页面加载性能的角度来说,次要会导致两个问题:
- 无奈做到按需加载,即便是以后页面不须要的代码也会进行加载。
- 线上缓存复用率极低,改变一行代码即可导致整个 bundle 产物缓存生效。
首先来看第一个问题,一般而言,一个前端页面中的 JS 代码能够分为两个局部: Initital Chunk 和 Async Chunk,前者指页面首屏所须要的 JS 代码,而后者并不一定须要,一个典型的例子就是 路由组件,与以后路由无关的组件并不必加载。而我的项目被打包成单 bundle 之后,无论是 Initial Chunk 还是 Async Chunk,都会打包进同一个产物,也就是说,浏览器加载产物代码的时候,会将两者一起加载,导致许多冗余的加载过程,从而影响页面性能。而通过 Code Splitting 咱们能够将按需加载的代码拆分出独自的 chunk,这样利用在首屏加载时只须要加载 Initial Chunk 即可,防止了冗余的加载过程,使页面性能失去晋升。
其次,线上的缓存命中率是一个重要的性能衡量标准。对于线上站点而言,服务端个别在响应资源时加上一些 HTTP 响应头,最常见的响应头之一就是 cache-control,它能够为浏览器指定强缓存,比方设置为上面这样。
cache-control: max-age=31536000
示意资源过期工夫为一年,在过期之前,拜访雷同的资源 url,浏览器间接利用本地的缓存,并不必给服务端发申请,这就大大降低了页面加载的网络开销。不过,在单 chunk 打包模式上面,一旦有一行代码变动,整个 chunk 的 url 地址都会变动,比方下图所示的场景。
因为构建工具个别会依据产物的内容生成哈希值,一旦内容变动就会导致整个 chunk 产物的强缓存生效,所以单 chunk 打包模式下的缓存命中率极低,根本为零。而进行 Code Splitting 之后,代码的改变只会影响局部的 chunk 哈希改变,如下图所示:
入口文件援用了 A、B、C、D 四个组件,当咱们批改 A 的代码后,变动的 Chunk 就只有 A 以及依赖 A 的 Chunk 中,A 对应的 chunk 会变动,这很好了解,后者也会变动是因为相应的引入语句会变动,如这里的入口文件会产生如下内容变动。
import CompA from './A.d3e2f17a.js'
// 更新 import 语句
import CompA from './A.a5d2f82b.js'
也就是说,在改变 A 的代码后,B、C、D 的 chunk 产物 url 并没有发生变化,从而能够让浏览器复用本地的强缓存,大大晋升线上利用的加载工夫和性能。
三、Vite 默认拆包策略
实际上 Vite 中曾经内置了一份拆包的策略,接下来让咱们来看看 Vite 默认的拆包模式是怎么运作的。
在生产环境下 Vite 齐全利用 Rollup 进行构建,因而拆包也是基于 Rollup 来实现的,但 Rollup 自身是一个专一 JS 库打包的工具,对利用构建的能力还尚为欠缺,Vite 正好是补足了 Rollup 利用构建的能力,在拆包能力这一块的扩大就是很好的体现。咱们先通过具体的我的项目来体验一下 Vite 拆包,示例我的项目我曾经放到 Gihub 仓库中,能够下载下来进行对照学习。
在我的项目中执行 yarn run build,终端会呈现如下的构建信息。
接下来,咱们来解释一下产物的构造:
.
├── assets
│ ├── Dynamic.645dad00.js // Async Chunk
│ ├── favicon.17e50649.svg // 动态资源
│ ├── index.6773c114.js // Initial Chunk
│ └── vendor.ab4b9e1f.js // 第三方包产物 Chunk
└── index.html // 入口 HTML
能够看到,一方面 Vite 实现了主动 CSS 代码宰割的能力,即实现一个 chunk 对应一个 css 文件,比方下面产物中 index.js 对应一份 index.css,而按需加载的 chunk Danamic.js 也对应独自的一份 Danamic.css 文件,与 JS 文件的代码宰割同理,这样做也能晋升 CSS 文件的缓存复用率。而另一方面,Vite 基于 Rollup 的 manualChunksAPI 实现了利用拆包的策略:
- 对于 Initital Chunk 而言,业务代码和第三方包代码别离打包为独自的 chunk,在上述的例子中别离对应 index.js 和 vendor.js。须要阐明的是,这是 Vite 2.9 版本之前的做法,而在 Vite 2.9 及当前的版本,默认打包策略更加简略粗犷,将所有的 js 代码全副打包到 index.js 中。
- 对于 Async Chunk 而言,动静 import 的代码会被拆分成独自的 chunk,如上述的 Dynacmic 组件。
能够发现,Vite 默认拆包的劣势在于实现了 CSS 代码宰割与业务代码、第三方库代码、动静 import 模块代码三者的拆散,但毛病也比拟直观,第三方库的打包产物容易变得比拟臃肿,上述例子中的 vendor.js 的大小曾经达到 500 KB 以上,显然是有进一步拆包的优化空间的,这个时候咱们就须要用到 Rollup 中的拆包 API ——manualChunks 了。
四、自定义拆包策略
针对更细粒度的拆包,Vite 的底层打包引擎 Rollup 提供了 manualChunks,让咱们能自定义拆包策略,它属于 Vite 配置的一部分,示例如下。
// vite.config.ts
export default {
build: {
rollupOptions: {
output: {
// manualChunks 配置
manualChunks: {},},
}
},
}
manualChunks 次要有两种配置的模式,能够配置为一个对象或者一个函数。咱们先来看看对象的配置,也是最简略的配置形式,你能够在上述的示例我的项目中增加如下的 manualChunks 配置代码。
// vite.config.ts
{
build: {
rollupOptions: {
output: {
// manualChunks 配置
manualChunks: {
// 将 React 相干库打包成独自的 chunk 中
'react-vendor': ['react', 'react-dom'],
// 将 Lodash 库的代码独自打包
'lodash': ['lodash-es'],
// 将组件库的代码打包
'library': ['antd', '@arco-design/web-react'],
},
},
}
},
}
在对象格局的配置中,key 代表 chunk 的名称,value 为一个字符串数组,每一项为第三方包的包名。在进行了如上的配置之后,咱们能够执行 yarn run build 尝试一下打包。
能够看到,原来的 vendor 大文件被拆分成了咱们手动指定的几个小 chunk,每个 chunk 大略 200 KB 左右,是一个比拟现实的 chunk 体积。这样,当第三方包更新的时候,也只会更新其中一个 chunk 的 url,而不会全量更新,从而进步了第三方包产物的缓存命中率。
除了对象的配置形式之外,咱们还能够通过函数进行更加灵便的配置,而 Vite 中的默认拆包策略也是通过函数的形式来进行配置的,咱们能够在 Vite 的实现中增加如下配置。
// Vite 局部源码
function createMoveToVendorChunkFn(config: ResolvedConfig): GetManualChunk {const cache = new Map<string, boolean>()
// 返回值为 manualChunks 的配置
return (id, { getModuleInfo}) => {
// Vite 默认的配置逻辑其实很简略
// 次要是为了把 Initial Chunk 中的第三方包代码独自打包成 `vendor.[hash].js`
if (id.includes('node_modules') &&
!isCSSRequest(id) &&
// 判断是否为 Initial Chunk
staticImportedByEntry(id, getModuleInfo, cache)
) {return 'vendor'}
}
}
Rollup 会对每一个模块调用 manualChunks 函数,在 manualChunks 的函数入参中你能够拿到模块 id 及模块详情信息,通过肯定的解决后返回 chunk 文件的名称,这样以后 id 代表的模块便会打包到你所指定的 chunk 文件中。当初来试着把方才的拆包逻辑用函数来实现一遍。
manualChunks(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';}
}
而后,执行打包操作,打包后后果如下。
看上去如同各个第三方包的 chunk (如 lodash、react 等等)都能拆分进去,但实际上你能够运行 npx vite preview 预览产物,会发现产物基本没有方法运行起来,页面呈现白屏。这也就是函数配置的坑点所在了,尽管灵便而不便,但稍不留神就陷入此类的产物谬误问题当中。
五、解决循环援用问题
从报错信息追溯到产物中,能够发现 react-vendor.js 与 index.js 产生了循环援用。
// react-vendor.e2c4883f.js
import {q as objectAssign} from "./index.37a7b2eb.js";
// index.37a7b2eb.js
import {R as React} from "./react-vendor.e2c4883f.js";
这是很典型的 ES 模块循环援用的场景,咱们能够用一个最根本的例子来还原这个场景。
// a.js
import {funcB} from './b.js';
funcB();
export var funcA = () => {console.log('a');
}
// b.js
import {funcA} from './a.js';
funcA();
export var funcB = () => {console.log('b')
}
接下来,咱们执行一下 a.js 文件。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<script type="module" src="/a.js"></script>
</body>
</html>
此时,在浏览器中关上可能会呈现如下相似的报错。
上面是代码的执行流程:
- JS 引擎执行 a.js 时,发现引入了 b.js,于是去执行 b.js
- 引擎执行 b.js,发现外面引入了 a.js(呈现循环援用),认为 a.js 曾经加载实现,持续往下执行
- 执行到 funcA()语句时发现 funcA 并没有定义,于是报错。
而对于如上打包产物的执行过程也是如此。
此处,大家可能有个疑难:react-vendor 为什么须要援用 index.js 的代码呢?其实也很好了解,咱们之前在 manualChunks 中仅仅将门路蕴含 react 的模块打包到 react-vendor 中,殊不知,像 object-assign 这种 react 自身的依赖并没有打包进 react-vendor 中,而是打包到另外的 chunk 当中,从而导致如下的循环依赖关系。
那咱们能不能防止这种问题呢?当然是能够的,之前的 manualChunks 逻辑过于简略粗犷,仅仅通过门路 id 来决定打包到哪个 chunk 中,而漏掉了间接依赖的状况。如果针对像 object-assign 这种间接依赖,咱们也能辨认出它属于 react 的依赖,将其主动打包到 react-vendor 中,这样就能够防止循环援用的问题。解决的思路如下:
- 确定 react 相干包的入口门路。
- 在 manualChunks 中拿到模块的详细信息,向上追溯它的援用者,如果命中 react 的门路,则将模块放到 react-vendor 中。
上面是实现代码:
// 确定 react 相干包的入口门路
const chunkGroups = {
'react-vendor': [require.resolve('react'),
require.resolve('react-dom')
],
}
// Vite 中的 manualChunks 配置
function manualChunks(id, { getModuleInfo}) {for (const group of Object.keys(chunkGroups)) {const deps = chunkGroups[group];
if (id.includes('node_modules') &&
// 递归向上查找援用者,查看是否命中 chunkGroups 申明的包
isDepInclude(id, deps, [], getModuleInfo)
) {return group;}
}
}
实际上,实现的外围逻辑蕴含在 isDepInclude 函数,用来递归向上查找援用者模块。
// 缓存对象
const cache = new Map();
function isDepInclude (id: string, depPaths: string[], importChain: string[], getModuleInfo): boolean | undefined {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;
};
对于这个函数的实现,有两个中央须要大家留神:
- 能够通过 manualChunks 提供的入参 getModuleInfo 来获取模块的详情 moduleInfo,而后通过 moduleInfo.importers 拿到模块的援用者,针对每个援用者又能够递归地执行这一过程,从而获取援用链的信息。
- 尽量应用缓存。因为第三方包模块数量个别比拟多,对每个模块都向上查找一遍援用链会导致开销十分大,并且会产生很多反复的逻辑,应用缓存会极大减速这一过程。
实现上述 manualChunks 的残缺逻辑后,当初咱们来执行 yarn run build 来进行打包。
此时,能够发现 react-vendor 能够失常拆分进去,查看它的内容。
从中你能够看出 react 的一些间接依赖曾经胜利打包到了 react-vendor 当中,执行 npx view preview 预览产物页面也能失常渲染了。
此时,曾经阐明循序依赖的问题被咱们解决了。
六、终极解决方案
接下来,给大家介绍一下 Vite 自定义拆包的终极解决方案:vite-plugin-chunk-split。首先,咱们须要装置一下这个插件。
npm i vite-plugin-chunk-split -D
而后,就能够在我的项目中引入并应用它了。
// vite.config.ts
import {chunkSplitPlugin} from 'vite-plugin-chunk-split';
export default {
chunkSplitPlugin({
// 指定拆包策略
customSplitting: {// 1. 反对填包名。`react` 和 `react-dom` 会被打包到一个名为 `render-vendor` 的 chunk 外面(包含它们的依赖,如 object-assign)
'react-vendor': ['react', 'react-dom'],
// 2. 反对填正则表达式。src 中 components 和 utils 下的所有文件被会被打包为 `component-util` 的 chunk 中
'components-util': [/src/components/, /src/utils/]
}
})
}
相比于手动操作依赖关系,应用插件只需几行配置就能实现,十分不便。当然,这个插件还能够反对多种打包策略,包含 unbundle 模式打包等。