引言
开发体验的「快」由 esbuild 与原生 ESM 带来,而生产质量的「优」则由 Rollup 构建管线兑现。很多项目「能跑」但首屏缓慢、加载过多请求、chunk 巨大——根因往往是对构建优化策略的理解不足。Tree Shaking 不是默认魔法,代码分割也不是随便写个 manualChunks 就完事。
本文从 Vite 构建流程讲起,拆解 Tree Shaking 的生效条件、manualChunks 的四种拆分策略、资源内联与预加载、gzip/brotli 压缩对比,最后给出可落地的性能基线与排查思路。读完你将能为自己的项目制定一套「有理有据」的构建优化方案。
前置:https://plumephp.com/vite-config-guide/(build 配置块)与 https://plumephp.com/vite-plugin-development/(用插件做深度定制)。
目录
1. 构建流程:esbuild 与 Rollup 的分工
1.1 双引擎分工
生产构建 (vite build):
1. 依赖预构建(dev 已缓存,build 复用)
2. 模块图遍历 + 代码转换(Vite 插件 + esbuild 转译 TS/JSX)
3. Rollup 打包:tree shaking、合并、chunk 拆分
4. 产物压缩:esbuild minify(默认)或 terser
1.2 关键点
- esbuild 负责转译(TS/JSX → JS),Rollup 负责打包(模块合并、Tree Shaking、chunk)。
- 转换是「按需 + 增量」,打包是「全量 + 静态分析」。
minify 默认为 esbuild(更快),terser(更小但慢)。
1.3 为什么分两步
转译是语法层面的(快)
打包是模块层面的(需完整依赖图)
两者解耦:转译可缓存,打包可精细控制
2. Tree Shaking:为什么不是默认魔法
2.1 什么是 Tree Shaking
构建时通过静态分析,删除未被引用的导出,避免把整库打进产物:
// 源码:只用到了 add
import { add, subtract } from './math'
// 若 subtract 未被使用,Rollup 会将其从产物中移除
2.2 生效条件
| 条件 | 说明 |
|---|
| ESM 静态 import | import { x } from 'pkg' 可分析 |
CJS/require() | 动态 require 无法静态分析,通常不摇树 |
package 的 sideEffects 声明 | sideEffects: false 允许整体移除 |
| 纯函数副作用识别 | 有副作用的模块不会移除 |
2.3 库作者的 sideEffects
// 库 package.json
{
"sideEffects": false
}
// 如果某些文件有副作用,需要精确声明
{
"sideEffects": ["./src/styles.css", "*.css"]
}
2.4 常见「摇不掉」的原因
1. 依赖是 CJS(CommonJS)——需要 esbuild 转 ESM 后才有摇树机会
2. 模块有顶层副作用(调用全局、写文件)
3. 库未声明 sideEffects
4. 使用命名空间 import(import * as ...)限制摇树粒度
3. manualChunks:代码分割策略
3.1 默认行为
Vite/Rollup 默认按入口与动态 import 拆分 chunk,但第三方依赖常常被合并进同一个 vendor chunk,导致单文件过大。
3.2 显式 manualChunks
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
// 按包名分组
if (id.includes('node_modules')) {
if (id.includes('react')) return 'vendor-react'
if (id.includes('lodash')) return 'vendor-lodash'
if (id.includes('three')) return 'vendor-three'
return 'vendor-other'
}
},
},
},
},
})
3.3 常见拆分策略
| 策略 | 做法 | 适用 |
|---|
| 按依赖拆分 | react/vue 单独 chunk | 缓存友好(依赖不变则 chunk 不变) |
| 按路由拆分 | 每页一个 chunk + 动态 import | 首屏只加载当前页 |
| 按体积拆分 | 超大库(three.js)独立 | 避免影响首屏 |
| 合并小依赖 | 过多小 chunk 时合并 | 减少请求数 |
3.4 拆分的权衡
拆太细 -> 请求多、HTTP 开销大
拆太粗 -> 单 chunk 过大、首屏慢
目标:在「请求数」与「体积」之间找平衡
4. 动态 import 与按需加载
4.1 代码里用动态 import
// 路由级懒加载(Vue Router)
const UserPage = () => import('./pages/UserPage.vue')
// 条件加载(图表等重库)
async function loadChart() {
const { createChart } = await import('echarts')
createChart(el, data)
}
4.2 效果
首屏只加载入口 + 必要 vendor
懒加载模块 -> 生成独立 chunk -> 触发时异步请求
4.3 配合 preload
export default defineConfig({
build: {
rollupOptions: {
output: {
// 关键模块生成 preload 提示
// 浏览器会在空闲时预取这些 chunk
},
},
},
})
5. 资源内联与预加载
5.1 内联小资源
export default defineConfig({
build: {
// 小于 4KB 的资源转 base64 内联,减少请求
assetsInlineLimit: 4096,
},
})
5.2 首屏关键资源预加载
<!-- 产物中的 <link rel="modulepreload"> 由 Vite 自动生成 -->
<link rel="modulepreload" href="/assets/vendor-react.abc.js" />
<link rel="modulepreload" href="/assets/index.def.js" />
- modulepreload:让浏览器尽早开始加载关键 chunk。
- Vite 会自动为入口 chunk 生成 modulepreload 链接。
5.3 手动 preload 的取舍
| 方式 | 优点 | 缺点 |
|---|
| 全部 preload | 后续点击秒开 | 首屏带宽占用增加 |
| 仅关键 chunk | 平衡首屏与体验 | 需人工分析 |
6. gzip 与 brotli:压缩对比
6.1 压缩级别与收益
| 算法 | 压缩比 | 解压速度 | 体积(典型 JS) |
|---|
| 不压缩 | — | — | 100 KB |
| gzip(level 6) | 约 70% | 快 | ~35 KB |
| brotli(level 11) | 约 75% | 略慢 | ~28 KB |
6.2 在构建时预生成 .gz/.br
// vite-plugin-compression 或自定义
export default defineConfig({
plugins: [
compression({
algorithm: 'brotliCompress',
ext: '.br',
threshold: 10240, // 10KB 以上才压缩
}),
],
})
6.3 部署层协商
CDN/Web 服务器根据 Accept-Encoding 选择:
浏览器发 Accept-Encoding: br -> 返回 .br
不支持 br -> 回退 gzip -> 再回退原文件
注意:预压缩文件需要服务器静态托管支持
7. 性能基线指标
7.1 需要关注的数字
| 指标 | 合理基线 | 说明 |
|---|
| 入口 JS | < 150 KB(gzip) | 首屏主 bundle |
| vendor chunk | 可控且稳定 | 可缓存、不常变 |
| 总请求数 | < 20(首屏) | 含静态资源 |
| LCP | < 2.5s | 用户可感知速度 |
| chunk 数 | 适中(非碎片化) | 5~15 合理 |
7.2 量化检查
# 构建产物体积
ls -lh dist/assets/*.js
# 用 vite build 的默认警告
# 超过 chunkSizeWarningLimit(默认 500KB)会提示
7.3 设置自己的警告阈值
export default defineConfig({
build: {
chunkSizeWarningLimit: 600, // 提示超过 600KB 的 chunk
},
})
8. 常见构建问题排查
8.1 问题清单
| 现象 | 原因 | 解决 |
|---|
| 构建产物巨大 | 未摇树 / 依赖过多 | 检查 sideEffects、精简依赖 |
| 大量小 chunk | 过度动态 import | 合并或调整拆分粒度 |
| 首屏请求过多 | preload 全开 / 资源零内联 | 调整 modulepreload 与 inlineLimit |
| 无 .gz/.br 文件 | 未配置压缩插件 | 构建期生成压缩文件 |
| 特定依赖无法摇树 | CJS 依赖 | 用 optimizeDeps 或升级库 |
8.2 用插件输出体积报告
// 接入可视化分析(如 vite-plugin-visualizer / rollup-plugin-visualizer)
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [visualizer({ open: true })],
})
8.3 定位重复依赖
# 检查依赖树
npm ls lodash
# 若出现不同版本,考虑 resolve.dedupe
export default defineConfig({
resolve: {
dedupe: ['react', 'lodash'],
},
})
9. 总结:优化决策框架
9.1 一套可复用流程
1. 先量化(体积/请求数/加载时间)
2. 再拆 chunk(vendor + 路由级动态 import)
3. 再缩体积(Tree Shaking + 压缩 + 内联)
4. 最后调加载(preload + CDN + 缓存)
9.2 优化优先级
| 优先级 | 动作 | 收益 |
|---|
| P0 | 路由级懒加载 | 首屏显著变快 |
| P1 | vendor chunk 稳定 | 缓存命中率提升 |
| P2 | 构建期 gzip/brotli | 传输体积下降 |
| P3 | Tree Shaking 治理 | 总包体积减小 |
9.3 自检清单
| 检查项 | 是否掌握 |
|---|
| 能解释 Tree Shaking 生效条件 | ☐ |
| 能配置 manualChunks 拆分 | ☐ |
| 能落地路由级动态 import | ☐ |
| 能解释 gzip/brotli 与协商 | ☐ |
| 能为项目设定性能基线 | ☐ |
延伸阅读
- https://plumephp.com/vite-config-guide/ — build 配置块详解
- https://plumephp.com/vite-plugin-development/ — 用插件实现压缩/分析
- https://plumephp.com/frontend-vite-deep-dive/ — 模块图与依赖预构建原理
- Vite 构建与生产环境文档 — 官方构建指南
- Rollup 输出选项文档 — manualChunks 完整参考
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。