代码分割与 Bundle 优化

JavaScript 体积是影响首屏可交互时间(TTI)的最大因素——浏览器需要下载、解析、编译、执行 JS,每一步都消耗时间。Nuxt4 基于 Vite 的 Rollup 打包机制自动进行代码分割,但默认策略并不总是最优的。本章深入分析 Nuxt4 的打包机制、manualChunks 优化策略、动态导入实践、以及 Tree-shaking 调优。

1. Nuxt4 的默认打包策略

1.1 自动代码分割

Nuxt4 / Vite 会自动将代码分割为多个 chunk:

chunk 类型内容加载时机
entryVue 运行时、Vue Router、Nuxt 运行时首次访问
vendornode_modules 中的依赖首次访问
page chunk每个页面组件单独一个 chunk路由导航时按需加载
component chunkLazy 前缀的异步组件组件首次渲染时
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.js230KB gzipdayjs(2KB)
lodash70KB gziplodash-es(按需导入)
chart.js + adapter60KB gzip动态导入,不放入首屏
monaco-editor2MB+动态导入 + Web Worker
video.js150KB 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-vendorVue + Vue Router极低(几个月一次)
ui-vendorUI 框架低(按需更新)
utils-vendorlodash-es, dayjs 等极低
app业务代码高(每次发版)

这样当你更新业务代码时,vue-vendorui-vendorutils-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 的静态分析——importexport 在编译时就确定了,打包器可以安全地移除没有被 importexport

5.2 Tree-shaking 生效条件

条件说明
ESM 格式库必须提供 ES Module 版本(package.jsonmoduleexports 字段)
无副作用库的 package.json 中标记 "sideEffects": false,或打包器能确认模块无副作用
具名导入import &#123; debounce &#125; 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+
P1manualChunks 分包(缓存优化)复访加载 -80%
P1Tree-shakable 依赖替换总 bundle -30%
P2预加载关键 chunkTTI -200ms
P2压缩(gzip/brotli)传输量 -70%

6.3 预加载与预取

// nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    crossOriginPrefetch: true,  // 跨域预取
  },
  router: {
    options: {
      // 自动预取可见链接对应的页面 chunk
      // Nuxt 默认开启
    },
  },
})

Nuxt 默认会在链接进入视口时自动预取其页面 chunk(&lt;NuxtLink&gt; 组件的 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,鼠标悬停可手动预加载