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

这个过程包含四个容易混淆的概念:

  1. 路由匹配决定“目标 URL 对应哪个页面组件”。
  2. 布局决定“页面放在什么公共结构中”。
  3. 路由中间件决定“是否允许进入这个目标页面”。
  4. 错误处理决定“导航、数据加载或渲染失败后显示什么”。

路由中间件不是后端鉴权本身,布局不是路由层级本身,错误页也不是普通的 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.vuepages/settings/profile.vue 这种目录式组织,不应机械地在每个页面中增加 <NuxtPage />。是否需要父页面承载子页面,取决于最终生成的路由树和页面层级设计。


三、页面元数据:页面如何声明中间件和布局

页面文件可以通过 definePageMeta 声明路由相关元数据:

<script setup lang="ts">
definePageMeta({
  layout: 'dashboard',
  middleware: ['auth'],
})
</script>

<template>
  <h1>控制台首页</h1>
</template>

definePageMeta 是 Nuxt 编译期宏,不是一个需要运行时导入的普通函数。常见字段包括:

  • layout:指定布局名称。
  • middleware:指定该页面使用的路由中间件。
  • pageTransitionlayoutTransition:配置页面或布局切换动画。
  • 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>

这里的因果关系是:

  1. URL /login 匹配 pages/login.vue
  2. 页面元数据声明 layout: 'auth'
  3. Nuxt 查找 layouts/auth.vue
  4. 页面组件作为默认插槽内容传入该布局。

布局不是路由守卫。把权限判断写在布局中,可能导致页面已经进入路由后才发现无权访问;权限控制应放在路由中间件或服务端接口中。


五、路由中间件:导航前的条件检查

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 和后续客户端导航也可能触发相关逻辑;
  • 访问 windowdocumentlocalStorage 前必须判断 import.meta.client
  • 统计、鉴权、重定向逻辑必须考虑重复执行。

中间件的签名:

defineNuxtRouteMiddleware((to, from) => {
  // to 是目标路由
  // from 是来源路由
})

tofrom 是 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>

这里的并发边界是:

  1. slug 变化;
  2. useFetch 产生新的请求;
  3. 旧请求可能尚未完成;
  4. 页面应根据当前请求状态展示结果;
  5. 如果手动使用 watchfetch,就必须自行处理旧请求覆盖新请求的问题。

手写请求时可以使用 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 时,预期流程是:

  1. 文件约定把 pages/dashboard.vue 映射为 /dashboard
  2. 路由中间件读取 loggedIn
  3. 初始值为 false,返回 navigateTo('/login')
  4. 登录页使用 auth 布局。
  5. 如果实际登录逻辑将状态改为 true,再次访问 /dashboard 才会通过中间件。

这个示例只展示导航机制,不构成安全鉴权。真实项目必须把登录状态存储和服务端会话验证接入服务端边界。


Nuxt 路由系统的核心不是某个单独 API,而是几个阶段之间的契约:pages/ 负责生成匹配关系,definePageMeta 负责声明页面元数据,路由中间件负责导航前决策,布局通过插槽组织公共结构,错误页承接应用级失败,NuxtLinknavigateTo 则提供统一导航入口。理解这些边界后,文件约定不再是“目录魔法”,而是一套可观察、可验证、可区分故障路径的应用架构。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。