代码分割与 Bundle 优化
JavaScript 体积是影响首屏可交互时间(TTI)的最大因素——浏览器需要下载、解析、编译、执行 JS,每一步都消耗时间。Nuxt4 基于 Vite 的 Rollup 打包机制自动进行代码分割,但默认策略并不总是最优的。本章深入分析 Nuxt4 的打包机制、
manualChunks优化策略、动态导入实践、以及 Tree-shaking 调优。
1. Nuxt4 的默认打包策略
1.1 自动代码分割
Nuxt4 / Vite 会自动将代码分割为多个 chunk:
| chunk 类型 | 内容 | 加载时机 |
|---|---|---|
| entry | Vue 运行时、Vue Router、Nuxt 运行时 | 首次访问 |
| vendor | node_modules 中的依赖 | 首次访问 |
| page chunk | 每个页面组件单独一个 chunk | 路由导航时按需加载 |
| component chunk | Lazy 前缀的异步组件 | 组件首次渲染时 |
| shared chunk | 被多个 chunk 共享的模块 | 依赖它的 chunk 加载时 |
关键机制:Nuxt 的页面级自动代码分割是开箱即用的——pages/ 目录下每个页面都是一个独立的 chunk,只有导航到该页面时才加载。这是 Nuxt 相比纯 Vue 项目的一大优势。
1.2 默认策略的问题
自动分割虽然方便,但在以下场景不够理想:
- vendor 过大:所有
node_modules打成一个 chunk,一个库更新就导致整个 vendor 缓存失效 - 共享模块碎片化:被 2-3 个页面共享的代码可能产生很多小 chunk,增加 HTTP 请求数
- 首屏加载过多:入口 chunk 包含了不需要立即执行的代码
2. nuxt analyze:看清你的 Bundle
2.1 运行分析
npx nuxi analyze这会生成一个可视化的 Bundle 分析报告(基于 rollup-plugin-visualizer),在浏览器中打开交互式的 treemap。
2.2 分析要点
拿到分析报告后,关注以下指标:
| 关注点 | 红线 | 行动 |
|---|---|---|
| 首屏 JS 总量 | > 200KB gzip | 拆分 vendor、延迟加载非关键模块 |
| 单个 chunk | > 500KB | 拆分为更小的 chunk |
| 重复代码 | 同一模块出现在多个 chunk | 提取到共享 chunk |
| 未使用代码 | 大库只用了少量 API | 换用 Tree-shakable 的替代品 |
2.3 常见"胖"依赖
| 依赖 | 典型体积 | 替代方案 |
|---|---|---|
moment.js | 230KB gzip | dayjs(2KB) |
lodash | 70KB gzip | lodash-es(按需导入) |
chart.js + adapter | 60KB gzip | 动态导入,不放入首屏 |
monaco-editor | 2MB+ | 动态导入 + Web Worker |
video.js | 150KB gzip | 动态导入 + <ClientOnly> |
3. manualChunks 配置
3.1 原理
Vite 底层用 Rollup 打包,manualChunks 允许你手动控制哪些模块打入哪个 chunk。在 Nuxt 中通过 vite.build.rollupOptions 配置:
// nuxt.config.ts
export default defineNuxtConfig({
vite: {
build: {
rollupOptions: {
output: {
manualChunks(id) {
// Vue 核心生态单独打包(更新频率低,长期缓存)
if (id.includes('node_modules/vue') ||
id.includes('node_modules/@vue') ||
id.includes('node_modules/vue-router')) {
return 'vue-vendor'
}
// UI 框架单独打包
if (id.includes('node_modules/@nuxt/ui') ||
id.includes('node_modules/@headlessui')) {
return 'ui-vendor'
}
// 工具库单独打包
if (id.includes('node_modules/lodash-es') ||
id.includes('node_modules/dayjs')) {
return 'utils-vendor'
}
},
},
},
},
},
})3.2 分包策略
按更新频率分包是最有效的策略——将更新频率相近的依赖打在一起,最大化浏览器缓存命中率:
| chunk 名 | 内容 | 更新频率 |
|---|---|---|
vue-vendor | Vue + Vue Router | 极低(几个月一次) |
ui-vendor | UI 框架 | 低(按需更新) |
utils-vendor | lodash-es, dayjs 等 | 极低 |
app | 业务代码 | 高(每次发版) |
这样当你更新业务代码时,vue-vendor、ui-vendor、utils-vendor 的缓存都不会失效,用户只需下载变化的 app chunk。
3.3 注意事项
- 不要过度拆分:每个 chunk 都是一个 HTTP 请求,HTTP/2 虽然支持多路复用,但仍有连接开销。5-10 个 chunk 是合理范围
- 避免循环依赖:
manualChunks可能导致循环引用,Rollup 会警告。确保分组逻辑清晰 - SSR 兼容:
manualChunks只影响客户端 bundle,服务端 bundle 不受影响
4. 动态导入
4.1 页面级自动分割
Nuxt 的 pages/ 目录已经自动实现了页面级代码分割。你不需要手动操作——每个 .vue 文件在 pages/ 下都会被自动分割为独立 chunk。
4.2 组件级动态导入
对于大型组件(编辑器、图表、播放器),用 Lazy 前缀或 defineAsyncComponent:
<template>
<!-- Lazy 前缀:Nuxt 自动处理为异步组件 -->
<LazyVideoPlayer v-if="showPlayer" :src="videoUrl" />
<!-- 条件渲染:只有需要时才加载组件代码 -->
<LazyRichTextEditor v-if="isEditing" v-model="content" />
</template>Lazy 前缀的工作原理:Nuxt 在编译时会将 LazyVideoPlayer 转换为 defineAsyncComponent(() => import('~/components/VideoPlayer.vue')),生成独立的 chunk。
4.3 库级动态导入
大型第三方库不应放在首屏:
// ❌ 静态导入:打入首屏 bundle
import ECharts from 'echarts'
// ✅ 动态导入:使用时才加载
async function renderChart(container: HTMLElement, data: any) {
const { init } = await import('echarts')
const chart = init(container)
chart.setOption(data)
return chart
}4.4 播放器 SDK 异步加载实战
视频播放器 SDK 通常很大(100-200KB),且只在播放页面需要:
// app/composables/useVideoPlayer.ts
export function useVideoPlayer() {
const playerInstance = shallowRef<any>(null)
const isLoading = ref(false)
async function initPlayer(container: HTMLElement, options: PlayerOptions) {
isLoading.value = true
try {
// 动态导入播放器 SDK
const { default: Player } = await import('xgplayer')
playerInstance.value = new Player({
el: container,
url: options.url,
autoplay: false,
...options,
})
} finally {
isLoading.value = false
}
}
onBeforeUnmount(() => {
playerInstance.value?.destroy()
})
return { playerInstance, isLoading, initPlayer }
}<!-- pages/videos/[id].vue -->
<template>
<div>
<div ref="playerContainer" class="aspect-video bg-black">
<div v-if="isLoading" class="flex items-center justify-center h-full">
<span class="text-white">播放器加载中...</span>
</div>
</div>
</div>
</template>
<script setup>
const playerContainer = ref<HTMLElement>()
const { isLoading, initPlayer } = useVideoPlayer()
onMounted(() => {
if (playerContainer.value) {
initPlayer(playerContainer.value, { url: video.value.url })
}
})
</script>5. Tree-shaking 优化
5.1 原理
Tree-shaking 是 Rollup/Vite 在构建时删除未使用代码的机制。它基于 ES Module 的静态分析——import 和 export 在编译时就确定了,打包器可以安全地移除没有被 import 的 export。
5.2 Tree-shaking 生效条件
| 条件 | 说明 |
|---|---|
| ESM 格式 | 库必须提供 ES Module 版本(package.json 的 module 或 exports 字段) |
| 无副作用 | 库的 package.json 中标记 "sideEffects": false,或打包器能确认模块无副作用 |
| 具名导入 | import { debounce } from 'lodash-es',而非 import _ from 'lodash' |
5.3 常见 Tree-shaking 失败场景
问题一:默认导入整个库
// ❌ 导入整个 lodash(无法 tree-shake)
import _ from 'lodash'
_.debounce(fn, 300)
// ✅ 从 lodash-es 具名导入
import { debounce } from 'lodash-es'
debounce(fn, 300)
// ✅ 或直接导入子模块
import debounce from 'lodash/debounce'问题二:CJS 格式的库
CommonJS(require/module.exports)是动态的,无法在编译时分析。解决方案:
// nuxt.config.ts
export default defineNuxtConfig({
vite: {
optimizeDeps: {
include: ['some-cjs-lib'], // 让 Vite 预构建为 ESM
},
},
})问题三:有副作用的模块
// 这个导入有副作用(注册全局 polyfill),不会被 tree-shake
import 'core-js/stable'
// 如果只需要特定 polyfill,按需导入
import 'core-js/features/array/flat'5.4 验证 Tree-shaking 效果
在 nuxt analyze 的报告中检查:如果一个库的全部模块都出现在 bundle 中,说明 tree-shaking 失败了。可以通过 Vite 的 build.reportCompressedSize 和 sourcemap 进一步确认。
6. 首屏优化综合策略
6.1 关键渲染路径
HTML 下载 → 解析 HTML → 下载 CSS/JS → 解析 CSS → 执行 JS → 首次渲染
优化目标:减少关键路径上的资源量和阻塞时间。
6.2 策略优先级
| 优先级 | 策略 | 预期收益 |
|---|---|---|
| P0 | 页面级代码分割(Nuxt 默认) | 首屏 JS -50%+ |
| P0 | 大库动态导入(播放器、编辑器) | 首屏 JS -100KB+ |
| P1 | manualChunks 分包(缓存优化) | 复访加载 -80% |
| P1 | Tree-shakable 依赖替换 | 总 bundle -30% |
| P2 | 预加载关键 chunk | TTI -200ms |
| P2 | 压缩(gzip/brotli) | 传输量 -70% |
6.3 预加载与预取
// nuxt.config.ts
export default defineNuxtConfig({
experimental: {
crossOriginPrefetch: true, // 跨域预取
},
router: {
options: {
// 自动预取可见链接对应的页面 chunk
// Nuxt 默认开启
},
},
})Nuxt 默认会在链接进入视口时自动预取其页面 chunk(<NuxtLink> 组件的 prefetch 行为)。这意味着用户点击链接时,代码已经下载好了,导航几乎是即时的。
可以用 prefetch: false 禁用特定链接的预取,或用 preloadRouteComponents() 手动预加载:
// 鼠标悬停时预加载
function onHoverVideoCard(videoId: string) {
preloadRouteComponents(`/videos/${videoId}`)
}本章小结
- 自动分割:Nuxt 默认按页面自动代码分割,每个页面是独立 chunk
- nuxt analyze:可视化分析 bundle 组成,找出"胖"依赖和重复代码
- manualChunks:按更新频率分包(vue-vendor / ui-vendor / utils-vendor),最大化缓存
- 动态导入:大型库(播放器、图表、编辑器)用
import()延迟到使用时加载 - Lazy 前缀:Nuxt 组件加
Lazy前缀自动转为异步组件 - Tree-shaking:使用 ESM 格式、具名导入、lodash-es 替代 lodash
- 预加载:NuxtLink 自动预取可见链接的 chunk,鼠标悬停可手动预加载