Nuxt 路由、渲染与项目结构 / Nuxt Routing, Rendering, and Project Structure
📅 创建时间:2026-07-28 🏷️ 标签:#Nuxt #Routing #SSR #Nitro #Layers #Middleware 📚 前置知识:[[./03-router-forms-and-component-architecture]] 📚 后续知识:[[./06-nuxt-data-server-cache]]
📋 本章目标
- 掌握 Nuxt 4 文件路由约定的完整语法(动态路由、嵌套路由、路由分组、catch-all)
- 理解
routeRules配置对路由行为的精确控制(重定向、ISR、代理等) - 理解
server/目录结构以及 Nitro 引擎的跨平台部署抽象 - 掌握 Nitro 缓存层(
cachedEventHandler、SWR)的配置与适用场景 - 建立
useFetch、useAsyncData、$fetch的场景决策树 - 理解 SSR、SSG、CSR、SWR 四种渲染模式的适用场景与 routeRules 配置
- 掌握 Nuxt Layers 的概念、引入方式与多项目复用场景
- 理解路由中间件的分类、执行顺序与典型应用(认证守卫、重定向)
第1部分:Nuxt 4 文件路由约定
1.1 pages/ 目录即路由表
Nuxt 基于文件系统自动生成 Vue Router 配置。pages/ 目录下的每一个 .vue 文件都对应一个路由——不需要手写路由配置。这是 Nuxt 最核心的"约定优于配置"设计。
┌─────────────────────────────────────────────────────────────┐
│ Nuxt 4 文件路由约定全景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ pages/ │
│ ├── index.vue → / │
│ ├── about.vue → /about │
│ ├── projects/ │
│ │ ├── index.vue → /projects │
│ │ ├── [id].vue → /projects/:id │
│ │ ├── [id]/ │
│ │ │ └── settings.vue → /projects/:id/settings │
│ │ └── new.vue → /projects/new │
│ ├── blog/ │
│ │ ├── [category]/ │
│ │ │ └── [slug].vue → /blog/:category/:slug │
│ │ └── [...slug].vue → /blog/* (catch-all) │
│ ├── (auth)/ │
│ │ ├── login.vue → /login (group stripped) │
│ │ └── register.vue → /register │
│ └── (dashboard)/ │
│ ├── analytics.vue → /analytics │
│ └── settings.vue → /settings │
│ │
│ 核心原则:目录结构 = 路由结构,文件名 = 路由参数 │
│ │
└─────────────────────────────────────────────────────────────┘1.2 动态路由:[param].vue 与 [...slug].vue
动态路由用方括号包裹参数名。参数值通过 useRoute().params 获取:
<!-- pages/projects/[id].vue -->
<script setup lang="ts">
const route = useRoute()
// route.params.id 的类型是 string | string[]
// 访问 /projects/42 → route.params.id === '42'
const projectId = computed(() => String(route.params.id))
</script>
<template>
<div>
<h1>Project {{ projectId }}</h1>
</div>
</template>可选参数使用双括号 [[param]]:
pages/
├── [lang]/ # 必须有 lang 段
│ └── index.vue
└── [[lang]]/ # lang 段可选,/ 或 /zh 都匹配
└── index.vueCatch-all 路由使用 [...slug].vue 匹配任意深度的路径段:
<!-- pages/docs/[...slug].vue -->
<script setup lang="ts">
const route = useRoute()
// 访问 /docs/guide/nuxt/routing
// route.params.slug → ['guide', 'nuxt', 'routing']
</script>1.3 路由分组:(group)/ 目录
括号包裹的目录名不会出现在 URL 中,仅用于组织文件:
┌─────────────────────────────────────────────────────────────┐
│ 路由分组的工作原理 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 文件结构 → 生成的 URL │
│ ───────────────────────────────────────── │
│ pages/ │
│ ├── (public)/ │
│ │ ├── index.vue → / │
│ │ ├── about.vue → /about │
│ │ └── blog/ │
│ │ └── [slug].vue → /blog/:slug │
│ ├── (auth)/ │
│ │ ├── login.vue → /login │
│ │ └── register.vue → /register │
│ └── (dashboard)/ │
│ ├── analytics.vue → /analytics │
│ └── users/ │
│ └── [id].vue → /users/:id │
│ │
│ 每个分组可以拥有独立的 layout、middleware │
│ 同组页面共享分组级的 app.vue 或 layout 配置 │
│ │
└─────────────────────────────────────────────────────────────┘路由分组的典型用途:
- 布局隔离:
(public)组使用营销布局,(dashboard)组使用后台布局 - 中间件作用域:
(auth)组统一挂载认证中间件 - 代码组织:大型项目按领域拆分 pages 子目录
1.4 routeRules:声明式路由行为配置
Nuxt 4 通过 nuxt.config.ts 中的 routeRules 对单个路由或路由模式进行精细化控制:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// 直接指定路由路径
'/': { prerender: true },
'/blog/**': { swr: 3600 },
'/admin/**': { ssr: false },
// 使用路由名称匹配(推荐,重构安全)
'/projects/[id]': {
ssr: true,
cache: { maxAge: 60 }
},
// 重定向
'/old-docs/**': { redirect: '/docs/**' },
// 代理到外部服务
'/api/external/**': { proxy: 'https://api.external.com/**' },
// ISR (Incremental Static Regeneration)
'/products/**': { isr: 600 },
}
})routeRules 支持的完整配置项:
┌─────────────────────────────────────────────────────────────┐
│ routeRules 配置项一览 │
├─────────────────────────────────────────────────────────────┤
│ │
│ │ 配置项 │ 类型 │ 作用 │
│ ├─────────────┼────────────────────┼─────────────────────┤
│ │ redirect │ string | object │ 服务端/客户端重定向 │
│ │ ssr │ boolean │ 是否启用服务端渲染 │
│ │ prerender │ boolean | object │ 构建时预渲染为静态 │
│ │ swr │ number | boolean │ 过期后后台重新验证 │
│ │ isr │ number | boolean │ 增量静态再生成 │
│ │ cache │ object │ 缓存控制(CDN/浏览器) │
│ │ cors │ boolean │ 自动添加 CORS 头 │
│ │ headers │ object │ 自定义响应头 │
│ │ proxy │ string | object │ 代理到后端服务 │
│ │ experimental │ object │ 实验性功能 │
│ │
└─────────────────────────────────────────────────────────────┘1.5 嵌套路由与 <NuxtPage>
当 pages/ 下的目录包含 index.vue 和其他子页面时,父级目录需要一个布局文件来渲染子路由:
<!-- pages/projects/index.vue -->
<template>
<div>
<h2>项目列表</h2>
<NuxtLink to="/projects/1">项目 #1</NuxtLink>
</div>
</template>嵌套路由的另一种方式是通过 <NuxtPage> 实现父-子布局:
<!-- pages/parent.vue -->
<template>
<div class="parent-layout">
<h1>父级页面</h1>
<!-- 子路由内容在此渲染 -->
<NuxtPage />
</div>
</template>对应的文件结构:
pages/
├── parent.vue # 使用 <NuxtPage> 渲染子路由
└── parent/
└── child.vue # /parent/child → 渲染在 parent.vue 的 <NuxtPage> 中第2部分:server/ 目录与 Nitro 引擎
2.1 Nitro 的架构定位
Nitro 是 Nuxt 的服务端引擎,负责将 server/ 目录下的代码编译为可部署到多种平台的通用服务端 bundle:
┌─────────────────────────────────────────────────────────────┐
│ Nitro 引擎架构全景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ server/ 目录 │ │
│ │ server/api/ → /api/* HTTP 端点 │ │
│ │ server/routes/ → 非 /api 前缀的 HTTP 端点 │ │
│ │ server/middleware/ → 请求级中间件 │ │
│ │ server/utils/ → 服务端工具函数 │ │
│ │ server/plugins/ → Nitro 插件(生命周期钩子) │ │
│ └───────────────────────┬─────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Nitro 核心 │ │
│ │ • h3 事件系统(defineEventHandler) │ │
│ │ • 跨平台预设(presets)——自动适配部署目标 │ │
│ │ • 文件系统路由(基于 server/api/ 和 server/routes/)│ │
│ │ • 缓存层(cachedEventHandler / SWR) │ │
│ │ • 存储层(unstorage —— KV / FS / Redis 等驱动) │ │
│ │ • 任务调度(cron / scheduled tasks) │ │
│ └───────────────────────┬─────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 跨平台部署预设(Presets) │ │
│ │ │ │
│ │ cloudflare-pages cloudflare-module-preset │ │
│ │ vercel vercel-edge │ │
│ │ netlify netlify-edge │ │
│ │ node-server aws-lambda │ │
│ │ deno-server bun │ │
│ │ ... (30+ presets) │ │
│ │ │ │
│ │ → Nitro 自动将 server/ 代码转译为平台适配格式 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2.2 Server Routes:server/api/ 与 server/routes/
Server API 路由定义在 server/api/ 下,自动挂载到 /api/* 路径。Nitro 使用 h3 的事件处理模型:
// server/api/projects/[id].get.ts
export default defineEventHandler(async (event) => {
// getRouterParam 安全提取路由参数
const id = getRouterParam(event, 'id')
// readBody 解析请求体(POST/PUT/PATCH)
// getQuery 读取 query 参数
// getHeader / setHeader 操作请求/响应头
if (!id) {
throw createError({
statusCode: 400,
statusMessage: '缺少项目 ID',
})
}
const project = await db.project.findUnique({ where: { id } })
if (!project) {
throw createError({ statusCode: 404 })
}
return project
})HTTP 方法约定:
[name].get.ts→ 处理 GET 请求[name].post.ts→ 处理 POST 请求[name].put.ts→ 处理 PUT 请求[name].delete.ts→ 处理 DELETE 请求[name].patch.ts→ 处理 PATCH 请求[name].ts→ 处理所有 HTTP 方法
server/routes/ 与 server/api/ 机制相同,但不带 /api 前缀:
// server/routes/health.ts → GET /health
export default defineEventHandler(() => {
return { status: 'ok', timestamp: Date.now() }
})
// server/routes/sitemap.xml.ts → GET /sitemap.xml
export default defineEventHandler(async (event) => {
setHeader(event, 'content-type', 'application/xml')
const urls = await getSitemapUrls()
return generateSitemapXml(urls)
})2.3 Server Middleware:请求级中间件
Server middleware 在每次 HTTP 请求到达 Server Route 之前执行,用于跨切面的请求处理:
// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
// 仅对 /api/ 开头的请求执行认证
if (!event.path.startsWith('/api/')) return
const token = getHeader(event, 'authorization')?.replace('Bearer ', '')
if (token) {
try {
const user = await verifyToken(token)
// 将用户信息注入事件上下文
event.context.user = user
} catch {
// token 无效时不阻断请求——交给具体 handler 决定
}
}
})// server/middleware/rate-limit.ts
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
export default defineEventHandler((event) => {
const ip = getRequestIP(event) || 'unknown'
const now = Date.now()
const record = rateLimitMap.get(ip)
if (!record || now > record.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
return
}
if (record.count > 100) {
throw createError({ statusCode: 429, statusMessage: '请求过于频繁' })
}
record.count++
})Server middleware 的执行顺序:按照 server/middleware/ 目录下文件名的字母顺序执行。可以通过数字前缀控制顺序(01.auth.ts、02.rate-limit.ts、03.logging.ts)。
2.4 Nitro 缓存层:cachedEventHandler 与 SWR
Nitro 内置了强大的缓存原语,可以在 handler 级别配置缓存策略:
// server/api/stats.get.ts
import { cachedEventHandler } from '#internal/nitro'
export default cachedEventHandler(
async (event) => {
// 耗时计算——如聚合查询
const stats = await computeDashboardStats()
return stats
},
{
// 缓存键:基于请求的唯一标识
getKey: (event) => `dashboard:stats`,
// 缓存有效期(秒)
maxAge: 300, // 5 分钟
// 可选:基于响应状态码决定是否缓存
shouldCache: (event, response) => {
return response?.status === 200
},
// SWR:过期后先返回旧缓存,后台重新验证
swr: true, // 或 swr: 3600(SWR 窗口期 1 小时)
}
)SWR(Stale-While-Revalidate) 模式的核心行为:
┌─────────────────────────────────────────────────────────────┐
│ SWR 缓存策略时序 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 时间轴: │
│ │
│ T+0 首次请求 → 缓存 MISS → 执行 handler → 写入缓存 │
│ T+60 缓存 HIT → 直接返回(< maxAge,新鲜) │
│ T+300 缓存 STALE → 但仍在 SWR 窗口内 │
│ → 立即返回旧缓存 + 后台异步执行 handler 刷新 │
│ T+301 后续请求 → 返回 T+300 刷新后的新缓存 │
│ T+3600 SWR 窗口过期 → 缓存 MISS → 同步执行 handler │
│ │
│ 关键优势:用户永远不会因为缓存过期而等待 handler 执行 │
│ 代价:可能短暂返回过期数据(最终一致性) │
│ │
└─────────────────────────────────────────────────────────────┘缓存适用场景判断:
- 数据对实时性要求不高(排行榜、统计数据、配置信息)
- 计算成本高但结果相对稳定
- 外部 API 调用有速率限制或延迟高
- 不适合:用户专属数据、实时交易、认证状态
第3部分:useFetch vs useAsyncData vs $fetch
3.1 三条数据路径的定位
Nuxt 提供了三种数据获取方式,它们的运行环境和数据流各不相同:
┌─────────────────────────────────────────────────────────────┐
│ useFetch / useAsyncData / $fetch 决策树 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────── 需要获取数据 ────────────────────┐ │
│ │ │ │
│ │ 发生在什么时候? │ │
│ │ ├── 页面加载时(SSR 阶段) │ │
│ │ │ ├── 请求 /api/* 接口? │ │
│ │ │ │ └── → useFetch() 【首选】 │ │
│ │ │ └── 非 API 数据(组合多源、ORM 调用、复杂逻辑) │ │
│ │ │ └── → useAsyncData() 【首选】 │ │
│ │ │ │ │
│ │ └── 用户交互时(按钮点击、表单提交、滚动加载) │ │
│ │ ├── 需要更新 SSR payload 缓存? │ │
│ │ │ └── → useFetch() 或 useAsyncData() │ │
│ │ └── 纯客户端操作、不需要缓存去重? │ │
│ │ └── → $fetch() 【首选】 │ │
│ │ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 核心区分: │
│ • useFetch/useAsyncData:服务端执行一次 → payload → 客户端 │
│ 水合复用,避免重复请求 │
│ • $fetch:每次调用都执行,不做 SSR payload 去重 │
│ │
└─────────────────────────────────────────────────────────────┘3.2 useFetch:页面数据的首选
useFetch 封装了 $fetch,自动处理 SSR payload 传递——服务器渲染时将结果序列化到 window.__NUXT__,客户端水合时直接复用,不发起重复请求:
<script setup lang="ts">
// 基础用法:URL 字符串
const { data, pending, error, refresh } = await useFetch('/api/projects')
// 响应式 URL:参数变化时自动重新请求
const route = useRoute()
const { data: project } = await useFetch(
() => `/api/projects/${route.params.id}`,
{
// 请求选项
method: 'GET',
query: { include: 'members' },
// 响应转换
transform: (response) => {
return response.projects.map(normalizeProject)
},
// 缓存 key —— 决定数据如何在 payload 中存储与复用
key: () => `project-${route.params.id}`,
// 只在服务端执行
server: true,
// 懒加载:先渲染页面,pending 状态下显示骨架屏
lazy: true,
// 只在水合后从客户端请求
// server: false,
}
)
</script>
<template>
<div>
<div v-if="pending">加载中...</div>
<div v-else-if="error">加载失败:{{ error.message }}</div>
<div v-else>
<pre>{{ data }}</pre>
</div>
</div>
</template>lazy: true vs 默认 await:
<script setup lang="ts">
// 默认行为:await —— 阻塞导航直到数据就绪
// 适合:数据是页面核心内容,没有数据展示空页面无意义
const { data } = await useFetch('/api/dashboard')
// lazy: true —— 不阻塞导航,先展示 pending UI
// 适合:非核心数据、可能较慢的请求、希望快速呈现页面骨架
const { data: recommendations, pending } = useFetch('/api/recommendations', {
lazy: true
})
</script>3.3 useAsyncData:自定义逻辑的通用容器
当数据源不是标准的 /api/* 端点时,useAsyncData 是更灵活的选择:
// 场景一:组合多个数据源
const { data } = await useAsyncData('user-context', async () => {
const [profile, permissions, settings] = await Promise.all([
$fetch('/api/users/me'),
$fetch('/api/users/me/permissions'),
$fetch('/api/users/me/settings'),
])
return { profile, permissions, settings }
})
// 场景二:调用 ORM / 数据库查询(server/ 层)
const { data: posts } = await useAsyncData(
() => `posts-${page.value}`,
() => db.post.findMany({
where: { published: true },
skip: (page.value - 1) * 10,
take: 10,
})
)
// 场景三:带复杂转换逻辑的数据处理
const { data: report } = await useAsyncData('monthly-report', async () => {
const raw = await $fetch('/api/reports/monthly')
return {
...raw,
// 日期转换
periodStart: new Date(raw.periodStart),
// 数值格式化
revenue: formatCurrency(raw.revenue),
// 排序
topItems: raw.items.sort((a, b) => b.value - a.value).slice(0, 10),
}
})key 的重要性:key 用于在 Nuxt payload 中唯一标识这份数据。如果 key 不稳定或缺少区分参数,会导致错误的数据共享:
// 错误:所有页面共享同一个 key,导致数据错乱
const { data } = await useAsyncData('project', () =>
$fetch(`/api/projects/${route.params.id}`)
)
// 正确:key 包含区分参数
const { data } = await useAsyncData(
() => `project:${route.params.id}`,
() => $fetch(`/api/projects/${route.params.id}`)
)3.4 $fetch:纯客户端操作与事件处理
$fetch 不做任何 SSR payload 缓存,每次调用都发起真实的 HTTP 请求。适合不需要服务端去重的场景:
// 场景一:用户交互触发的 mutation
async function saveProject() {
try {
const result = await $fetch('/api/projects', {
method: 'POST',
body: formData.value,
})
await navigateTo(`/projects/${result.id}`)
} catch (err) {
showError(err)
}
}
// 场景二:Server API 内调用外部服务
// server/api/weather/[city].get.ts
export default defineEventHandler(async (event) => {
const city = getRouterParam(event, 'city')
return $fetch(`https://api.weather.com/v1/${city}`, {
headers: { Authorization: `Bearer ${useRuntimeConfig().weatherKey}` }
})
})
// 场景三:纯客户端请求(server: false 标记)
// 明确说明该数据只在浏览器端可用
const { data: clientData } = await useFetch('/api/device-info', {
server: false // 跳过 SSR 阶段
})3.5 数据 API 对比总结
┌─────────────────────────────────────────────────────────────┐
│ 三种数据 API 的行为对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ │ 特性 │ useFetch │ useAsyncData │ $fetch │
│ ├──────────────────┼────────────┼─────────────┼──────────┤
│ │ SSR payload 去重 │ 自动 │ 自动 │ 无 │
│ │ 阻塞导航(await) │ 支持 │ 支持 │ - │
│ │ 懒加载(lazy) │ 支持 │ 支持 │ - │
│ │ 响应式 URL 重请求 │ 支持 │ 手动实现 │ - │
│ │ 自定义转换逻辑 │ transform │ 函数体内 │ 调用方 │
│ │ 类型推断 │ 需泛型 │ 自动推断 │ 自动推断 │
│ │ 缓存管理 │ key 控制 │ key 控制 │ 无 │
│ │ 适用数据源 │ /api/* URL │ 任意异步函数 │ 任意 URL │
│ │ 服务端调用 │ SSR 阶段 │ SSR 阶段 │ 任意位置 │
│ │ 客户端调用 │ 水合后按需 │ 水合后按需 │ 任意位置 │
│ │
└─────────────────────────────────────────────────────────────┘第4部分:SSR / SSG / CSR / SWR 混合渲染
4.1 四种渲染模式的本质差异
Nuxt 4 支持在同一个应用中混合使用多种渲染策略。每个路由可以独立配置其渲染行为。
┌─────────────────────────────────────────────────────────────┐
│ 四种渲染模式运行时对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌── SSR (Server-Side Rendering) ─────────────────────────┐ │
│ │ 每次请求 → Nitro 执行组件 → 生成 HTML → 返回浏览器 │ │
│ │ 数据新鲜度:实时 服务器负载:高 │ │
│ │ 适用:个性化内容、实时数据、需要 SEO 的动态页面 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌── SSG / Prerender (Static Site Generation) ────────────┐ │
│ │ 构建时 → 执行组件 → 生成 HTML 文件 → 部署为静态文件 │ │
│ │ 数据新鲜度:构建时 服务器负载:零(纯静态托管) │ │
│ │ 适用:文档、博客、营销页等内容不频繁变化的页面 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌── CSR (Client-Side Rendering) ─────────────────────────┐ │
│ │ 服务器返回空壳 HTML → 浏览器下载 JS → 客户端渲染 │ │
│ │ 数据新鲜度:实时 服务器负载:极低(仅静态文件) │ │
│ │ 适用:登录后的 dashboard、不需要 SEO 的交互工具 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌── SWR / ISR (Stale-While-Revalidate / Incremental) ────┐ │
│ │ 首次请求 → SSR 生成 HTML + 缓存 │ │
│ │ 后续请求 → 返回缓存 HTML(同时后台刷新) │ │
│ │ 数据新鲜度:准实时 服务器负载:低(缓存命中率高) │ │
│ │ 适用:产品列表、文章内容等"可略微过时"的页面 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘4.2 routeRules 配置每种模式
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// ========== SSR(默认)==========
// 不配置 routeRules 即为 SSR,或显式设置
'/dashboard/**': { ssr: true },
// ========== SSG / Prerender ==========
'/': { prerender: true },
'/about': { prerender: true },
// 批量预渲染:为动态路由的每个已知参数值生成静态页面
'/blog/[slug]': {
prerender: {
// 从数据源获取所有可能的 slug
getStaticPaths: async () => {
const posts = await $fetch('/api/posts')
return posts.map(p => ({ params: { slug: p.slug } }))
}
}
},
// ========== CSR(纯客户端渲染)==========
'/admin/**': { ssr: false },
// ========== SWR(过期后后台刷新)==========
// swr: 3600 → 缓存 1 小时后"软化",后续请求先返回旧缓存再后台刷新
'/products/**': { swr: 3600 },
// swr: true → 始终先返回缓存,后台刷新(缓存永不过期)
'/api/stats': { swr: true },
// ========== ISR(增量静态再生成)==========
// 类似 SWR,但生成的 HTML 会持久化到 CDN/存储层
'/blog/**': { isr: 1800 }, // 30 分钟后重新生成
// ========== 带过期时间的缓存 ==========
// 适合 CDN 边缘缓存
'/assets/**': {
headers: { 'Cache-Control': 'public, max-age=31536000, immutable' }
},
'/api/posts': {
cache: { maxAge: 60 } // CDN/浏览器缓存 60 秒
},
}
})4.3 混合渲染策略设计
实际项目中,应根据每个路由的数据特性和访问模式选择渲染策略:
┌─────────────────────────────────────────────────────────────┐
│ 典型的混合渲染项目配置 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 路由 │ 策略 │ 理由 │
│ ─────────────────────┼──────────┼───────────────────────── │
│ / (首页) │ prerender │ 内容稳定,访问量最大 │
│ /about /pricing │ prerender │ 静态营销页面 │
│ /blog/* │ ISR 600 │ 偶尔更新,SEO 重要 │
│ /blog (列表页) │ SWR 300 │ 聚合页,可略微过时 │
│ /products/* │ SWR 3600 │ 商品详情,更新频率低 │
│ /search │ SSR │ 实时搜索结果 │
│ /dashboard/* │ SSR │ 个性化数据,必须实时 │
│ /admin/* │ CSR │ 认证后使用,不需要 SEO │
│ │
│ 关键原则: │
│ 1. 越靠前的页面(首页、列表)越倾向预渲染 / 缓存 │
│ 2. 越个性化的页面(dashboard、设置)越倾向 SSR / CSR │
│ 3. 公共内容永远优先考虑缓存,私有内容永远跳过缓存 │
│ │
└─────────────────────────────────────────────────────────────┘4.4 客户端渲染的降级策略
当 SSR 失败时(第三方 API 超时、服务端错误),需要优雅降级到客户端渲染:
<script setup lang="ts">
// 方案一:使用 NuxtErrorBoundary 隔离错误
// 在 layout 或 app.vue 中包裹
</script>
<template>
<NuxtErrorBoundary>
<!-- SSR 错误时显示 fallback,不影响其他区域 -->
<ProductDetail :product-id="productId" />
<template #error="{ error, clearError }">
<div class="error-fallback">
<p>加载失败:{{ error?.message }}</p>
<button @click="clearError">重试</button>
</div>
</template>
</NuxtErrorBoundary>
</template><script setup lang="ts">
// 方案二:ClientOnly 组件包裹浏览器专属内容
</script>
<template>
<div>
<!-- 服务端渲染占位符 -->
<ClientOnly fallback-tag="div">
<div class="skeleton">加载中...</div>
<template #fallback>
<!-- 客户端水合后才渲染真实组件 -->
<HeavyChart :data="chartData" />
</template>
</ClientOnly>
</div>
</template>// 方案三:在 setup 中处理 SSR 失败
const { data, error } = await useFetch('/api/recommendations', {
// SSR 请求超时
timeout: 3000,
// 失败时在客户端重试
onResponseError({ response }) {
if (process.server) {
// 服务端静默失败,交给客户端重试
console.warn('SSR 数据获取失败,将降级为客户端渲染')
}
}
})
// 客户端重试逻辑:如果 SSR 阶段数据为空,客户端自动重试
if (!data.value && process.client) {
await refresh()
}第5部分:Nuxt Layers
5.1 Layer 的概念与设计意图
Nuxt Layer 是一种可复用的 Nuxt 项目配置单元。一个 Layer 可以包含 pages、components、composables、server API、plugins、middleware 等一切 Nuxt 项目可以拥有的内容。
┌─────────────────────────────────────────────────────────────┐
│ Nuxt Layer 概念模型 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Layer 可以包含什么? │ │
│ │ │ │
│ │ app/ pages/ server/ │ │
│ │ ├─ app.vue ├─ index.vue ├─ api/ │ │
│ │ ├─ components/ ├─ about.vue ├─ middleware/ │ │
│ │ ├─ composables/ ├─ ... └─ utils/ │ │
│ │ ├─ layouts/ │ │
│ │ ├─ middleware/ public/ nuxt.config.ts │ │
│ │ ├─ plugins/ └─ favicon.ico │ │
│ │ └─ assets/ │ │
│ │ │ │
│ │ 一个 Layer = 一个"迷你 Nuxt 项目" │ │
│ │ 可以被其他 Nuxt 项目引入并合并 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌── 主项目 ──────────────────────────────────────────────┐ │
│ │ │ │
│ │ extends: [layerA, layerB, layerC] │ │
│ │ │ │
│ │ 合并规则: │ │
│ │ • 组件/Composable/Plugin → 全部可用(同名时主项目覆盖)│ │
│ │ • pages/ → 合并(冲突时构建报错) │ │
│ │ • nuxt.config.ts → 深度合并 │ │
│ │ • server/ → 合并所有 handler │ │
│ │ │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘5.2 Layer 的引入方式
方式一:本地相对路径
// nuxt.config.ts
export default defineNuxtConfig({
extends: [
// 本地 layer(同仓库内)
'./layers/base',
'./layers/theme-corporate',
]
})方式二:npm 包
// nuxt.config.ts
export default defineNuxtConfig({
extends: [
// 通过 npm 安装的 layer 包
'@my-org/nuxt-layer-base',
'nuxt-layer-dashboard',
]
})npm 包形式的 layer,其 package.json 不需要特殊配置,只需将 Nuxt 文件放在包根目录即可。使用方可按需选择引入哪些功能。
方式三:Git 仓库 / Git Submodule
// nuxt.config.ts
export default defineNuxtConfig({
extends: [
// GitHub 仓库(通过 giget 下载)
'github:my-org/nuxt-layer-base',
// GitLab 仓库
'gitlab:my-org/nuxt-layer-theme',
]
})# 或使用 git submodule 获得更好的版本锁定
git submodule add https://github.com/my-org/nuxt-layer-base.git layers/base5.3 Layer 的典型应用场景
场景一:多项目共享基础配置(白标应用)
┌─────────────────────────────────────────────────────────────┐
│ 白标应用:Layer 实现多品牌复用 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌── Layer: base ────────────────────────────────────────┐ │
│ │ 共享的 pages 结构、composables、server API │ │
│ │ components/ (基础 UI 组件库) │ │
│ │ server/api/ (通用业务 API) │ │
│ │ middleware/ (统一的认证逻辑) │ │
│ └────────────┬───────────────┬──────────────────────────┘ │
│ │ │ │
│ ┌────────▼────┐ ┌───────▼────────┐ │
│ │ Layer: 品牌A │ │ Layer: 品牌B │ │
│ │ 主题/颜色 │ │ 主题/颜色 │ │
│ │ 品牌组件 │ │ 品牌组件 │ │
│ │ 内容定制 │ │ 内容定制 │ │
│ └──────┬───────┘ └───────┬────────┘ │
│ │ │ │
│ ┌───────▼──────┐ ┌───────▼────────┐ │
│ │ Project: A │ │ Project: B │ │
│ │ extends: │ │ extends: │ │
│ │ [base, 品牌A]│ │ [base, 品牌B] │ │
│ └──────────────┘ └────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘示例配置:
// layers/base/nuxt.config.ts
export default defineNuxtConfig({
// 基础配置:所有品牌共享
modules: ['@nuxtjs/i18n', '@pinia/nuxt'],
css: ['~/assets/css/base.css'],
runtimeConfig: {
public: {
apiBase: process.env.API_BASE_URL,
}
},
app: {
head: {
htmlAttrs: { lang: 'zh-CN' },
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
]
}
}
})// layers/theme-brand-a/nuxt.config.ts
export default defineNuxtConfig({
// 品牌 A 的覆盖配置
css: ['~/assets/css/brand-a-theme.css'],
app: {
head: {
title: 'Brand A Platform',
link: [{ rel: 'icon', href: '/brand-a-favicon.ico' }]
}
}
})// project-a/nuxt.config.ts
export default defineNuxtConfig({
extends: [
'../layers/base',
'../layers/theme-brand-a',
]
})场景二:共享 UI 组件库与设计系统
// layers/design-system/nuxt.config.ts
export default defineNuxtConfig({
components: [
// 自动导入 layer 内所有组件
{ path: '~/components', pathPrefix: false },
],
// 注入全局 CSS 变量
css: ['~/assets/tokens.css'],
})
// layers/design-system/composables/useTheme.ts
export function useTheme() {
const colorMode = useColorMode()
const isDark = computed(() => colorMode.value === 'dark')
function toggleTheme() {
colorMode.preference = isDark.value ? 'light' : 'dark'
}
return { colorMode, isDark, toggleTheme }
}场景三:共享 Server API 与认证逻辑
// layers/auth/server/middleware/session.ts
export default defineEventHandler(async (event) => {
const sessionToken = getCookie(event, 'session')
if (sessionToken) {
const session = await getSession(sessionToken)
if (session) {
event.context.session = session
event.context.user = session.user
}
}
})
// layers/auth/composables/useAuth.ts
export function useAuth() {
const { data: user, refresh } = useFetch('/api/auth/me')
async function login(credentials: { email: string; password: string }) {
await $fetch('/api/auth/login', { method: 'POST', body: credentials })
await refresh()
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
user.value = null
}
const isAuthenticated = computed(() => !!user.value)
return { user, isAuthenticated, login, logout }
}5.4 Layer 优先级与覆盖规则
多个 Layer 按 extends 数组的顺序合并,后面的 Layer 覆盖前面的:
┌─────────────────────────────────────────────────────────────┐
│ Layer 合并优先级 │
├─────────────────────────────────────────────────────────────┤
│ │
│ extends: [layerA, layerB, layerC] │
│ │
│ 优先级(从低到高): │
│ layerA < layerB < layerC < 主项目(nuxt.config.ts) │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 主项目始终拥有最高优先级 │ │
│ │ → 可以覆盖任何 Layer 中的配置、组件、页面 │ │
│ │ │ │
│ │ app.vue:主项目有 app.vue → 使用主项目 │ │
│ │ components:同名组件 → 主项目覆盖 Layer │ │
│ │ composables:同名 composable → 主项目覆盖 Layer │ │
│ │ nuxt.config:深度合并,主项目值优先 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘第6部分:Nuxt 中间件
6.1 路由中间件的分类
Nuxt 的中间件运行在客户端导航和服务端首次渲染时,用于在页面渲染前执行逻辑(认证检查、重定向、分析跟踪等):
┌─────────────────────────────────────────────────────────────┐
│ Nuxt 路由中间件体系 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌── 定义方式 ────────────────────────────────────────────┐ │
│ │ │ │
│ │ middleware/ ← 全局目录 │ │
│ │ ├── auth.ts ← 命名中间件 │ │
│ │ ├── analytics.ts │ │
│ │ └── 01.redirects.global.ts ← 全局中间件(.global) │ │
│ │ │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌── 使用方式 ────────────────────────────────────────────┐ │
│ │ │ │
│ │ 1. definePageMeta({ middleware: 'auth' }) 单个页面 │ │
│ │ 2. definePageMeta({ middleware: ['auth','log'] }) 多个 │ │
│ │ 3. 文件名后缀 .global.ts → 每页自动执行 │ │
│ │ 4. nuxt.config.ts → router.options.middleware 全局注册 │ │
│ │ │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌── 执行时机 ────────────────────────────────────────────┐ │
│ │ │ │
│ │ 客户端导航:在页面组件创建之前执行 │ │
│ │ 服务端 SSR:在渲染 HTML 之前执行 │ │
│ │ │ │
│ │ 流程: │ │
│ │ 导航触发 → 全局中间件 → 命名中间件(按数组顺序) │ │
│ │ → 页面 setup → 页面渲染 │ │
│ │ │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘6.2 命名中间件:按需挂载
命名中间件定义在 middleware/ 目录,使用时在页面的 definePageMeta 中显式引用:
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated } = useAuth()
// 未登录 → 重定向到登录页
if (!isAuthenticated.value && to.path !== '/login') {
// 登录后可以回到目标页面
return navigateTo({
path: '/login',
query: { redirect: to.fullPath }
})
}
// 已登录但访问登录页 → 重定向到首页
if (isAuthenticated.value && to.path === '/login') {
return navigateTo('/')
}
})// middleware/role-check.ts
export default defineNuxtRouteMiddleware(async (to) => {
const { user } = useAuth()
// 异步获取用户角色
const { data: permissions } = await useFetch('/api/users/me/permissions')
const requiredRole = to.meta.requiredRole as string | undefined
if (requiredRole && !permissions.value?.roles?.includes(requiredRole)) {
// abortNavigation 阻止导航但不重定向
return abortNavigation('你没有访问此页面的权限')
}
})页面中使用:
<script setup lang="ts">
definePageMeta({
middleware: ['auth', 'role-check'],
// 页面级别的 meta 信息可被中间件读取
requiredRole: 'admin',
})
</script>中间件组合模式:
// middleware/authenticated.ts
// 将多个中间件逻辑组合为一个
export default defineNuxtRouteMiddleware(async (to, from) => {
// 先执行认证检查
const authMiddleware = await import('./auth')
const authResult = authMiddleware.default(to, from)
if (authResult) return authResult
// 再执行角色检查
const roleMiddleware = await import('./role-check')
return roleMiddleware.default(to, from)
})6.3 全局中间件:每次导航执行
全局中间件的文件名以 .global.ts 结尾,每次路由变化都会执行:
// middleware/01.analytics.global.ts
export default defineNuxtRouteMiddleware((to, from) => {
// 页面浏览埋点
if (process.client) {
// 仅在客户端执行(SSR 阶段不需要)
nextTick(() => {
useAnalytics().trackPageView({
path: to.fullPath,
title: to.meta.title as string || document.title,
referrer: from?.fullPath,
})
})
}
})// middleware/02.redirects.global.ts
const redirects: Record<string, string> = {
'/old-home': '/',
'/v1/docs': '/docs',
'/blog/2023': '/blog',
}
export default defineNuxtRouteMiddleware((to) => {
const target = redirects[to.path]
if (target) {
return navigateTo(target, { redirectCode: 301 })
}
})6.4 中间件的执行顺序与注意事项
执行顺序规则:
- 全局中间件按文件名字母顺序执行(建议用数字前缀如
01.、02.控制) - 命名中间件按
definePageMeta中数组的顺序执行 - 所有中间件执行完毕后才进入页面组件的 setup
关键注意事项:
// 1. 中间件在 SSR 和客户端都会执行——注意环境区分
export default defineNuxtRouteMiddleware(() => {
// 错误:SSR 阶段没有 window
// const width = window.innerWidth
// 正确:区分环境
if (process.client) {
// 只能在客户端执行的逻辑
}
})
// 2. 中间件中的 return 值会终止导航链
// navigateTo() → 重定向
// abortNavigation() → 停止且不重定向
// 无返回值 / undefined → 正常通过
// 3. 避免在中间件中做重量级数据请求
// 这会阻塞所有页面的导航——应放在页面组件的 setup 中6.5 中间件 vs Server Middleware vs 路由守卫的边界
┌─────────────────────────────────────────────────────────────┐
│ 三层"中间件"的职责边界 │
├─────────────────────────────────────────────────────────────┤
│ │
│ │ 层级 │ 执行位置 │ 主要用途 │
│ ├──────────────┼───────────┼─────────────────────────────┤
│ │ 路由中间件 │ 客户端+SSR │ 认证守卫、重定向、页面埋点 │
│ │ (middleware/) │ │ 阻止访问、路由级权限控制 │
│ ├──────────────┼───────────┼─────────────────────────────┤
│ │ Server Mid. │ Nitro 服务端│ 请求认证、速率限制、日志 │
│ │ (server/ │ │ CORS、Header 注入 │
│ │ middleware/) │ │ → 安全边界,不可跳过 │
│ ├──────────────┼───────────┼─────────────────────────────┤
│ │ Vue Router │ 客户端导航 │ beforeResolve、afterEach │
│ │ 守卫 │ (插件注册) │ 细粒度导航控制 │
│ │ │ │ → 路由中间件通常是首选 │
│ │
│ 安全原则:认证和授权必须在 Server Middleware 中再次验证 │
│ 路由中间件改善用户体验(提前跳转),但不能作为安全边界 │
│ │
└─────────────────────────────────────────────────────────────┘核心总结
总结1:文件路由的本质是"目录即路由表"
Nuxt 4 将 pages/ 目录结构直接映射为 Vue Router 配置。理解动态路由([id])、可选参数([[lang]])、catch-all([...slug])和路由分组((group))四种模式,就能覆盖绝大多数路由场景。routeRules 在路由层之上提供了声明式的行为控制(重定向、缓存、代理),不需在页面组件中硬编码这些逻辑。
总结2:Nitro 让服务端代码"写一次,处处部署"
server/api/ 和 server/routes/ 定义 HTTP 端点,server/middleware/ 处理请求级切面。Nitro 通过预设(presets)自动适配 30+ 部署平台——相同的 defineEventHandler 代码可以不加修改地部署到 Node.js、Cloudflare Workers、Vercel、AWS Lambda 等。cachedEventHandler 和 SWR 在 handler 层提供了与部署平台无关的缓存能力。
总结3:三种数据 API 各司其职
useFetch 用于请求 /api/* 接口的页面数据,自动处理 SSR payload 去重。useAsyncData 是通用容器,适合组合多数据源、调用 ORM、执行复杂转换。$fetch 不做任何缓存去重,适合用户事件触发的 mutation 和 Server API 内部调用。选择的关键不是"哪个更好",而是"数据在什么时候、从哪一端获取"。
总结4:混合渲染是 Nuxt 的核心竞争力
同一应用内,首页和文档预渲染为静态文件,产品列表使用 SWR 缓存,dashboard 实时 SSR,后台管理纯 CSR——每个路由独立选择最适合的渲染策略。routeRules 的 prerender、swr、isr、ssr: false 四种配置覆盖了从纯静态到纯动态的完整光谱。
总结5:Layers 实现"不重复造轮子"的工程化
Layer 是"可复用的 Nuxt 项目片段"——包含 pages、components、composables、server 等一切 Nuxt 资源。通过 Git submodule、npm 包或本地路径引入,Layer 的配置、组件、页面会与主项目合并。多品牌白标应用、共享设计系统、统一认证逻辑是 Layer 的典型应用场景。
总结6:中间件不能替代 Server 端的安全验证
路由中间件(middleware/)在页面渲染前执行,适合认证守卫、重定向、页面埋点等 UI 层面的控制。但它在客户端是可绕过或调试的——真正的安全认证和授权必须在 Server Middleware 和 Server API 中独立验证。路由中间件改善体验,Server Middleware 保障安全。
章节测试
测试1:以下哪种文件结构不生成合法的 Nuxt 路由?
A. pages/projects/[id].vue → /projects/:id B. pages/(auth)/login.vue → /auth/login C. pages/blog/[...slug].vue → /blog/* D. pages/index.vue → /
测试2:cachedEventHandler 的 SWR 模式与普通缓存的最大区别是什么?
A. SWR 缓存永不过期 B. SWR 在缓存过期后先返回旧数据,同时后台刷新 C. SWR 的缓存存储在 CDN 而不是服务端内存 D. SWR 只能在 Edge Functions 环境中使用
测试3:何时应该使用 $fetch 而不是 useFetch?
测试4:一个在线商城项目的路由渲染策略如何设计?为以下页面选择合适的渲染模式并说明理由:
- 首页(产品推荐、Banner)
- 商品详情页
- 搜索结果页
- 用户订单列表
- 后台管理面板
测试5:Nuxt Layer 的优先级规则是什么?如果 Layer A 和 Layer B 都定义了同名的 components/Button.vue,主项目也定义了一个,最终会使用哪个?
测试6:全局中间件 .global.ts 和命名中间件在执行顺序上的关系是怎样的?
参考答案
测试1答案
答案:B。路由分组 (auth) 的括号目录名不会出现在 URL 中,因此 pages/(auth)/login.vue 生成的路由是 /login,不是 /auth/login。
测试2答案
答案:B。SWR 的核心价值在于"过期不等待"——缓存过期后,请求立即收到旧缓存数据,同时后台异步执行 handler 刷新缓存。用户感知到的延迟永远是缓存读取的速度,而不是 handler 执行的速度。A 不准确(SWR 可以设置窗口期),C 和 D 都是错误的。
测试3答案
$fetch 适合以下场景:
- 用户交互触发的写操作(表单提交、删除、更新)——不需要 SSR payload 缓存
- Server API 内部调用外部服务——此时已在服务端,没有"去重"需求
- 纯客户端请求——明确不需要 SSR 阶段的数据
- 事件处理函数中的一次性请求——不需要响应式追踪
核心判断:如果数据需要在 SSR 阶段获取并传递到客户端水合复用,用 useFetch/useAsyncData;如果只是某次用户操作的瞬态请求,用 $fetch。
测试4答案
| 页面 | 渲染模式 | 理由 |
|---|---|---|
| 首页(产品推荐) | SWR 300 或 ISR 600 | 内容更新不频繁、访问量最大、SEO 重要 |
| 商品详情页 | SWR 3600 或 ISR | 商品信息相对稳定、SEO 重要、可用缓存大幅降低服务端负载 |
| 搜索结果页 | SSR | 搜索参数组合无限、结果实时变化、需要个性化推荐 |
| 用户订单列表 | SSR | 个性化数据、必须实时准确、涉及用户隐私不能缓存 |
| 后台管理面板 | CSR (ssr: false) | 认证后使用、不需要 SEO、以交互体验为主 |
测试5答案
优先级:主项目 > extends 中靠后的 Layer > extends 中靠前的 Layer。
对于 extends: [layerA, layerB]:layerB 的优先级高于 layerA。主项目的内容拥有最高优先级。
如果 Layer A、Layer B、主项目都定义了 components/Button.vue,最终会使用主项目的版本。如果只有 Layer A 和 Layer B 定义,主项目没有,则使用 Layer B 的版本(B 在数组中位置更靠后,优先级更高)。
测试6答案
全局中间件(.global.ts)先于命名中间件执行。
完整执行顺序:
- 全局中间件按文件名字母顺序执行(
01.analytics.global.ts→02.redirects.global.ts) - 命名中间件按
definePageMeta中middleware数组的顺序执行(['auth', 'role-check']→ 先 auth 后 role-check) - 所有中间件执行完毕后,进入页面组件的 setup
如果任何中间件返回 navigateTo() 或 abortNavigation(),后续中间件和页面组件都不会执行。
相关笔记
- [[./03-router-forms-and-component-architecture]] — Vue Router 基础与组件架构
- [[./06-nuxt-data-server-cache]] — Nuxt 数据获取、Server API 与缓存深度
- [[./04-pinia-state-management]] — Pinia 状态管理(中间件常配合 Store 使用)
- [[../02-react-and-nextjs/00-overview]] — React/Next.js 生态对比参考
下一步学习
- [ ] 阅读 Nuxt 数据获取、Server API 与缓存 — 深入数据层
- [ ] 在本地创建一个 Nuxt 4 项目,尝试配置不同的
routeRules渲染策略 - [ ] 实现一个完整的认证中间件(登录守卫 + 角色检查 + 全局日志)
- [ ] 将一个现有的 Vue 组件库封装为 Nuxt Layer,并在另一个项目中引用
学习状态:🟡 开始学习