案例背景
本文以一个真实的内容型网站为例,介绍 Nuxt 3 性能优化的完整过程。该网站初始性能数据如下:
| 指标 | 优化前 | 目标值 |
|---|---|---|
| LCP | 4.2s | <2.5s |
| FID | 180ms | <100ms |
| CLS | 0.25 | <0.1 |
| 首屏时间 | 3.8s | <1.5s |
| 构建体积 | 2.1MB | <500KB |
通过系统性优化,最终达成了所有目标。以下是详细的优化过程。
构建优化
分析构建产物
首先需要了解当前的包体积构成:
// nuxt.config.ts
export default defineNuxtConfig({
build: {
analyze: true
}
})
运行 nuxt build --analyze 后,发现以下问题:
- lodash 完整引入(200KB)
- moment.js 包含所有语言包(300KB)
- 未使用的 UI 组件库组件(150KB)
Tree Shaking 优化
优化 lodash:
// ❌ 优化前:完整引入
import _ from 'lodash'
_.debounce(fn, 300)
// ✅ 优化后:按需引入
import debounce from 'lodash/debounce'
debounce(fn, 300)
// 或使用 lodash-es
import { debounce } from 'lodash-es'
替换 moment.js:
// ❌ 优化前:moment.js (300KB)
import moment from 'moment'
moment().format('YYYY-MM-DD')
// ✅ 优化后:dayjs (2KB)
import dayjs from 'dayjs'
dayjs().format('YYYY-MM-DD')
代码分割策略
// nuxt.config.ts
export default defineNuxtConfig({
vite: {
build: {
rollupOptions: {
output: {
// 手动分包
manualChunks: (id) => {
// Vue 相关
if (id.includes('vue') || id.includes('@vue')) {
return 'vue-vendor'
}
// UI 库
if (id.includes('element-plus') || id.includes('@element-plus')) {
return 'ui-vendor'
}
// 工具库
if (id.includes('lodash') || id.includes('dayjs')) {
return 'utils-vendor'
}
}
}
},
// 分块大小警告阈值
chunkSizeWarningLimit: 500
}
}
})
组件按需加载
// nuxt.config.ts
export default defineNuxtConfig({
components: {
dirs: [
{
path: '~/components',
// 不自动引入的目录
ignore: ['**/legacy/**']
}
]
}
})
对于大型组件使用动态导入:
<template>
<div>
<!-- 懒加载重型组件 -->
<LazyRichTextEditor v-if="showEditor" />
<!-- 条件渲染时才加载 -->
<ClientOnly>
<LazyChartComponent :data="chartData" />
</ClientOnly>
</div>
</template>
<script setup>
// 使用 defineAsyncComponent 更精细控制
const HeavyComponent = defineAsyncComponent({
loader: () => import('~/components/HeavyComponent.vue'),
loadingComponent: LoadingSpinner,
delay: 200,
timeout: 10000
})
</script>
渲染优化
渲染模式选择
根据页面特性选择合适的渲染模式:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// 首页:SSG,构建时生成
'/': { prerender: true },
// 博客文章:ISR,每小时更新
'/blog/**': { isr: 3600 },
// 用户中心:SPA,客户端渲染
'/account/**': { ssr: false },
// 产品页:SSR,每次请求渲染
'/products/**': { ssr: true },
// API 路由:无缓存
'/api/**': {
cors: true,
headers: { 'Cache-Control': 'no-cache' }
}
}
})
预渲染优化
对于静态内容优先使用预渲染:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
prerender: {
// 预渲染这些路由
routes: [
'/',
'/about',
'/contact',
'/pricing'
],
// 爬取发现的链接
crawlLinks: true,
// 忽略错误继续
failOnError: false
}
}
})
Payload 优化
减少 SSR 传递给客户端的数据:
<script setup>
// ❌ 传递完整对象(包含不需要的字段)
const { data: posts } = await useFetch('/api/posts')
// ✅ 只获取需要的字段
const { data: posts } = await useFetch('/api/posts', {
pick: ['id', 'title', 'summary', 'date'],
transform: (data) => {
// 转换数据,减少体积
return data.map(post => ({
id: post.id,
title: post.title,
summary: post.summary?.slice(0, 100),
date: post.date
}))
}
})
</script>
组件缓存
使用 <KeepAlive> 缓存组件状态:
<template>
<NuxtLayout>
<NuxtPage :keepalive="{
include: ['ProductList', 'SearchResults'],
max: 10
}" />
</NuxtLayout>
</template>
数据获取优化
并行数据获取
<script setup>
// ❌ 串行请求(慢)
const { data: user } = await useFetch('/api/user')
const { data: posts } = await useFetch('/api/posts')
const { data: notifications } = await useFetch('/api/notifications')
// ✅ 并行请求(快)
const [
{ data: user },
{ data: posts },
{ data: notifications }
] = await Promise.all([
useFetch('/api/user'),
useFetch('/api/posts'),
useFetch('/api/notifications')
])
</script>
智能缓存策略
<script setup>
// 配置详细的缓存策略
const { data: products } = await useFetch('/api/products', {
// 缓存键
key: 'products-list',
// 获取缓存的数据后立即渲染,同时后台刷新
getCachedData(key) {
const cached = nuxtApp.payload.data[key] || nuxtApp.static.data[key]
if (cached) {
// 检查缓存是否过期(5分钟)
const cacheTime = nuxtApp.payload._cache?.[key]
if (cacheTime && Date.now() - cacheTime < 5 * 60 * 1000) {
return cached
}
}
return null
},
// 请求去重
dedupe: 'cancel'
})
</script>
分页数据懒加载
<script setup>
const page = ref(1)
const pageSize = 20
const allItems = ref([])
// 初始数据
const { data: initialData } = await useFetch('/api/items', {
query: { page: 1, pageSize }
})
allItems.value = initialData.value?.items || []
// 加载更多
const loadMore = async () => {
page.value++
const { data } = await useFetch('/api/items', {
query: { page: page.value, pageSize },
key: `items-page-${page.value}`
})
if (data.value?.items) {
allItems.value.push(...data.value.items)
}
}
</script>
图片优化
Nuxt Image 配置
// nuxt.config.ts
export default defineNuxtConfig({
image: {
// 图片质量
quality: 80,
// 响应式断点
screens: {
xs: 320,
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
xxl: 1536
},
// 预设配置
presets: {
// 博客封面图
cover: {
modifiers: {
format: 'webp',
fit: 'cover',
quality: 80
}
},
// 缩略图
thumbnail: {
modifiers: {
format: 'webp',
width: 300,
height: 200,
fit: 'cover',
quality: 70
}
},
// 头像
avatar: {
modifiers: {
format: 'webp',
width: 100,
height: 100,
fit: 'cover',
quality: 75
}
}
},
// CDN 配置
domains: ['cdn.example.com', 'images.unsplash.com']
}
})
图片组件使用
<template>
<!-- 响应式图片 -->
<NuxtImg
src="/images/hero.jpg"
alt="Hero Image"
sizes="sm:100vw md:50vw lg:400px"
:placeholder="[50, 25, 75, 5]"
loading="lazy"
format="webp"
/>
<!-- 使用预设 -->
<NuxtImg
:src="post.cover"
preset="cover"
:alt="post.title"
/>
<!-- 关键图片优先加载 -->
<NuxtImg
src="/images/logo.svg"
:loading="'eager'"
fetchpriority="high"
/>
</template>
LCP 优化
确保最大内容绘制元素快速加载:
<template>
<header>
<!-- LCP 元素:优先加载 -->
<NuxtImg
:src="heroImage"
alt="Hero"
:loading="'eager'"
fetchpriority="high"
:preload="true"
/>
</header>
</template>
<script setup>
// 预加载关键资源
useHead({
link: [
{
rel: 'preload',
href: '/images/hero.webp',
as: 'image',
type: 'image/webp'
},
{
rel: 'preload',
href: '/fonts/main.woff2',
as: 'font',
type: 'font/woff2',
crossorigin: 'anonymous'
}
]
})
</script>
字体优化
字体加载策略
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
link: [
// 预连接字体服务
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
// 异步加载字体
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
media: 'print',
onload: "this.media='all'"
}
]
}
}
})
本地字体优化
/* assets/css/fonts.css */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap; /* 关键:避免 FOIT */
}
/* 字体子集化:只包含中文常用字符 */
@font-face {
font-family: 'ChineseFont';
src: url('/fonts/chinese-subset.woff2') format('woff2');
font-weight: 400;
unicode-range: U+4E00-9FFF; /* CJK 基本区 */
font-display: swap;
}
CLS 优化
预留空间
<template>
<!-- 为图片预留空间 -->
<div class="aspect-video relative">
<NuxtImg
:src="image"
class="absolute inset-0 w-full h-full object-cover"
loading="lazy"
/>
</div>
<!-- 为动态内容预留空间 -->
<div class="min-h-[400px]">
<ClientOnly>
<AdBanner />
<template #fallback>
<div class="h-[400px] bg-gray-100 animate-pulse" />
</template>
</ClientOnly>
</div>
</template>
骨架屏
<!-- components/PostListSkeleton.vue -->
<template>
<div class="space-y-4">
<div
v-for="i in 5"
:key="i"
class="flex gap-4 animate-pulse"
>
<div class="w-24 h-24 bg-gray-200 rounded" />
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-200 rounded w-3/4" />
<div class="h-4 bg-gray-200 rounded w-1/2" />
<div class="h-4 bg-gray-200 rounded w-1/4" />
</div>
</div>
</div>
</template>
<!-- pages/posts/index.vue -->
<template>
<div>
<PostListSkeleton v-if="pending" />
<PostList v-else :posts="posts" />
</div>
</template>
<script setup>
const { data: posts, pending } = await useFetch('/api/posts', {
lazy: true // 客户端延迟加载
})
</script>
网络优化
HTTP/2 Push
// server/middleware/push.ts
export default defineEventHandler((event) => {
const path = event.path
// 为首页推送关键资源
if (path === '/') {
setHeader(event, 'Link', [
'</css/critical.css>; rel=preload; as=style',
'</js/app.js>; rel=preload; as=script'
].join(', '))
}
})
缓存策略
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// 静态资源长期缓存
'/_nuxt/**': {
headers: {
'Cache-Control': 'public, max-age=31536000, immutable'
}
},
// HTML 页面短期缓存
'/**': {
headers: {
'Cache-Control': 'public, max-age=300, stale-while-revalidate=600'
}
},
// API 按需缓存
'/api/products': {
cache: {
maxAge: 60,
staleMaxAge: 300
}
}
}
})
监控与分析
性能指标采集
// plugins/performance.client.ts
export default defineNuxtPlugin(() => {
if (typeof window === 'undefined') return
// Core Web Vitals
const reportWebVitals = (metric) => {
console.log(metric)
// 发送到分析服务
fetch('/api/analytics/vitals', {
method: 'POST',
body: JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
page: window.location.pathname
}),
keepalive: true
})
}
// 使用 web-vitals 库
import('web-vitals').then(({ onCLS, onFID, onLCP, onFCP, onTTFB }) => {
onCLS(reportWebVitals)
onFID(reportWebVitals)
onLCP(reportWebVitals)
onFCP(reportWebVitals)
onTTFB(reportWebVitals)
})
})
性能预算
// 构建时检查性能预算
// scripts/check-bundle-size.js
import { readFileSync, readdirSync } from 'fs'
import { join } from 'path'
const BUDGET = {
total: 500 * 1024, // 500KB
'vue-vendor': 100 * 1024, // 100KB
'ui-vendor': 150 * 1024, // 150KB
}
const distDir = '.output/public/_nuxt'
const files = readdirSync(distDir)
let totalSize = 0
const violations = []
files.forEach(file => {
if (file.endsWith('.js')) {
const size = readFileSync(join(distDir, file)).length
totalSize += size
// 检查单个 chunk 预算
Object.entries(BUDGET).forEach(([name, budget]) => {
if (file.includes(name) && size > budget) {
violations.push(`${file}: ${(size/1024).toFixed(2)}KB > ${budget/1024}KB`)
}
})
}
})
if (totalSize > BUDGET.total) {
violations.push(`Total: ${(totalSize/1024).toFixed(2)}KB > ${BUDGET.total/1024}KB`)
}
if (violations.length > 0) {
console.error('性能预算超标:')
violations.forEach(v => console.error(` - ${v}`))
process.exit(1)
}
console.log(`✅ 构建体积: ${(totalSize/1024).toFixed(2)}KB`)
优化成果
通过以上优化,最终达成目标:
| 指标 | 优化前 | 优化后 | 改善幅度 |
|---|---|---|---|
| LCP | 4.2s | 1.8s | -57% |
| FID | 180ms | 45ms | -75% |
| CLS | 0.25 | 0.05 | -80% |
| 首屏时间 | 3.8s | 1.2s | -68% |
| 构建体积 | 2.1MB | 380KB | -82% |
最佳实践清单
| 类别 | 优化措施 | 优先级 |
|---|---|---|
| 构建 | Tree Shaking + 按需引入 | 高 |
| 构建 | 合理的代码分割策略 | 高 |
| 渲染 | 根据页面特性选择渲染模式 | 高 |
| 渲染 | 预渲染静态页面 | 高 |
| 数据 | 并行数据获取 | 中 |
| 数据 | 智能缓存策略 | 中 |
| 图片 | WebP + 响应式 + 懒加载 | 高 |
| 字体 | font-display: swap | 高 |
| CLS | 预留空间 + 骨架屏 | 高 |
| 网络 | 合理的缓存头配置 | 中 |
总结
Nuxt 3 性能优化需要系统性思考,从构建、渲染、数据、资源多个维度入手。核心原则:
- 减少体积 - Tree Shaking、代码分割、压缩
- 加速渲染 - 选择合适的渲染模式、预渲染
- 优化资源 - 图片压缩、字体子集化、预加载
- 持续监控 - 采集指标、设置预算、CI/CD 集成
通过持续优化,可以显著提升用户体验和 SEO 表现。


