原创
李春阳 / 叫叫技术团队
本文将以 webpack4、umi3 为例,介绍一些相关内容。尽管不同的打包工具、不同的版本之间可能略有差异,但是基本的优化过程是相似的。
前言
在本文中,我们将讨论如何通过合理的拆包策略来改善应用程序的性能。在开始之前,让我们先看一下拆包前后的性能提升效果。下图展示了项目拆包前与拆包后的包体积对比。请注意,这些数据仅供参考,实际结果可能因应用程序的复杂性而有所不同,但可以帮助我们了解拆包对性能的影响。
一、为什么需要拆包
随着应用程序规模的增长,JavaScript 文件的大小也越来越大。一个大的 JavaScript 文件会导致页面加载时间过长,影响用户体验。良好的拆包策略可以将一个大的 JavaScript 文件分割成多个较小的代码块 ,将公用的代码抽离成单独的 chunk ,在需要的时候按需加载 ,并充分利用 浏览器的缓存机制,如果使用合理,会极大影响加载时间。
在正式开始之前,我们还需要明确一些概念。例如我们贯穿全文的"块"(chunk
) ,以及它和我们常常提到的"包"(bundle
)以及"模块"(module
) 到底是什么?
module
指的是应用程序中的一个单独的模块,例如 JavaScript 代码、CSS、图片或其他资源。webpack 将每个模块都视为一个独立的单元,并通过识别模块之间的依赖关系来构建应用程序。
chunk一个 chunk 是一组相互依赖的 module,它们被组合在一起以便可以并行加载。这样做可以提高应用程序的性能,因为浏览器可以同时加载多个小块而不是一个大文件。一个 chunk 可以是一个入口文件或者由 webpack 自动生成。
bundle当 webpack 将所有模块打包成一个或多个文件时,这些文件就称为 bundle。一个 bundle 可以包含一个或多个 chunk 和其他资源文件。
module 、chunk 和 bundle 其实就是同一份代码在不同转换场景下的三个 webpack 术语名称,源文件是 module ,webpack 处理时是 chunk ,最后生成浏览器可以直接运行的代码叫 bundle 。
二、拆包方式
1. 入口起点(entry 高级用法)
通过使用入口起点的方式,可以将代码分离成多个单独的文件,这些文件可以通过 webpack 配置一个数组结构的 entry 选项指定。并且在其中传入不同类型的文件,可以实现将 CSS 和 JavaScript(和其他)文件分离在不同的 bundle。但是,这种方式有一些缺点,如无法动态加载,无法共享模块等,因此不在此文的讨论范围内。
2. 代码分离(Code Splitting)
代码分离指将代码分成不同的包/块,然后可以按需加载,而不是加载包含所有内容的单个包。用户只需要下载当前他正在浏览站点的这部分代码,代码分离可以使用ES6模块中的 import() 函数动态的加载模块。使用动态导入的模块会被分割到一个单独的 chunk 中。
3. 打包分离(Bundle Splitting)
这个过程提供了一种优化构建的方法,允许 webpack 为单个应用程序生成多个 bundle 文件。因此,可以将每个 bundle 文件与影响其他文件的更改进行分离,其背后的思想非常简单。如果你有一个体积巨大的文件,并且只改了一行代码,用户仍然需要重新下载整个文件。但是如果你把它分为了两个文件,那么用户只需要下载那个被修改的文件,而另一个文件浏览器可以从缓存中加载,从而减少重新发布并由此被客户端重新下载的代码量。
综上所述,合理的拆包策略可以显著提升应用程序的性能。代码分离通过将代码按需加载,减小初始下载量;而打包分离将应用程序拆分成多个块,实现增量更新,减少不必要的下载。根据具体场景和需求,选择合适的拆包方式可以最大程度地优化应用程序的加载性能。
请继续阅读下一部分,我们将深入探讨代码分离和打包分离的应用场景及具体实现方法。
三、代码分离(Code Splitting)
代码分离(Code Splitting)是一种优化技术,当你运行 webpack 进行构建时,它会解析你的应用程序的依赖关系图,并检测动态导入点。webpack 会根据动态导入点创建拆分点,并生成相应的 chunk。每个 chunk 都是一个独立的文件,包含了按需加载的代码。
1. 实现方式
通过使用动态 import 语法 import()
来实现在代码中手动进行模块分割。 示例代码如下:
javascript
// 使用动态导入 import() 语法 导入 ./module 文件时
// webpack将会为 ./module 文件 生成一个独立的chunk module_js
import('./module').then((module) => {
// 使用加载的模块
}).catch((error) => {
// 处理加载错误
});
通过 webpack 打包后:
javascript
// 模块代码中的 import('./module') 被翻译成了 __webpack_require__.e('module_js')
// __webpack_require__.e的作用就是创建一个 script 标签 动态加载 module_js
// 打包后的代码类似如下这样
Promise.all(
/* import() */ [__webpack_require__.e('module_js')],
).then(({ default: module }) => console.log(module));
2. 应用场景
a. 路由懒加载
在 React、Vue 等框架中,代码分离最常见的应用场景是按需加载路由组件。你可以通过以下示例代码了解它的使用方法:
jsx
// React
let routes = createRoutesFromElements(
<Route path="/" element={<Layout />}>
<Route path="a" lazy={() => import("./a")} />
<Route path="b" lazy={() => import("./b")} />
</Route>
);
// vue
const router = createRouter({
// ...
routes: [{
path: '/users/:id',
component: () => import('./views/UserDetails.vue')
}],
})
你也可以点击这里查看有关 React、Vue 框架中按需加载路由的文档。
b. 组件懒加载
假设你有一个页面A,其中包含两个组件:
- Details.js(详情组件)
- Player.js(视频组件)
当用户点击按钮时,会弹出视频组件播放视频。以下是代码示例:
jsx
import React, { useState } from 'react';
import Details from './Details'; // 假设详情组件大小为 3KB
import Player from './Player'; // 假设视频组件大小为 30KB
function App() {
const [visible, setVisible] = useState(false);
const handlePlay = () => {
setVisible(true)
};
return (
<div>
<Details />
<button onClick={handlePlay}>play Video</button>
<Player visible={visible} />
</div>
);
}
export default App;
假设页面A的大小为20KB
,Details 组件大小为3KB
,Player 组件大小为30KB
。当用户访问页面A时,将下载一个大小为 53KB(Details(3KB) + Player(30KB) + PageA(20KB)
的 PageA.hash.js
文件。从上面的代码可以看出,只有当用户点击播放视频按钮时才会展示视频播放组件。实际上,可能只有 30% 的用户会点击播放视频按钮,其他 70% 的用户只会浏览详情后退出页面。然而,他们却不必要地下载了 30KB
的视频组件代码。因此,我们可以将视频组件拆分成单独的代码块(chunk
),例如player.chunk.js
,当用户点击按钮时再去请求player.chunk.js
,这样用户只需下载当前所需的代码,这就是代码分离(code splitting)的作用。 优化后的代码如下所示:
jsx
import React, { lazy, useState, Suspense } from "react";
import Details from "./components/Details"; // 假设详情组件大小为 3KB
// import Player from "./components/Player"; // 假设视频组件大小为 30KB
function App() {
const [visible, setVisible] = useState(false);
const [loadPlayer, setLoadPlayer] = useState(false);
const handlePlay = async () => {
setVisible(true);
setLoadPlayer(true);
};
// 按需加载player
const player = () => {
const Player = lazy(() => import("./components/Player"));
return (
<Suspense>
<Player visible={visible} />
</Suspense>
);
};
return (
<div>
<Details />
<button onClick={handlePlay}>load player</button>
{loadPlayer && player()}
</div>
);
}
export default App;
当用户点击 load player
按钮时可以在浏览器控制台看到请求了我们的 player.chunk.js
你也可以点击这里体验完整代码示例
代码分离(Code Splitting)看起来很吸引人,不是吗?然而,事实上在大多数情况下,你的组件大小与 Details 组件更加接近,用户下载一个20KB
和下载一个23KB
的文件几乎没有什么区别。当然,如果你的组件代码体积过大,那么建议使用代码分离将代码拆分成单独的代码块,以实现更好的性能。 对于大多数项目来说,首先应该考虑的是打包分离(Bundle Splitting),这也是本文的重点。通过合理的代码分离策略,可以移除重复的代码块,减少不必要的代码下载,提高应用程序的加载速度和性能。
请继续阅读下一部分,打包分离(Bundle Splitting)。
四、打包分离(Bundle Splitting)
通过打包分离(Bundle Splitting)策略,我们可以实现增量更新,并提高应用程序的性能。在介绍打包分离之前,让我们先了解一下哈希生成的机制。在构建 JavaScript 应用程序时,缓存是一个重要的优化策略。通常的做法是根据文件内容生成哈希值,并将其作为文件名的一部分。这样,当文件内容发生变化时,哈希值也会改变,浏览器会重新下载更新的文件。
1. 哈希生成机制
在 webpack 中,我们可以使用 chunkhash 和 contenthash 这两个选项来生成哈希值。
- chunkhash:基于每个独立代码块(chunk)的内容生成的哈希值。每个独立代码块都有一个唯一的哈希值。当任何一个模块发生变化时,只有受影响的代码块的哈希值会发生变化,其他代码块的哈希值仍然保持不变。这样,我们可以实现只重新下载发生变化的代码块,而不是整个应用程序的所有代码。
- contenthash:基于文件内容生成的哈希值。每个文件都有一个唯一的哈希值。无论是应用程序的代码文件、样式文件还是图片等静态资源,只要文件内容发生变化,对应的 contenthash 值就会改变。这样,我们可以确保浏览器能够正确缓存并更新静态资源。
通过使用 chunkhash 和 contenthash,我们能够更有效地实现文件的缓存和更新,提供更好的用户体验和性能优化。 需要注意的是,chunkhash 用于生成文件名中的哈希值,而 contenthash 用于生成文件内容的哈希值。具体使用哪个选项取决于你的 webpack 配置和需求。
webpack 文件 hash 配置示例:
javascript
const path = require('path');
module.exports = {
// 其他配置项...
output: {
filename: '[name].[chunkhash].js', // 使用 chunkhash 生成文件名哈希值
path: path.resolve(__dirname, 'dist'),
},
// ...
};
2. 打包分离实现方式
通过对 webpack 的配置文件进行适当的配置,就可以实现代码的打包分离,代码如下所示:
jsx
// webpack.config.js
module.exports = {
// ...
output: {
// 拆分块的文件名模板
chunkFilename: '[name].[chunkhash].js',
},
optimization: {
splitChunks: {
// 拆分的规则和条件
// ...
},
},
};
下面是 splitChunks 部分配置选项的说明: **chunks 选项 ** 此选项指定对哪些 chunk 进行优化。可以提供字符串值,有效值为 all、async 和 initial。
minSize 选项 生成的 chunk 的最小体积,单位为字节(bytes)。
maxSize 选项
将大于 maxSize 字节的 chunk 分割成较小的部分,每个部分的体积至少为 minSize。maxSize 选项通常与 HTTP/2 和长期缓存一同使用,以改善缓存效果。同时,它也可用于减小文件大小,从而加速二次构建速度。
minChunks 选项
在拆分之前,必须共享的模块的最小 chunk 数。
maxAsyncRequests 选项
在按需加载时,允许的最大并行请求数。
maxInitialRequests 选项
入口点允许的最大并行请求数。
automaticNameDelimiter 选项
用于生成 chunk 名称的分隔符。
name 选项
用于指定拆分 chunk 的名称,可以提供字符串或函数,允许自定义命名。如果使用字符串或总是返回相同字符串的函数,所有常见模块和 vendor 将合并为一个 chunk。这可能会导致更大的初始下载量并减慢页面加载速度。
五、最佳实践
在之前的片段中,我们已介绍了为何需要拆包,以及拆包带来的性能优势。我们可以将大型 JavaScript 文件分割成多个较小的代码块,将公用代码单独提取为 chunk
,在需要时按需加载,从而充分利用浏览器的缓存机制。
同时,我们也探讨了拆包背后的思想:即使只改动一行代码,传统方式下用户仍需重新下载整个巨大文件。但通过拆分为两个文件,用户只需下载修改部分,另一个文件则可从缓存加载,从而减少重新发布和重新下载的代码量。
接下来,我们将使用开篇中拆包前后包体积对比图中的实际项目来展示一下我们是如何将项目包体积从45.29MB
缩小到2.96MB
的,项目为 [email protected] + webpack4,在之前没有任何的拆包配置。 我们先使用 webpack-bundle-analyzer 分析我们的构建结果,结果如下图所示: 上图中可以看到构建产物中充斥着大量重复的代码,以
xlsx
为例,重复的xlsx
代码块就占用了29MB
的包体积,所以第一步,我们需要移除重复的代码块。
1. 移除重复的代码块
使用 splitChunks 的 cacheGroups 配置,将位于 node_modules
下的模块打包到 vendor 缓存组中,同时针对非 node_modules
下的文件,采用默认的缓存组配置。 代码如下所示:
typescript
// .umirc.ts
import { defineConfig } from 'umi';
export default defineConfig({
// umi config
//...
chainWebpack(config, { webpack }) {
config.merge({
optimization: {
splitChunks: {
// 注意 配置在splitChunks 中的
// chunks、minSize、minChunks 将对所有缓存组生效
chunks: 'all', // 对所有的chunk进行拆分
minSize: 20000, // 拆分 chunk 的最小体积 20000 bytes
minChunks: 2, // 需在两个模块中共享才进行拆分
cacheGroups: { // 设置缓存组
vendor: { // vendor组 存放node_modules下的chunk
name: 'vendor', // chunk 的名称 vendor
test: /[\\/]node_modules[\\/]/i, // 匹配node_modules下所有的chunk
priority: 10, // 优先级10 优先将node_modules下的chunk拆分到vendor组
reuseExistingChunk: true, // 重用模块,而不是重新生成
enforce: true, // 强制拆分
},
default: { // 默认组 非node_modules下的文件块 将执行default缓存组规则
reuseExistingChunk: true, // 重用模块,而不是重新生成
priority: -10, // 优先级 -10
enforce: true, // 强制拆分
},
}
}
}
});
return config
}
//...
});
构建结果如下图所示: 红色框中的
vendor.js
是根据上述代码中 vendor
缓存组规则生成的代码块,之前重复的xlsx
代码块都被打包到了绿色框中。
通过将位于 node_modules
目录下的文件进行合并打包,我们成功地将整体包的大小从 45.29MB
缩减至了仅 3.46MB
,从而避免了冗余代码的存在。然而,这种策略也带来了一些不足之处,我们需要继续探讨:
- 单文件尺寸的增加:虽然整体包的体积大幅减小,但由于将许多文件合并成一个,可能导致某些单文件的尺寸显著增加。这可能会影响某些场景下的加载性能。
- 缓存效率问题:尽管我们成功减小了整体包的大小,但由于合并了多个文件,即使只有一个文件发生微小变化,整个合并文件都需要重新下载,降低了缓存的效能。
继续往下阅读,我们将探讨如何解决这些问题,并进一步优化我们的打包策略。
2. 将NPM包拆分为体积适中的chunk
首先我们需要进一步分析我们拆分出去的vendor.js
,如下图所示,红色方框中的为xlsx(356.51KB)
,绿色方框中的为wangEditor(278.27KB),
蓝色方框中的为antd(146.95KB)
、黄色框中的为ace-builds(130.8kb)
我们的 ABTest 页面只用到了
antd
,xlsx
、ace-builds
和wangEditor
都没有使用到,所以我们试着将这四个npm
包构建成单独的chunk
。
增加代码如下所示:
typescript
// .umirc.ts
import { defineConfig } from 'umi';
export default defineConfig({
// umi config
//...
chainWebpack(config, { webpack }) {
config.merge({
optimization: {
splitChunks: {
// 注意 配置在splitChunks 中的
// chunks、minSize、minChunks 将对所有缓存组生效
chunks: 'all', // 对所有的chunk进行拆分
minSize: 20000, // 拆分 chunk 的最小体积 20000 bytes
minChunks: 2, // 需在两个模块中共享才进行拆分
cacheGroups: { // 设置缓存组
vendor: { // vendor组 存放node_modules下的chunk
name: 'vendor', // chunk 的名称 vendor
test: /[\\/]node_modules[\\/]/i, // 匹配node_modules下所有的chunk
priority: 10, // 优先级10 优先将node_modules下的chunk拆分到vendor组
reuseExistingChunk: true, // 重用模块,而不是重新生成
enforce: true, // 强制拆分
},
default: { // 默认组 非node_modules下的文件块 将执行default缓存组规则
reuseExistingChunk: true, // 重用模块,而不是重新生成
priority: -10, // 优先级 -10
enforce: true, // 强制拆分
},
xlsx: { // xlsx组
name: 'xlsx', // chunk 的名称 xlsx
test: /[\\/]node_modules[\\/]xlsx[\\/]/, // 匹配node_modules下的xlsx库
priority: 20, // 优先级20 优先将node_modules下的xlsx拆分出去
minChunks: 2, // 需在两个模块中共享
reuseExistingChunk: true, // 重用模块,而不是重新生成
},
antd: { // antd组
name: 'antd', // chunk 的名称 antd
test: /[\\/]node_modules[\\/]antd[\\/]/, // 匹配node_modules下的antd库
priority: 20, // 优先级20 优先将node_modules下的antd拆分出去
minChunks: 2, // 需在两个模块中共享
reuseExistingChunk: true, // 重用模块,而不是重新生成
},
wangeditor: { // wangeditor组
name: 'wangeditor', // chunk 的名称 wangeditor
test: /[\\/]node_modules[\\/]@wangeditor[\\/]/, // 匹配node_modules下的wangeditor库
priority: 20, // 优先级20 优先将node_modules下的wangeditor拆分出去
minChunks: 2, // 需在两个模块中共享
reuseExistingChunk: true, // 重用模块,而不是重新生成
},
aceBuilds: { // aceBuilds组
name: 'aceBuilds', // chunk 的名称 aceBuilds
test: /[\\/]node_modules[\\/]ace-builds[\\/]/, // 匹配node_modules下的aceBuilds库
priority: 20, // 优先级20 优先将node_modules下的aceBuilds拆分出去
minChunks: 2, // 需在两个模块中共享
reuseExistingChunk: true, // 重用模块,而不是重新生成
},
}
}
}
});
return config
}
//...
});
构建后可以看到xlsx
、wangeditor
、antd
、ace-build
已经被拆分到了单独的chunk
再次访问
ABTest
页面,只加载了我们需要的antd
,页面资源大小为1.9MB
,和拆分前的资源大小1.8MB
接近。
虽然我们已经将 4 个比较大的 npm 包拆分到了单独的chunk
,但vendorjs
还有733KB
。并且剩下的数量多、体积小,我们不可能一个一个的去配置缓存组。所以我们在原有拆包策略上修改 vendor 配置 (去除 vendor 缓存组的 name
选项即可,原因在文章的后面会讲到)
typescript
// .umirc.ts
import { defineConfig } from 'umi';
export default defineConfig({
// umi config
//...
chainWebpack(config, { webpack }) {
config.merge({
optimization: {
splitChunks: {
// 注意 配置在splitChunks 中的
// chunks、minSize、minChunks 将对所有缓存组生效
chunks: 'all', // 对所有的chunk进行拆分
minSize: 20000, // 拆分 chunk 的最小体积 20000 bytes
minChunks: 2, // 需在两个模块中共享才进行拆分
cacheGroups: { // 设置缓存组
vendor: { // vendor组 存放node_modules下的chunk
// name: 'vendor', // chunk 的名称 vendor
test: /[\\/]node_modules[\\/]/i, // 匹配node_modules下所有的chunk
priority: 10, // 优先级10 优先将node_modules下的chunk拆分到vendor组
reuseExistingChunk: true, // 重用模块,而不是重新生成
enforce: true, // 强制拆分
},
default: { // 默认组 非node_modules下的文件块 将执行default缓存组规则
reuseExistingChunk: true, // 重用模块,而不是重新生成
priority: -10, // 优先级 -10
enforce: true, // 强制拆分
},
xlsx: { // xlsx组
name: 'xlsx', // chunk 的名称 xlsx
test: /[\\/]node_modules[\\/]xlsx[\\/]/, // 匹配node_modules下的xlsx库
priority: 20, // 优先级20 优先将node_modules下的xlsx拆分出去
minChunks: 2, // 需在两个模块中共享
reuseExistingChunk: true, // 重用模块,而不是重新生成
},
antd: { // antd组
name: 'antd', // chunk 的名称 antd
test: /[\\/]node_modules[\\/]antd[\\/]/, // 匹配node_modules下的antd库
priority: 20, // 优先级20 优先将node_modules下的antd拆分出去
minChunks: 2, // 需在两个模块中共享
reuseExistingChunk: true, // 重用模块,而不是重新生成
},
wangeditor: { // wangeditor组
name: 'wangeditor', // chunk 的名称 wangeditor
test: /[\\/]node_modules[\\/]@wangeditor[\\/]/, // 匹配node_modules下的wangeditor库
priority: 20, // 优先级20 优先将node_modules下的wangeditor拆分出去
minChunks: 2, // 需在两个模块中共享
reuseExistingChunk: true, // 重用模块,而不是重新生成
},
aceBuilds: { // aceBuilds组
name: 'aceBuilds', // chunk 的名称 aceBuilds
test: /[\\/]node_modules[\\/]ace-builds[\\/]/, // 匹配node_modules下的aceBuilds库
priority: 20, // 优先级20 优先将node_modules下的aceBuilds拆分出去
minChunks: 2, // 需在两个模块中共享
reuseExistingChunk: true, // 重用模块,而不是重新生成
},
}
}
}
});
return config
}
//...
});
分析一下构建后的产物 可以看到红色部分为我们的
node_modules
下的代码,被拆分成了多个chunk
,包总体积缩小到了2.96MB
。 我们再次访问 ABTest 页面,可以看到页面资源大小为1.1MB
,和添加name
选项、拆分前进行对比,资源大小分别缩小了0.8MB
和0.7MB
。
假设我们对当前应用程序添加或升级了某个 npm 包,那么 vendor.js
的哈希值将会发生改变,这意味着用户在访问ABTest 页面时需要重新下载一个大小约为 1.5MB
的 vendor.js
文件。 这个时候假如我们对antd
进行了升级或新增/删除了一个antd
组件,那么用户不需要重新下载1.5MB
的vendor.js
文件,而只需要下载antd
模块对应的chunk(100KB)
。因为每个chunk
都有一个独立的哈希值,当我们对单独的chunk
进行修改时,只会影响到对应的chunk
文件,而不会影响其他chunk
文件。这样就实现了对npm
包的增量更新。在当前这种假设的情况下,缓存利用率提升了约 93.48%((1536kb - 100kb) / 1536kb * 100)。
3. HTTP/2 多路复用
你可能会担心一次性下载 40 个 JavaScript 文件是否会导致加载变慢,因为浏览器通常会有同时下载的并发限制,一般情况下限制为 6 个。然而,这个问题在使用了 HTTP/2 的多路复用特性后得到了很好的解决。 尽管看起来同时下载多个 JS 文件可能会降低性能,但实际上,HTTP/2 的多路复用特性能够极大地提升资源加载效率,同时不会影响页面加载速度。 如果你还在使用 HTTP/1,你可以考虑调整拆分 chunk 的最小体积(minSize
)或者限制最大并行请求数量(maxAsyncRequests
)来优化这个问题。
4. auto-chunks
如果不出意外的话,将上述配置代码放入你的 umi 项目中,会导致无法成功运行项目。这是因为在umi.js
进行vendors
依赖提取后,需要在umi.js
之前加载vendors.js
。为了解决这个问题,我们可以尝试手动配置chunks
选项,参考 umi-config-chunks。 代码如下所示:
typescript
// .umirc.ts
import { defineConfig } from 'umi';
export default defineConfig({
// umi config
// 配置chunks选项
chunks: ['vendors', 'umi'],
chainWebpack(config, { webpack }) {
config.merge({
optimization: {
splitChunks: {
// ...
cacheGroups: { // 设置缓存组
// ...
// 修改vendor
vendor: { // vendor组
// name: 'vendor', // chunk 的名称 vendor
test: /[\\/]node_modules[\\/]/i, // 匹配node_modules下所有的chunk
priority: 10, // 优先级10 优先将node_modules下的chunk拆分到vendor组
enforce: true, // 强制拆分
reuseExistingChunk: true, // 重用模块,而不是重新生成
},
}
}
}
});
return config
}
//...
});
由于我们在对vendor.js
按照chunk
大小进行拆分时在缓存组中注释掉了name
选项。所以即使这样配置了chunks
选项也无效,因为 webpack 构建后并没有name
为vendors
的chunk
。
那我们为什么不把name
选项加上呢?
我们再来看看 webpack 对name
选项的术语解释:
name 提供字符串或函数使你可以使用自定义名称。指定字符串或始终返回相同字符串的函数会将所有常见模块和 vendor 合并为一个 chunk。这可能会导致更大的初始下载量并减慢页面加载速度。
简而言之,如果在相同name
选项的缓存组中有多个chunk
,它们将被合并为一个chunk
。 以vendor
缓存组为例,去除name
选项后,vendor
中的 npm 包被拆分成了多个chunk
,ABTest 页面在加载时只需引入需要的chunk
,这也是为什么去除name
选项后,ABTest 页面资源大小缩小到了1.1MB
的原因。除此之外我们还可以通过一个最小示例来进一步说明这一点:
我们分别有两个页面 pageA 和 pageB。
pageA 引入了antd
中的form
及Button
typescript
// pageA
import { Button, Form} from 'antd'
const pageA = `这是pageA页面${Button}${lodash}${Form}`;
export default pageA;
// index
console.log('index');
import('./pages/pageA').then(({ default: pageA }) => console.log(pageA));
pageB 引入了antd
中的Table
typescript
// pageB
import { Table } from 'antd'
const pageB = `这是pageB页面|${Table}`;
export default pageB
typescript
// index
console.log('index');
import('./pages/pageA').then(({ default: pageA }) => console.log(pageA));
import('./pages/pageB').then(({ default: pageB }) => console.log(pageB));
在 webpack.config 中添加拆包配置:
typescript
splitChunks: {
chunks: 'all',
minSize: 0,
maxSize: 30,
minChunks: 1,
cacheGroups: {
antd: {
name: 'antd',
reuseExistingChunk: true,
chunks: 'all',
priority: -10,
test: /[\\/]node_modules[\\/]antd[\\/]/, // 匹配node_modules下的antd库
},
},
}
查看构建后的产物Button
、Form
和Table
分别被打包成了 3 个 js 文件: 查看入口文件
main.js
main.js
中pageA
和pageB
都去加载了Button
、Form
和Table
,实际上我们的PageA
只用到了Button
和Form
而pageB
只用到了Table
,这说明,即使通过minSize
、maxSize
等方式拆分出了多个chunk
,但它们仍然具有相同的模块依赖关系,webpack
仍然将它们视为同一个chunk
进行依赖处理。
我们再将name
选项去掉或者配置为函数返回不同的name
:
typescript
splitChunks: {
chunks: 'all',
minSize: 0,
maxSize: 30,
minChunks: 1,
cacheGroups: {
antd: {
name(module, chunks, cacheGroupKey) {
const moduleFileName = module
.identifier()
.split('/')
.reduceRight((item) => item);
const allChunksNames = chunks.map((item) => item.name).join('~');
return `${cacheGroupKey}-${allChunksNames}-${moduleFileName}`;
},
reuseExistingChunk: true,
chunks: 'all',
priority: -10,
test: /[\\/]node_modules[\\/]antd[\\/]/, // 匹配node_modules下的antd库
},
},
}
再来看看构建后的main.js
,按需引入生效了:
根据 webpack 对name
选项的解释以及上面的示例,我们已经了解到,具有相同name
选项的多个chunk
将被合并为一个chunk
。以antd
为例,如果希望按需引入组件,则需要动态配置name
选项,或者不添加name
选项。无论选择动态配置还是不配置name
选项,都会导致我们无法直接配置umi.js
中的chunks
选项。因此,我们需要通过 umi 自定义插件,自动为umi.js
添加入口依赖项。
代码如下:
typescript
import { IApi } from 'umi';
import type { Compiler } from 'webpack';
/**
* UmiAutoChunksPlugin: 自动将拆分的代码块注入到 Umi 应用的 HTML 中。
* @param {IApi} api - Umi 的插件 API。
*/
function UmiAutoChunksPlugin(api: IApi) {
// 在 Umi 插件配置中描述此插件
api.describe({
key: 'auto-chunks',
enableBy: () => true,
});
// 存储生成的资源路径的容器
const assets: {
js: string[];
css: string[];
} = {
js: [],
css: [],
};
class HtmlWebpackPlugin {
apply(compiler: Compiler) {
compiler.hooks.emit.tapPromise(
'UmiHtmlGeneration',
async (compilation) => {
const entryPointFiles =
compilation?.entrypoints?.get('umi')?.getFiles() || [];
// 提取 .js、.mjs 和 .css 文件的路径
const entryPointPublicPathMap: { [key: string]: any } = {};
const extensionRegexp = /\.(css|js|mjs)(\?|$)/;
const UMI_ASSETS_REG = {
js: /^umi(\..+)?\.js$/,
css: /^umi(\..+)?\.css$/,
};
entryPointFiles.forEach((entryPointPublicPath) => {
const extMatch = extensionRegexp.exec(entryPointPublicPath);
// 如果公共路径不是 .css、.mjs 或 .js 文件,则跳过
if (!extMatch) {
return;
}
if (entryPointPublicPath.includes('.hot-update')) {
return;
}
// 如果已经处理过则跳过
if (entryPointPublicPathMap[entryPointPublicPath]) {
return;
}
// 跳过 Umi 默认注入的资源
if (
UMI_ASSETS_REG.js.test(entryPointPublicPath) ||
UMI_ASSETS_REG.css.test(entryPointPublicPath)
) {
return;
}
entryPointPublicPathMap[entryPointPublicPath] = true;
const ext = extMatch[1] === 'mjs' ? 'js' : extMatch[1];
// 避免在本地开发热更新时重复注入
if (
assets[ext as 'js' | 'css']?.find(
(i) => i === entryPointPublicPath,
)
) {
return;
}
assets[ext as 'js' | 'css'].push(entryPointPublicPath);
});
},
);
}
}
// 将生成的 CSS 链接添加到 HTML 中
api.addHTMLLinks({
fn: async () => {
const { publicPath } = api.config;
const displayPublicPath = publicPath === 'auto' ? '/' : publicPath;
return assets.css.map((css) => {
return { rel: 'stylesheet', href: `${displayPublicPath}${css}` };
});
},
});
// 将生成的 JS 脚本添加到 HTML 头部
api.addHTMLHeadScripts({
fn: async () => {
const { publicPath } = api.config;
const displayPublicPath = publicPath === 'auto' ? '/' : publicPath;
return assets.js.map((js) => {
return { src: `${displayPublicPath}${js}` };
});
},
});
// 修改打包配置,将拆分的代码块注入到 HTML 中
api.modifyBundleConfig((bundleConfig): any => {
bundleConfig.plugins?.unshift(new HtmlWebpackPlugin() as any);
});
}
module.exports = UmiAutoChunksPlugin;
将这个插件添加到 umi 项目中后,已经可以成功运行项目了。
虽然上述项目中的antd
压缩后的体积仅为97.13KB
,还没有达到需要进行拆分的地步。但是这个示例能帮助我们更好地理解如何在需要时进行优化。具体的解决方案需要根据项目的复杂程度和所使用的 npm 包的大小来决定。 在实际项目中,当 npm 包的体积较大或者项目较为复杂时,拆分和按需加载可以发挥更大的优化效果。通过拆分和按需加载,可以减小每个页面需要加载的资源体积,提高页面加载速度和性能表现。
总结起来,通过合理拆分和按需加载代码块,结合HTTP/2的多路复用特性,我们可以在保证应用性能的同时充分利用浏览器的缓存机制,提供更好的用户体验。
六、一键拆包
为了满足不同应用的拆包需求,我们在公司内部提供了两个拆包插件:
- umi-auto-chunks-plugin:这个插件专为 umi3 框架设计,它能够自动分析并添加 umi 入口依赖,从而减少了手动配置
chunks
的繁琐工作。 - split-chunks-plugin:这个插件内置了多种拆包策略,同时也提供了根据项目类型和具体需求进行配置的灵活性。split-chunks-plugin 支持以下几种拆包策略:
- split-by-experience:根据经验制定的拆分策略,自动将一些常用的 npm 包拆分为体积适中的 chunk,适用于大多数项目。
- split-by-module:按 npm 包的粒度进行拆分,每个 npm 包对应一个
chunk
。 - split-by-size:根据模块大小自动进行拆分。
- single-vendor:将所有 npm 包的代码打包到一个独立的
chunk
中。 - custom:自定义拆包配置,根据项目需求进行定制。
对于基于 webpack 的项目,只需使用 split-chunks-plugin 就能实现拆包优化。 而对于 umi3 项目,我们建议结合使用 umi-auto-chunks-plugin 和 split-chunks-plugin 来进行拆包优化。
通过使用这两个拆包插件,能够轻松地实现一键式的拆包效果。具体选择使用哪个插件,取决于你的项目类型和框架。