包体积过大?我会这样教你「先诊断、再分包、再治理」
顶级前端不会一上来就改配置,而是先建立一套可复用的思维框架。
一、先建立正确目标:你在优化什么?
包体积不是单一数字,要分三层看:
| 指标 | 含义 | 典型目标 |
|---|---|---|
| 首包体积 | 用户第一次打开必须下载的 JS | H5 < 200KB gzip;小程序主包 < 2MB |
| 首屏 JS | 首屏渲染真正执行的代码 | 越小越好,通常 < 100KB |
| 总包体积 | 全站所有 chunk 之和 | 可以大,但不能阻塞首屏 |
核心原则:
不是把包拆小,而是把「用户现在不需要的代码」推迟加载。
二、我的工作流:Measure → Analyze → Split → Verify → Guard

三、诊断工具(按平台)
可视化分析(Vite 用 rollup-plugin-visualizer):
TypeScript
// config/prod.ts
export default {
compiler: {
type: 'vite',
vitePlugins: [visualizer({ open: true, gzipSize: true, brotliSize: true })],
},
},
四、分包的 4 种手段(从易到难)
1. 按页面拆包(最常用,ROI 最高)
antd、echarts、富文本编辑器等重库不要在入口文件、app.ts 或公共 layout 里引入
2. 小程序「分包」(subPackages)
这是微信小程序特有的「物理分包」,和 H5 的 code splitting 是两套机制。
TypeScript
// src/app.config.ts
export default defineAppConfig({
pages: [
'pages/index/index', // 主包:Tab 页、登录、高频页
],
subPackages: [
{
root: 'packages/order',
pages: ['list/index', 'detail/index'],
},
{
root: 'packages/user',
pages: ['profile/index', 'settings/index'],
},
],
preloadRule: {
'pages/index/index': {
network: 'all',
packages: ['packages/order'], // 首页预下载 order 分包
},
},
})
分包设计原则:
- 主包 = 启动路径 + TabBar 页 + 公共组件/工具(尽量轻)
- 分包 = 按业务域划分(订单、用户、活动、管理后台...)
- 独立分包 = 可被分享直达、不依赖主包的页面(如活动落地页)
目录结构示例:
src
├─ pages
│ └─ index ← 主包
│
└─ packages
├─ order
│ ├─ list
│ └─ detail
└─ user
└─ profile
3. Vendor 拆分(第三方库隔离)
H5 端可在 Vite 配置 manualChunks:
TypeScript
// config/prod.ts
import type { UserConfigExport } from '@tarojs/cli';
export default {
mini: {},
modifyViteConfig(viteConfig) {
const output = viteConfig.build?.rollupOptions?.output;
if (Array.isArray(output)) return;
viteConfig.build = {
...viteConfig.build,
rollupOptions: {
...viteConfig.build?.rollupOptions,
output: {
...(typeof output === 'object' ? output : {}),
manualChunks(id: string) {
if (id.includes('node_modules')) {
if (id.includes('react')) return 'vendor-react';
if (id.includes('@tarojs')) return 'vendor-taro';
if (id.includes('antd')) return 'vendor-antd';
return 'vendor';
}
},
},
},
};
},
h5: {},
} satisfies UserConfigExport<'vite'>;
这是 Rollup 的 output.manualChunks 钩子(Vite 生产构建底层用 Rollup):
- 构建时 Rollup 遍历每个模块,把模块的文件路径传给你
- 你
return一个字符串 = 这个模块打进哪个 chunk - 返回
undefined/ 不 return = 交给 Rollup 默认策略
4. 组件/功能级动态加载
适合:编辑器、地图、PDF 预览、复杂图表等「低频重功能」。
TypeScript
import { lazy, Suspense } from 'react'
const RichEditor = lazy(() => import('@/components/RichEditor'))
function Page() {
const [showEditor, setShowEditor] = useState(false)
return (
<>
<Button onClick={() => setShowEditor(true)}>写评论</Button>
{showEditor && (
<Suspense fallback={<Loading />}>
<RichEditor />
</Suspense>
)}
</>
)
}
顶级细节:
- 用
Suspense提供 fallback,避免白屏 - 可配合
import(/* webpackPrefetch: true */ ...)在用户 hover 时预加载 - 小程序端动态 import 支持有限,优先用 分包 + 独立页面 代替
五、比「分包」更底层的减体积手段
分包是「推迟加载」;下面这些才是「真正变小」:
1. 按需引入(Tree Shaking 的前提)
TypeScript
// ❌
import _ from 'lodash'
// ✅
import debounce from 'lodash/debounce'
// ❌ antd 全量
import { Button, Modal, Table } from 'antd'
// ✅ 若项目配置了 babel-plugin-import 或 antd 5 的 ES module,确保走 ESM 路径
2. 检查 package.json 的 sideEffects
TypeScript
{
"sideEffects": ["*.scss", "*.css"]
}
没有 sideEffects 标记,打包器可能不敢 tree-shake。
3. 替换重依赖
| 重 | 轻 |
|---|---|
| moment.js | dayjs |
| lodash 全量 | lodash-es + 按需 |
| echarts 全量 | 按需 register 图表类型 |
| axios(若只用 Taro.request) | 直接用 @tarojs/taro request |
4. 图片/字体/静态资源
- 大图走 CDN + WebP/AVIF
- 图标用 SVG sprite 或 iconfont 子集,不要引整套 icon 库
- 字体只打包用到的字重