Vite 生产构建与代码分割
Vite 生产构建:build 配置、动态 import 与 manualChunks 分包、tree-shaking、资源压缩与产物体积分析
知识点地图
- 知识类别:Vite 生产构建与代码分割(
build配置、动态 import、manualChunks、产物分析),对应 vite.dev 的「Build Options」与「Guide: Building for Production」章节。 - 解决什么问题:开发环境秒开、构建上线后首屏 5 秒——开发不打包掩盖了体积问题;构建产物是黑盒,不知道哪个依赖拖慢了首屏。本篇给出一套「拆分 -> 分析 -> 预算」的完整优化闭环。
- 什么时候用到:上线前性能体检;首屏慢的归因排查;依赖升级后核对产物变化;CI 里守住体积预算。
- 本篇主线:第 1 节的事故现场是全篇的「为什么」——后面每一节都是给那 5 秒做的一刀手术。拆分手段(动态 import/manualChunks)解决「下多少」,分析工具链(visualizer/source-map-explorer/size-limit)与加载策略(modulepreload/prefetch)解决「下得对不对、来得早不早」。
- 本篇不讲:Rolldown 本身的迁移(见 Vite 8 与 Rolldown);插件机制(见 插件生态与机制)。
0. 一个类比:搬家打包与快递分装
假设你要搬家,把所有家当堆进一个巨大的行李箱,然后整个搬走。后果是什么?路上每开一段就要翻箱倒柜找东西;到了新家,哪怕只想用一把勺子,也得先把整个行李箱翻个底朝天。
聪明的搬家方式是这样的:
- 常用的小件(证件、钥匙、充电器)随身带着——对应”首屏只加载必需代码”
- 大型家电(冰箱、洗衣机)单独包装、单独运输——对应”大依赖拆成独立 chunk”
- 不常穿的换季衣服先寄存在仓库,需要时再取——对应”路由懒加载,用到了才请求”
- 搬家公司还提供”打包清单”,告诉你每箱装了什么——对应”产物分析工具”
网页加载的道理完全一样:把全部代码塞进一个文件,用户打开页面就要下载几 MB 的 JS,首屏等得心焦;拆成多个文件按需加载,用户只下载当前页面需要的部分。这就是代码分割(Code Splitting)。本文用一个真实事故开场,带你完整走一遍 Vite 生产构建与代码分割的优化链路。
1. 事故现场:首屏加载 5 秒
某电商后台项目上线后,用户反馈”打开页面要转 5 秒的圈”。排查过程如下:
第一步:看 Network 面板
index-abc123.js 2.1 MB 下载耗时 2.8s(4G 网络)
chunk-xyz789.js 800 KB
vendor-qwe456.js 1.5 MB ← 注意:第三方库竟然有 1.5 MB!
第二步:看终端构建输出
dist/assets/index-abc123.js 2,850.42 kB │ gzip: 820.11 kB
(!) Some chunks are larger than 500 kB after minification.两个关键线索:
- 所有路由的代码都打进了同一个文件——用户打开登录页,却下载了整个后台的所有页面代码。
- 第三方库全部混在一起——图表库、UI 库、工具库打包成一个巨型 vendor 文件,只要升级其中任意一个库,整个 vendor 都要重新下载,浏览器缓存形同虚设。
这就是典型的分包失败案例。接下来的内容,就是教你一步步解决这类问题。
2. 生产构建做了什么
vite build 把开发产物转换成可上线的优化版本。Vite 8 中整条流程由 Rolldown 统一完成(开发与生产同一套管线,详见《Vite 8 与 Rolldown 新特性》)。一次构建的执行链:
vite build 的执行链:
- 入口分析:从 index.html 追踪所有模块
- 转换与解析:TS/JSX 转 JS、处理 import 图
- tree-shaking:删除未使用的代码
- 代码分割:按动态 import 边界与 manualChunks 拆分 chunk
- 压缩:JS/CSS 压缩 + 文件内容哈希
- 输出到 dist/(默认)
讲解:开发环境(dev)不打包、按需转换;生产构建则相反——完整打包、深度优化。Vite 8 中两者由同一个打包器承担,“本地能跑、上线就挂”的差异问题从架构上被大幅消除(《Vite 8 与 Rolldown 新特性》详述)。
先看一个最简单的构建示例:
# 执行生产构建
pnpm build
# 典型输出
vite v8.x.x building for production...
42 modules transformed.
dist/index.html 0.45 kB
dist/assets/index-Bh7kRCDa.js 85.14 kB │ gzip: 26.32 kB文件名的 Bh7kRCDa 是内容哈希:文件内容变化,哈希就变化,文件名随之变化。这是浏览器缓存策略的基础——内容没变,文件名不变,浏览器继续用缓存;内容变了,新文件名迫使浏览器下载新版本。
3. 问题一:整包太大——动态 import 按路由拆分
3.1 动态 import 是什么
import() 是 JavaScript 原生的动态导入语法,也是 Vite 代码分割的”天然边界”。只要代码里出现 import(),构建时就会自动拆出一个独立 chunk,按需加载:
// 静态导入:无论用不用,都会被打进主包
import UserPage from './pages/UserPage'
// 动态导入:构建时自动拆出独立 chunk,用到了才下载
const UserPage = () => import('./pages/UserPage')3.2 路由级懒加载
实际项目中最常见的用法是”一个路由一个 chunk”:
// React + React Router 写法
import { lazy, Suspense } from 'react'
// 每个 lazy() 对应一个独立 chunk
const HomePage = lazy(() => import('./pages/HomePage'))
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
// 用 Suspense 包裹,加载 chunk 时显示 fallback
function App() {
return (
<Suspense fallback={<div>页面加载中...</div>}>
{/* 路由切换时按需加载对应 chunk */}
</Suspense>
)
}// Vue Router 写法:component 使用函数形式即可
const routes = [
{ path: '/', component: () => import('../views/Home.vue') },
{ path: '/dashboard', component: () => import('../views/Dashboard.vue') },
{ path: '/settings', component: () => import('../views/Settings.vue') },
]效果:构建后 dist/assets/ 下出现 HomePage-xxx.js、DashboardPage-xxx.js 等多个文件,用户访问 / 时只下载 Home 页面的 chunk。
3.3 组件级懒加载
大组件(如富文本编辑器、大图表)也可以单独懒加载,而不必等到路由级别:
// 仅在用户点击"编辑"时才加载编辑器(约 400KB)
const onClickEdit = async () => {
const { Editor } = await import('../components/Editor')
setEditorReady(Editor)
}3.4 注意事项
- 不要滥用:把 20 行的简单组件也拆出去,只会制造大量小文件,增加 HTTP 请求数,得不偿失。一般”路由级 + 超大第三方依赖”是拆分重点。
import()里尽量用静态路径:import(\./locales/${lang}.json`)` 这种带变量的写法,打包器只能把该目录下所有文件都作为候选拆出,容易失控。
4. 问题二:第三方库混在一起——manualChunks 手动分组
动态 import 解决”按需加载”,manualChunks 解决”缓存复用”。目标:把变更频率接近的代码放在同一个 chunk 里,某个库升级只重下对应 chunk。
4.1 对象形式(简单分组)
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// 将 echarts 及其子依赖合并为独立 chunk
charts: ['echarts', 'echarts-gl'],
// 将 UI 库单独拆出
ui: ['antd', '@ant-design/icons'],
// 框架与路由单独拆出
framework: ['react', 'react-dom', 'react-router-dom'],
},
},
},
},
})对象形式的缺点:若某个库实际未被引入,会生成空的 chunk。
4.2 函数形式(推荐,更灵活)
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
// 函数形式:根据模块 ID 动态决定归属
manualChunks(id) {
// 只处理 node_modules 里的第三方依赖
if (id.includes('node_modules')) {
// 图表库体积大且很少变更,单独成包
if (id.includes('echarts') || id.includes('d3')) return 'charts'
// 框架代码极少变更,单独成包
if (id.includes('react') || id.includes('react-dom')) return 'react-vendor'
// UI 组件库按需引入,中等变更频率
if (id.includes('antd') || id.includes('@arco-design')) return 'ui'
// 其余依赖统一放进 vendor
return 'vendor'
}
},
},
},
},
})讲解:函数形式的判断顺序很重要——把”变更频率低、体积大”的库放在最前面匹配。分包粒度太粗(全塞 vendor)缓存命中率低;太细(每个库一包)又会制造几十个文件。业界经验:框架 1 包、UI 库 1 包、大图表库 1 包、其余 vendor 1 包是比较稳妥的起点。
配置名仍是 rollupOptions:在 Vite 8 中它作为 Rolldown 的兼容入口保留,保持插件与配置的兼容性(《Vite 8 与 Rolldown 新特性》会讲 rolldownOptions 与迁移)。
4.3 分包后的实际收益
以第 1 节事故项目为例,分四步整改:
整改前:
index.js 2.85 MB(全部页面 + 全部第三方库混在一起)
整改后:
react-vendor.js 180 kB ← 极少变更,长期缓存
ui.js 320 kB
charts.js 1.1 MB ← 只在用到图表的页面按需加载
vendor.js 260 kB
index.js 95 kB ← 首屏主包
HomePage.js 60 kB ← 路由 chunk,按需加载
Dashboard.js 85 kB首屏从”下载 2.85 MB”降到”下载约 1 MB 以内”,且之后每次发布,只要依赖没变,vendor 全部走缓存。
5. build 配置核心项
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
outDir: 'dist', // 输出目录(相对项目根)
assetsDir: 'assets', // 静态资源子目录
sourcemap: false, // 是否生成 sourcemap,调试用 'hidden'
minify: true, // 是否压缩 JS
target: 'baseline-widely-available', // 编译目标浏览器
cssCodeSplit: true, // CSS 代码分割
assetsInlineLimit: 4096, // 小于 4KB 的资源内联为 base64
chunkSizeWarningLimit: 500, // chunk 超过 500KB 时警告
emptyOutDir: true, // 构建前清空 outDir
},
})| 选项 | 说明 |
|---|---|
outDir | 构建产物目录,构建前自动清空(默认 emptyOutDir: true) |
sourcemap | true 生成 .map 文件;'hidden' 生成但不写注释(避免源码映射暴露给用户);'inline' 内联到 JS 里(会让文件显著变大) |
target | 目标浏览器语法。Vite 8 默认 'baseline-widely-available'(2026 年起的主流浏览器基线,Chrome 111 / Edge 111 / Firefox 114 / Safari 16.4 起) |
minify | 是否压缩 JS。Vite 8 中由 Rolldown 基于 Oxc 原生执行,不再依赖单独压缩器 |
chunkSizeWarningLimit | 单个 chunk 超过该体积(KB)时输出警告。默认 500,这是提醒而非错误 |
emptyOutDir | 构建前是否清空输出目录。注意:outDir 位于项目根目录之外时默认为 false,需显式开启 |
6. tree-shaking:消除无用代码
tree-shaking(摇树)依赖 ES Module 的静态结构——import/export 在编译期即可确定,构建时删除”被引入但从未使用”的代码:
// utils.ts
export function used() { return 'ok' }
export function unused() { return 'dead code' } // 会被删除
// main.ts
import { used } from './utils'
console.log(used())构建后,unused 函数不会出现在产物里。为保证 tree-shaking 效果,请做到:
- 使用 ESM 语法(
import/export),避免require()等 CommonJS 写法。 - 避免模块顶层产生副作用。
console.log('loaded')这种顶层语句会让打包器认为模块有副作用而保留整段代码;package.json里可配置"sideEffects": false声明包内无副作用。 - 第三方库选择提供 ESM 产物的版本。lodash 全量引入会拖进整个库,改用
lodash-es(ESM 版)才能被正确摇树;import { debounce } from 'lodash'在部分库上仍会引入整库。 - 避免 barrel 文件副作用:
index.ts统一导出(barrel)里若有副作用导入,整个 barrel 都可能被保留。
Rolldown 在 Vite 8 中默认启用更强的死代码消除与常量内联,同等代码下产物往往比旧版更小。
7. 资源压缩
| 产物类型 | 压缩方式 | 说明 |
|---|---|---|
| JS | Rolldown 内置压缩(Oxc Minifier 实现) | 无需额外依赖 |
| CSS | Lightning CSS 压缩 | 默认启用,无需配置 |
| 图片/字体 | 不压缩(原样复制) | 需用图片优化插件 |
| HTML | 极简压缩 | 保留必要结构 |
讲解:Vite 8 不再依赖 esbuild 压缩 JS、也不需要 cssnano——分别被 Rolldown(Oxc)与 Lightning CSS 取代。图片压缩不是 Vite 内置能力,可选用 vite-plugin-imagemin 或构建前用工具处理。另外注意:生产构建默认保留 console.log 与 debugger,需要移除时要在构建配置中显式开启对应的 drop 选项——上线前检查产物里有没有调试日志,是发版 checklist 的固定动作。
8. 分析产物体积
8.1 终端报告
pnpm build终端会按 chunk 列出体积与 gzip 体积,一眼看出”哪个 chunk 超了 500KB 警告线”。
8.2 可视化分析
终端报告只到 chunk 级别,想看”chunk 里哪个依赖占了多少”要用可视化插件:
pnpm add -D rollup-plugin-visualizer// vite.config.ts
import { defineConfig } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [visualizer({ open: true })], // 构建后自动打开分析页面
})pnpm build # 构建完成后自动打开 treemap 分析页visualizer 生成交互式 treemap(矩形面积图):每个矩形的大小代表体积占比,鼠标悬停能看到具体依赖。定位”某个库怎么占了 500KB”这类问题,这是必备工具。
8.3 体积预算意识
业界经验值:移动端首屏 JS(gzip 后)200KB 左右是”表现良好”的上限。500KB 压缩前的 chunk,gzip 后大约 150KB,已经接近红线。建议在 CI 中加体积检查(如 size-limit),防止体积悄悄膨胀。
8.4 性能预算落地:把 8.3 的数字搬进 CI
体积预算停留在「团队共识」里就会失效——人会忘,CI 不会。用 size-limit 把预算写成可执行断言:
// package.json
{
"size-limit": [
{ "name": "首屏入口", "path": "dist/assets/index-*.js", "limit": "150 KB", "gzip": true },
{ "name": "单 chunk 上限", "path": "dist/assets/*.js", "limit": "300 KB", "gzip": true }
],
"scripts": {
"size": "pnpm build && size-limit"
}
}落地要点:
- CI 流水线在 build 后追加
size-limit步骤,超预算直接红——「这次 PR 让首屏多了 40KB」从口口相传变成流水线上的硬失败; - 预算值不是抄来的:先跑一次当前产物记录基线,预算设为「基线 x 1.1」,每季度随重构收敛一次;
path用通配符匹配带哈希的文件名;gzip: true让断言对齐传输体积(8.3 的红线是 gzip 后口径)。
8.5 加载时序策略:modulepreload 与 prefetch
拆分之后出现新问题:浏览器「发现需要下一个 chunk」有延迟——主 chunk 执行到 import() 才发起下一个请求,形成请求瀑布。两类注入手段:
- modulepreload(关键路径):Vite 构建时自动为入口 chunk 的静态依赖注入
<link rel="modulepreload">,浏览器并行预取并预解析。需要干预的场景:某路由的手动分组 vendor(4 节的vendor-chart)不在自动注入清单里,可在index.html手写一行<link rel="modulepreload" href="/assets/vendor-chart-xxx.js">,或用build.modulePreload.resolveDependencies编程化注入; - prefetch(非关键路径):用户大概率会去、但不在当前页必需的资源(如登录后的仪表盘),加
<link rel="prefetch">让浏览器空闲时提前拉取——牺牲一点带宽换下一跳的瞬时切换。判断标准一句话:当前路由渲染要用的用 modulepreload,下个路由大概率要用的用 prefetch。
误用的代价:把太多 chunk 塞进 modulepreload,等于变相回到「一次性下完」——瀑布消失了,首屏带宽竞争回来了。注入清单要跟着「真实首屏依赖」走,而不是「我们觉得重要的」。
8.6 source-map-explorer:gzip 之后的归因
visualizer 回答「哪个模块体积大」,但它看的是压缩前体积。两个它回答不了的问题,交给基于 sourcemap 的归因工具:
- tree-shaking 真的生效了吗:明明
import { debounce } from 'lodash-es',产物里却出现了一堆没用到的方法。source-map-explorer把产物字节逐条映射回源文件,直接看到哪些源文件真实进入了产物:bashpnpm build --sourcemap npx source-map-explorer dist/assets/index-*.js - 体积超预算时的精确定位:size-limit 报红后,用归因结果决定「换库、拆分还是砍功能」,而不是凭 treemap 的矩形目测。
工具链分工一句话:visualizer 看模块占比(压缩前),source-map-explorer 看产物归因(映射回源文件),size-limit 守门(gzip 口径)——三件套各管一段,别指望任何一个单独回答所有体积问题。
动手实践:复现并修好那 5 秒
任务:亲手把第 1 节的事故完整复现一遍,再按本篇手段修复,记录前后数据。
- 制造事故:新建 Vite 项目,
pnpm add echarts,在入口文件静态import * as echarts from 'echarts'(只用它画一个 100x100 的小图);执行pnpm build,记录入口 chunk 体积与 chunk 数量; - 第一刀:动态 import:把 echarts 改为图表组件内
const { init } = await import('echarts')动态加载,重新 build,对比入口体积变化; - 第二刀:按需引入:改用
echarts/core+ 按需注册(只引柱状图与 Canvas 渲染器),对比体积; - 归因验证:给第 1 步的产物跑
source-map-explorer,确认 echarts 全量模块确实在产物里;给第 3 步产物再跑一次,确认只剩用到的模块; - 守门:把最优体积写进
size-limit预算,本地故意pnpm add lodash(完整 CJS 版)静态引入,验证 CI 式检查会红。
参考数据与要点(先自己跑,再展开对照)
参考量级(echarts 5.x,构建默认压缩):第 1 步入口 chunk 约 1MB+(echarts 全量约 800KB-1MB 混入主包);第 2 步入口回落到几十 KB,echarts 独立成异步 chunk(首屏不再下载它,代价是进图表页时多一次请求);第 3 步按需引入后异步 chunk 降到 300-400KB 以下(只含 core+柱状图+Canvas)。
第 4 步的判读:第 1 步归因图里能看到 echarts 的 lib/chart/ 下所有图表类型源文件;第 3 步只剩 BarChart 与 CanvasRenderer 相关文件——tree-shaking 在按需引入下真正生效的证据。注意:echarts 全量静态引入时 tree-shaking 帮不了你,它的入口 re-export 全部图表且带副作用标记,摇不动的部分必须靠按需 API 主动裁剪。
第 5 步:lodash 完整 CJS 版约 70KB+(无 tree-shaking),一旦静态引入即触发预算红线。把「添加依赖 -> 产物红 -> 归因定位」这个循环跑顺,你就拥有了 8.4-8.6 全套工具的实战肌肉记忆。
9. 常见错误与对策表
| 现象 / 报错信息 | 常见原因 | 解决办法 |
|---|---|---|
构建输出 Some chunks are larger than 500 kB | 主包混入了大依赖且未分割 | 用动态 import 拆路由、manualChunks 拆第三方库 |
| 构建成功但上线 404 | base 配置与部署子路径不一致 | 部署在子路径时配置 base: '/子路径/',见《Vite 静态资源处理》 |
| tree-shaking 失效、产物里仍有死代码 | 依赖是 CommonJS 或模块有顶层副作用 | 改用 ESM 版库(如 lodash-es),配置 sideEffects |
| 动态 import 不生效、仍打进主包 | 误用了静态 import 或变量路径 | 用 const Page = () => import('./Page') 写法,路径写静态 |
| vendor chunk 反复变哈希、缓存失效 | 手动分组粒度不合理,业务代码混入 vendor | 按”框架/UI/大库/其余”分层分组 |
| manualChunks 生成空 chunk | 对象形式声明了未被引用的库 | 改用函数形式,按 id.includes(...) 动态判断 |
产物里残留 console.log / debugger | 生产压缩配置未移除 | 确认构建压缩开启(默认移除),或按团队约定配置 |
| sourcemap 泄露源码 | sourcemap: 'inline' 或 true 直接上线 | 线上用 'hidden',或只用于灰度/内网 |
10. 一句话记忆
代码分割就是”搬家分装”:动态 import 让每个路由按需加载,manualChunks 让变更频率相近的依赖共享缓存——用户只下载当下需要的,浏览器只重新下载变了的。