Vue 基础体系 · 第 65/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Nuxt 路由与布局:文件约定、中间件、错误页和导航
Nuxt 的路由系统建立在 Vue Router 之上,但开发者通常不直接手写一份完整的路由表,而是通过文件目录表达页面、参数和嵌套路由。布局负责页面的公共外壳,中间件负责导航前的条件检查,错误页负责把异常转换为可呈现的界面,导航 API 则连接用户操作、程序逻辑和服务端渲染。
下面的示例基于 Nuxt 3、Vue 3、Composition API、TypeScript。Nuxt 4 对部分目录采用了 app/ 前缀,文中会单独说明。
一、先建立整体模型:一次导航发生了什么
一次从 /orders/42 导航到 /orders/43,可以抽象为以下过程:
sequenceDiagram
participant U as 用户或代码
participant R as Vue Router
participant M as Nuxt 路由中间件
participant P as 页面组件
participant L as 布局
participant E as 错误处理
U->>R: 点击 NuxtLink 或调用 navigateTo
R->>R: 根据文件生成的路由表匹配目标地址
R->>M: 执行全局、目录级、页面级中间件
alt 中间件拒绝或重定向
M-->>R: abortNavigation / navigateTo
else 中间件通过
M->>P: 创建或更新页面组件
P->>L: 在布局的默认插槽中渲染
alt 页面或数据加载失败
P->>E: createError / 抛出异常
E-->>U: error.vue
else 正常
L-->>U: 页面结果
end
end
这个过程包含四个容易混淆的概念:
- 路由匹配决定“目标 URL 对应哪个页面组件”。
- 布局决定“页面放在什么公共结构中”。
- 路由中间件决定“是否允许进入这个目标页面”。
- 错误处理决定“导航、数据加载或渲染失败后显示什么”。
路由中间件不是后端鉴权本身,布局不是路由层级本身,错误页也不是普通的 404.vue 页面。它们位于不同的生命周期阶段。
二、文件约定如何生成路由
2.1 pages/ 目录生成页面路由
假设项目结构如下:
pages/
├── index.vue
├── about.vue
├── users/
│ ├── index.vue
│ └── [id].vue
└── docs/
└── [...slug].vue
对应的路由大致是:
| 文件 | URL | 组件 |
|---|---|---|
pages/index.vue |
/ |
首页 |
pages/about.vue |
/about |
关于页 |
pages/users/index.vue |
/users |
用户列表 |
pages/users/[id].vue |
/users/:id |
用户详情 |
pages/docs/[...slug].vue |
/docs/:slug(.*)* |
文档捕获页 |
index.vue 表示当前目录的默认页面。目录结构表达 URL 层级,但它不自动等价于布局层级;布局需要通过 layouts/ 和 <NuxtLayout> 配置。
如果项目没有 pages/,Nuxt 不会凭空生成业务页面路由。此时可以在 app.vue 中直接编写应用根组件,但不能依靠文件约定获得页面级路由。
Nuxt 3 常见结构是:
app.vue
pages/
layouts/
middleware/
error.vue
Nuxt 4 默认更倾向于:
app/
├── app.vue
├── pages/
├── layouts/
└── middleware/
具体目录取决于项目使用的 Nuxt 主版本和 future 配置。迁移时应以项目生成的 Nuxt 配置和官方版本文档为准,不要把 Nuxt 3 与 Nuxt 4 的目录规则混用。
2.2 动态路由:[id].vue
文件名中的方括号表示动态参数:
pages/products/[id].vue
它匹配:
/products/1
/products/abc
/products/a-b-c
页面中可以通过 useRoute() 读取参数:
<script setup lang="ts">
const route = useRoute()
const productId = computed(() => {
const value = route.params.id
if (typeof value !== 'string') {
throw createError({
statusCode: 400,
statusMessage: '商品 ID 无效',
})
}
return value
})
const { data: product, error } = await useFetch(
() => `/api/products/${encodeURIComponent(productId.value)}`,
)
if (error.value) {
throw createError({
statusCode: error.value.statusCode || 500,
statusMessage: '商品加载失败',
cause: error.value,
})
}
</script>
<template>
<article v-if="product">
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
</article>
</template>
这里有三个重要事实:
route.params.id的类型通常不是无条件的string,因为 Vue Router 的参数可能是字符串数组。- URL 参数是用户输入,传给 API 或拼接 URL 前应进行编码或校验。
useFetch在页面的服务端渲染阶段可以获取数据并把结果传递给客户端,避免客户端 hydration 后立刻重复请求;但具体请求去重还取决于请求键和使用方式。
如果只需要匹配数字,Nuxt 文件名本身不能像某些路由 DSL 一样直接表达所有正则约束。通常应在页面、中间件或服务端 API 中校验:
const id = Number(route.params.id)
if (!Number.isInteger(id) || id <= 0) {
throw createError({
statusCode: 404,
statusMessage: '商品不存在',
})
}
2.3 捕获多级路径:[...slug].vue
pages/docs/[...slug].vue
用于匹配:
/docs/vue
/docs/vue/composition-api
/docs/nuxt/routing/middleware
route.params.slug 通常是数组,例如:
const route = useRoute()
const segments = route.params.slug
// 可能是 ['nuxt', 'routing', 'middleware']
const path = Array.isArray(segments)
? segments.join('/')
: segments
捕获路由适合文档系统、CMS 页面和任意层级的内容树。它的代价是:如果捕获路由过于宽泛,可能吞掉原本应该由更具体页面处理的 URL。因此应避免用一个 catch-all 页面覆盖整个站点,除非它确实是内容系统的边界。
Nuxt 还支持可选捕获参数的写法,但不同 Nuxt/Nitro/Vue Router 版本在边缘匹配行为上可能存在差异。使用前应在当前版本中验证“零段路径”是否匹配,不要假设 [...slug] 与可选 catch-all 完全相同。
2.4 嵌套路由与 <NuxtPage>
目录嵌套表达嵌套路由:
pages/
└── settings/
├── index.vue
├── profile.vue
└── security.vue
这通常生成:
/settings
/settings/profile
/settings/security
如果使用页面组件的嵌套结构,例如父页面本身还要承载子页面,则父页面需要渲染 <NuxtPage />:
<!-- pages/settings.vue -->
<template>
<section class="settings">
<nav>
<NuxtLink to="/settings/profile">个人资料</NuxtLink>
<NuxtLink to="/settings/security">安全设置</NuxtLink>
</nav>
<NuxtPage />
</section>
</template>
但对于常见的 pages/settings/index.vue、pages/settings/profile.vue 这种目录式组织,不应机械地在每个页面中增加 <NuxtPage />。是否需要父页面承载子页面,取决于最终生成的路由树和页面层级设计。
三、页面元数据:页面如何声明中间件和布局
页面文件可以通过 definePageMeta 声明路由相关元数据:
<script setup lang="ts">
definePageMeta({
layout: 'dashboard',
middleware: ['auth'],
})
</script>
<template>
<h1>控制台首页</h1>
</template>
definePageMeta 是 Nuxt 编译期宏,不是一个需要运行时导入的普通函数。常见字段包括:
layout:指定布局名称。middleware:指定该页面使用的路由中间件。pageTransition、layoutTransition:配置页面或布局切换动画。key:影响页面组件复用和重新渲染。
布局名 dashboard 对应:
layouts/dashboard.vue
如果要禁用布局:
definePageMeta({
layout: false,
})
如果布局需要根据状态动态切换,可以使用 setPageLayout:
<script setup lang="ts">
const route = useRoute()
const { setPageLayout } = useNuxtApp()
watch(
() => route.path,
(path) => {
setPageLayout(path.startsWith('/admin') ? 'admin' : 'default')
},
{ immediate: true },
)
</script>
不过,动态切换布局的逻辑应与页面生命周期保持一致。若在多个页面、多个插件中同时调用 setPageLayout,最后一次调用可能覆盖前面的结果,导致难以诊断的布局闪烁或错配。
四、布局:公共外壳如何包裹页面
4.1 layouts/default.vue
布局是包裹页面的组件。一个最小的默认布局如下:
<!-- layouts/default.vue -->
<template>
<div class="site-shell">
<header class="site-header">
<NuxtLink to="/">WR Blog</NuxtLink>
<nav aria-label="主导航">
<NuxtLink to="/articles">文章</NuxtLink>
<NuxtLink to="/about">关于</NuxtLink>
</nav>
</header>
<main>
<slot />
</main>
<footer>© 2025 WR Blog</footer>
</div>
</template>
<slot /> 是页面内容进入布局的位置。布局中可以放置不会随着页面切换而重复实现的结构,例如:
- 顶部导航;
- 侧边栏;
- 页脚;
- 全局通知区域;
- 页面级 loading 容器;
- 站点级主题或登录状态展示。
页面使用布局:
<!-- pages/articles/index.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
</script>
<template>
<h1>文章列表</h1>
</template>
如果页面没有显式指定布局,Nuxt 通常使用 default 布局。要让布局生效,应用根组件需要使用 <NuxtLayout> 和 <NuxtPage>:
<!-- app.vue -->
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
Nuxt 模板项目通常已经提供了相应结构,但如果自定义了 app.vue,漏掉 <NuxtLayout> 会使布局不被渲染,漏掉 <NuxtPage> 则不会渲染文件约定页面。
4.2 不同布局的选择
<!-- layouts/auth.vue -->
<template>
<div class="auth-shell">
<main class="auth-panel">
<slot />
</main>
</div>
</template>
登录页可以使用:
<!-- pages/login.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'auth',
})
</script>
<template>
<form>
<h1>登录</h1>
<!-- 表单字段 -->
</form>
</template>
这里的因果关系是:
- URL
/login匹配pages/login.vue。 - 页面元数据声明
layout: 'auth'。 - Nuxt 查找
layouts/auth.vue。 - 页面组件作为默认插槽内容传入该布局。
布局不是路由守卫。把权限判断写在布局中,可能导致页面已经进入路由后才发现无权访问;权限控制应放在路由中间件或服务端接口中。
五、路由中间件:导航前的条件检查
5.1 路由中间件与服务端中间件不是一回事
Nuxt 中至少有两类容易混淆的中间件:
- 路由中间件:位于
middleware/,控制页面导航。 - 服务端中间件:位于
server/middleware/,处理 Nitro 服务端请求。
路由中间件可以决定是否允许进入页面,但它不等于后端鉴权。浏览器中的 JavaScript 可以被修改,真正保护数据的条件必须在服务端 API 或服务端渲染逻辑中再次验证。
5.2 命名中间件
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
const auth = useAuth()
if (auth.status.value === 'pending') {
return
}
if (!auth.isLoggedIn.value && to.path !== '/login') {
return navigateTo({
path: '/login',
query: {
redirect: to.fullPath,
},
})
}
})
页面使用:
<script setup lang="ts">
definePageMeta({
middleware: ['auth'],
})
</script>
<template>
<h1>需要登录的页面</h1>
</template>
navigateTo 返回一个导航结果,因此中间件中应直接 return:
return navigateTo('/login')
不要写成:
navigateTo('/login')
// 中间件继续执行
否则当前导航可能继续完成,造成并行导航或重定向行为不明确。
登录页读取原始目标地址:
<script setup lang="ts">
const route = useRoute()
const redirectTo =
typeof route.query.redirect === 'string'
? route.query.redirect
: '/'
async function submitLogin() {
// 登录请求成功后
await navigateTo(redirectTo)
}
</script>
生产代码还应限制 redirect 只能是站内路径,避免开放重定向:
function safeRedirect(value: unknown): string {
if (
typeof value === 'string' &&
value.startsWith('/') &&
!value.startsWith('//')
) {
return value
}
return '/'
}
5.3 全局中间件
文件名以 .global 结尾的路由中间件会在每次路由导航时执行:
// middleware/analytics.global.ts
export default defineNuxtRouteMiddleware((to, from) => {
if (import.meta.client && to.fullPath !== from.fullPath) {
// 发送页面访问事件
}
})
全局中间件需要特别注意 SSR:
- 初次请求可能在服务端执行;
- 客户端 hydration 和后续客户端导航也可能触发相关逻辑;
- 访问
window、document、localStorage前必须判断import.meta.client; - 统计、鉴权、重定向逻辑必须考虑重复执行。
中间件的签名:
defineNuxtRouteMiddleware((to, from) => {
// to 是目标路由
// from 是来源路由
})
to 和 from 是 Vue Router 的路由对象。中间件中应优先使用参数,而不是依赖尚未稳定的全局 useRoute()。
5.4 内联中间件
页面也可以直接声明匿名中间件:
<script setup lang="ts">
definePageMeta({
middleware: [
function (to) {
if (to.query.preview !== '1') {
return abortNavigation(
createError({
statusCode: 403,
statusMessage: '预览模式未开启',
}),
)
}
},
],
})
</script>
abortNavigation() 会中止当前导航。它与 navigateTo() 的区别是:
navigateTo():结束当前导航并转到另一个地址;abortNavigation():保留当前页面,不进入目标地址;- 直接不返回值:允许导航继续。
中间件可以组合:
<script setup lang="ts">
definePageMeta({
middleware: ['auth', 'admin'],
})
</script>
中间件的执行顺序受全局中间件、页面声明顺序和 Nuxt 处理规则影响。不要让两个中间件分别对同一条件作互相冲突的重定向,例如一个重定向 /login,另一个又把未完成资料的用户重定向 /profile。应明确状态优先级,否则容易产生重定向循环。
5.5 SSR 下鉴权的正确边界
一个常见错误是只在路由中间件中判断客户端状态:
const token = localStorage.getItem('token')
这段代码在服务端没有 localStorage,并且即使修正为客户端判断,也不能保护服务端数据。
更可靠的结构是:
// composables/useAuth.ts
export function useAuth() {
const token = useCookie<string | null>('auth_token')
const user = useState<{ id: string; name: string } | null>(
'current-user',
() => null,
)
const isLoggedIn = computed(() => Boolean(token.value))
return {
token,
user,
isLoggedIn,
status: computed(() => 'ready'),
}
}
路由中间件负责用户体验上的提前拦截:
// middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
const { isLoggedIn } = useAuth()
if (!isLoggedIn.value) {
return navigateTo('/login')
}
})
服务端 API 仍然必须验证 cookie 或 token:
// server/api/me.get.ts
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token')
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
})
}
return {
id: 'u_1',
name: 'Ada',
}
})
原因是页面中间件控制的是“页面导航”,而 API 控制的是“数据访问”。两者属于不同信任边界。
六、错误页:把失败状态变成界面
6.1 页面级错误页 error.vue
项目根目录的 error.vue 用于显示 Nuxt 应用级错误:
<!-- error.vue -->
<script setup lang="ts">
type NuxtError = {
statusCode?: number
statusMessage?: string
message?: string
data?: unknown
}
const props = defineProps<{
error: NuxtError
}>()
const title = computed(() => {
if (props.error.statusCode === 404) {
return '页面不存在'
}
return '页面加载失败'
})
async function backHome() {
await clearError({ redirect: '/' })
}
</script>
<template>
<main class="error-page">
<p>错误码:{{ error.statusCode ?? 500 }}</p>
<h1>{{ title }}</h1>
<p>{{ error.statusMessage || error.message }}</p>
<button type="button" @click="backHome">
返回首页
</button>
</main>
</template>
error.vue 不应被当作普通的 pages/error.vue。前者是 Nuxt 错误状态的专用入口,后者只是一个普通 URL 页面。
clearError 用于清除当前错误状态。传入 redirect 后,Nuxt 会在清除错误的同时导航到指定地址。如果只清除错误而不改变地址,页面可能再次执行相同的失败逻辑。
6.2 产生结构化错误
页面或服务端代码可以使用 createError:
const { data: article } = await useFetch(
`/api/articles/${route.params.slug}`,
)
if (!article.value) {
throw createError({
statusCode: 404,
statusMessage: '文章不存在',
fatal: true,
})
}
字段含义:
statusCode:HTTP 或应用层状态码。statusMessage:面向用户或日志的简短说明。message:错误消息。fatal:在客户端错误场景下,要求 Nuxt 进入应用错误页的信号之一。
不同调用位置对 fatal 的影响不完全相同:服务端渲染期间抛出的错误本来就会影响当前响应;客户端组件中的普通异常可能只影响局部组件。因此应根据错误发生位置验证行为,而不要把所有异常都简单设置为 fatal: true。
服务端 API 中:
// server/api/articles/[slug].get.ts
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
if (!slug) {
throw createError({
statusCode: 400,
statusMessage: '缺少文章标识',
})
}
const article = await findArticle(slug)
if (!article) {
throw createError({
statusCode: 404,
statusMessage: '文章不存在',
})
}
return article
})
服务端返回 404 后,调用该 API 的页面可以选择:
- 把 404 转换为 Nuxt 页面错误;
- 在页面中显示局部“文章不存在”状态;
- 让服务端 API 错误保持为接口错误。
选择取决于失败范围。文章不存在通常是页面级状态;推荐列表加载失败可能只应显示列表区域的重试按钮。
6.3 局部错误:NuxtErrorBoundary
并非所有错误都应替换整个应用页面。对于可隔离的组件,可以使用 NuxtErrorBoundary:
<template>
<NuxtErrorBoundary>
<RecommendationPanel />
<template #error="{ error, clearError }">
<section class="panel-error">
<p>推荐内容暂时不可用:{{ error.message }}</p>
<button type="button" @click="clearError">
重试
</button>
</section>
</template>
</NuxtErrorBoundary>
</template>
其状态转换可以表示为:
正常内容
├── 子组件抛出错误 → error 插槽
├── clearError() → 重新尝试渲染
└── 未捕获或升级为应用级错误 → error.vue
局部边界适合推荐、评论、统计卡片等非核心区域。登录失效、页面主数据不存在、路由目标无法解析等问题通常不能只显示一个局部错误块。
6.4 404 的两种来源
访问一个不存在的页面,例如:
GET /does-not-exist
当没有任何页面路由匹配时,Nuxt 会进入 404 错误处理流程,最终由 error.vue 呈现。
另一种情况是路由匹配成功,但资源不存在:
GET /articles/missing-slug
此时 pages/articles/[slug].vue 可以匹配 URL,但文章查询结果为空。页面必须主动抛出 404:
if (!article.value) {
throw createError({
statusCode: 404,
statusMessage: '文章不存在',
})
}
因此,“路由不存在”和“路由存在但业务资源不存在”是两条不同的失败路径,不能只依赖通配页面解决。
七、导航:链接、程序跳转与浏览器历史
7.1 NuxtLink
页面内部链接应优先使用 NuxtLink:
<template>
<nav>
<NuxtLink to="/">首页</NuxtLink>
<NuxtLink to="/articles">文章</NuxtLink>
<NuxtLink
:to="{
path: '/articles',
query: { tag: 'nuxt', page: 2 },
}"
>
Nuxt 文章
</NuxtLink>
</nav>
</template>
NuxtLink 会根据目标判断是否为内部路由,并在客户端使用 Vue Router 导航;内部导航通常不会重新加载整个文档。它还可以使用 active class:
<NuxtLink
to="/articles"
active-class="is-active"
exact-active-class="is-exact-active"
>
文章
</NuxtLink>
不要手写一个普通 <a href="/articles"> 来替代所有内部链接。普通链接通常会触发完整文档请求,丢失客户端路由切换带来的状态连续性;但下载文件、跨域地址、必须重新建立文档上下文的场景仍然可以使用普通链接。
外部链接可以显式标记:
<NuxtLink
to="https://nuxt.com"
external
target="_blank"
rel="noopener noreferrer"
>
Nuxt 官网
</NuxtLink>
7.2 navigateTo
程序化导航使用 navigateTo:
async function openArticle(slug: string) {
await navigateTo(`/articles/${encodeURIComponent(slug)}`)
}
对象形式适合同时表达路径、查询参数和 hash:
await navigateTo({
path: '/articles',
query: {
tag: 'vue',
page: 1,
},
hash: '#latest',
})
在中间件、事件处理函数、提交函数中都应考虑它返回的 Promise:
async function submit() {
const result = await saveForm()
if (result.ok) {
await navigateTo('/success')
}
}
如果导航被中间件拒绝、目标相同或发生路由错误,Promise 的结果可能不是成功完成。涉及表单提交、离开页面提示或错误提示时,应捕获异常或根据返回结果处理。
7.3 useRouter
需要访问 Vue Router 能力时使用:
<script setup lang="ts">
const router = useRouter()
async function replaceFilters() {
await router.replace({
query: {
status: 'open',
},
})
}
function goBack() {
router.back()
}
</script>
常用方法的语义不同:
router.push():新增一条浏览器历史记录;router.replace():替换当前历史记录;router.back():返回上一条记录;router.go(n):按历史记录偏移量移动。
Nuxt 项目中页面跳转通常优先使用 navigateTo,因为它是 Nuxt 提供的跨 SSR/客户端导航抽象;需要直接控制 Vue Router 历史行为时再使用 useRouter()。
7.4 查询参数是字符串输入
例如 URL:
/articles?page=2&draft=true
读取时不要直接假设类型:
const route = useRoute()
const page = computed(() => {
const value = Number(route.query.page ?? 1)
return Number.isInteger(value) && value > 0 ? value : 1
})
const draft = computed(() => route.query.draft === 'true')
查询参数可能是:
string | string[] | undefined
多个同名参数会产生数组,因此需要根据业务定义规范化规则,而不是把它直接传入 API。
八、页面切换、数据加载与并发
页面导航不是“URL 改变后再随便请求数据”。在 Nuxt 中,页面组件可能经历服务端渲染、客户端 hydration、后续客户端导航三个阶段。
使用动态参数请求数据时,可以让 useFetch 依赖响应式 URL:
<script setup lang="ts">
const route = useRoute()
const slug = computed(() => String(route.params.slug))
const {
data: article,
pending,
error,
refresh,
} = await useFetch(
() => `/api/articles/${encodeURIComponent(slug.value)}`,
)
</script>
<template>
<div v-if="pending">加载中……</div>
<div v-else-if="error">
<p>加载失败</p>
<button type="button" @click="refresh">重试</button>
</div>
<article v-else-if="article">
<h1>{{ article.title }}</h1>
</article>
</template>
这里的并发边界是:
slug变化;useFetch产生新的请求;- 旧请求可能尚未完成;
- 页面应根据当前请求状态展示结果;
- 如果手动使用
watch和fetch,就必须自行处理旧请求覆盖新请求的问题。
手写请求时可以使用 AbortController:
const data = ref<Article | null>(null)
const pending = ref(false)
const error = ref<Error | null>(null)
let controller: AbortController | undefined
async function loadArticle(slug: string) {
controller?.abort()
controller = new AbortController()
pending.value = true
error.value = null
try {
data.value = await $fetch(`/api/articles/${encodeURIComponent(slug)}`, {
signal: controller.signal,
})
} catch (err) {
if ((err as DOMException).name !== 'AbortError') {
error.value = err as Error
}
} finally {
pending.value = false
}
}
如果不取消或标记旧请求,用户快速切换 /articles/a、/articles/b 时,a 的慢响应可能晚于 b 返回,从而覆盖当前页面的数据。这不是 Nuxt 路由匹配错误,而是应用层并发控制缺失。
九、导航失败与错误的诊断路径
遇到“点击链接没有变化”时,应按层次区分:
9.1 路由没有匹配
检查:
pages/articles/[slug].vue
是否真的生成了目标路径。常见问题包括:
- 文件放在了错误的目录;
- Nuxt 3 与 Nuxt 4 的
app/目录约定混用; index.vue位置不正确;- catch-all 路由覆盖或冲突;
- 修改文件后开发服务器未重新生成路由。
可以通过 Nuxt DevTools 查看路由,也可以阅读开发环境生成的 .nuxt 内部产物进行诊断,但 .nuxt 文件属于生成结果,不应直接修改,也不应把其中的内部文件路径当作稳定 API。
9.2 中间件中止或重定向
在中间件中加入临时日志:
export default defineNuxtRouteMiddleware((to, from) => {
console.debug('[auth]', {
from: from.fullPath,
to: to.fullPath,
})
// 返回 navigateTo 或 abortNavigation 的分支
})
重点查找:
- 是否在每次导航中无条件重定向;
- 是否重定向到自身;
- 是否登录页也被鉴权中间件保护;
- 是否同时存在多个互相覆盖的中间件;
- SSR 与客户端执行时读取到的认证状态是否一致。
9.3 页面组件抛错
如果 URL 已改变但页面显示错误页,检查:
useFetch的错误是否被转换为createError;- 页面是否访问了未定义的
route.params; - 服务端和客户端渲染结果是否不一致;
- 是否在 SSR 阶段访问了浏览器专属 API;
error.vue自身是否又抛出了异常。
错误页应尽量保持依赖简单。若 error.vue 依赖一个也可能失败的远程接口,错误处理会形成二次故障。
十、几个边界和反例
10.1 不要把 server/middleware 当页面鉴权
server/middleware/auth.ts
它可以检查服务端请求,但不会自动阻止浏览器进入某个 Nuxt 页面。页面导航仍可能已经发生。反过来,middleware/auth.ts 也不能保护没有经过页面的 API 请求。
正确做法是:
- 页面体验:路由中间件提前重定向;
- 数据安全:服务端 API 每次验证身份和权限。
10.2 不要在模板中直接拼接未经编码的参数
<!-- 风险:slug 可能包含特殊字符 -->
<NuxtLink :to="`/articles/${slug}`">查看</NuxtLink>
对于来自用户输入或外部数据的值,应使用安全的路径构造策略。最简单的单段参数可以使用:
const articlePath = computed(
() => `/articles/${encodeURIComponent(slug.value)}`,
)
如果参数允许斜杠,它就不再是单段参数,应改用明确的捕获路由和路径规范化。
10.3 不要用 404 页面替代业务空状态
搜索无结果不一定是错误:
/search?q=unknown
更合理的是在普通页面中显示“没有找到结果”。而请求一个明确不存在的文章资源,才适合返回 404。错误状态应反映故障边界,而不是所有空数据都统一使用错误页。
10.4 不要把所有错误都升级为全局错误
支付组件、推荐组件、评论组件失败时,如果直接 throw createError({ fatal: true }),用户可能连文章正文都看不到。局部错误边界可以保留核心内容。
相反,如果当前页面的主资源不存在,继续渲染半个页面会产生错误的 SEO 和用户认知,此时应使用 404 页面。
十一、一个最小可运行的路由骨架
可以用以下命令创建并运行 Nuxt 项目:
npx nuxi@latest init nuxt-routing-demo
cd nuxt-routing-demo
npm install
npm run dev
创建这些文件:
app.vue
layouts/default.vue
layouts/auth.vue
middleware/auth.ts
pages/index.vue
pages/login.vue
pages/dashboard.vue
error.vue
app.vue:
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
pages/index.vue:
<template>
<h1>首页</h1>
<nav>
<NuxtLink to="/login">登录</NuxtLink>
<NuxtLink to="/dashboard">控制台</NuxtLink>
</nav>
</template>
pages/dashboard.vue:
<script setup lang="ts">
definePageMeta({
layout: 'default',
middleware: ['auth'],
})
</script>
<template>
<h1>控制台</h1>
</template>
一个仅用于演示的鉴权中间件:
// middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
const loggedIn = useState('demo-logged-in', () => false)
if (!loggedIn.value) {
return navigateTo('/login')
}
})
访问 /dashboard 时,预期流程是:
- 文件约定把
pages/dashboard.vue映射为/dashboard。 - 路由中间件读取
loggedIn。 - 初始值为
false,返回navigateTo('/login')。 - 登录页使用
auth布局。 - 如果实际登录逻辑将状态改为
true,再次访问/dashboard才会通过中间件。
这个示例只展示导航机制,不构成安全鉴权。真实项目必须把登录状态存储和服务端会话验证接入服务端边界。
Nuxt 路由系统的核心不是某个单独 API,而是几个阶段之间的契约:pages/ 负责生成匹配关系,definePageMeta 负责声明页面元数据,路由中间件负责导航前决策,布局通过插槽组织公共结构,错误页承接应用级失败,NuxtLink 和 navigateTo 则提供统一导航入口。理解这些边界后,文件约定不再是“目录魔法”,而是一套可观察、可验证、可区分故障路径的应用架构。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 静态站点交付:Nginx、CDN、History 回退、缓存和压缩
- 下一篇:Nuxt Server Routes:Nitro、API、运行时配置、缓存和部署
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论