跳转到正文
YGJoon 技术博客

Nuxt 3 服务端渲染实践:路由、数据获取与水合避坑

发布于:更新于:
·1.3k 字 · 约 3 分钟·作者:YGJoon
useFetch 和 useAsyncData 选哪个?文件路由组织、server/api 写接口,以及服务端水合 mismatch 的三个经典坑。
Nuxt 3 服务端渲染实践:路由、数据获取与水合避坑文章封面
目录

Nuxt 3 把 Vue 的服务端渲染(SSR)做成了开箱即用的东西,但”能跑起来”和”不出水合问题”是两回事。这篇讲文件路由怎么组织、数据怎么取、以及 SSR 最容易翻车的几个坑。

文件路由:目录即路由

pages/ 目录下的文件自动变成路由,不用手写 router 配置:

pages/
  index.vue          → /
  about.vue          → /about
  posts/
    index.vue        → /posts
    [id].vue         → /posts/123(动态路由)
    [...slug].vue    → /posts/a/b/c(捕获所有)

动态路由里拿参数用 useRoute():

<!-- pages/posts/[id].vue -->
<script setup>
const route = useRoute()
const id = route.params.id // /posts/123 → '123'
</script>

布局用 layouts/,在页面里指定:

<script setup>
definePageMeta({
  layout: 'admin', // 用 layouts/admin.vue
})
</script>

数据获取:useFetch vs useAsyncData

这是 Nuxt 3 最核心的两个 composable,选错会多写很多代码:

<script setup>
// ✅ 简单 GET 请求直接 useFetch,语义最清晰
const { data: user, pending, error, refresh } = await useFetch('/api/user/1')

// ✅ 需要自定义请求逻辑(POST、header、transform)用 useAsyncData + $fetch
const { data: posts } = await useAsyncData('post-list', () =>
  $fetch('/api/posts', {
    method: 'POST',
    body: { category: 'tech' },
  })
)
</script>

<template>
  <p v-if="pending">加载中…</p>
  <p v-else-if="error">出错了:{{ error.message }}</p>
  <ul v-else>
    <li v-for="p in posts" :key="p.id">{{ p.title }}</li>
  </ul>
</template>

记住这条线:useFetch(url) ≈ useAsyncData(key, () => $fetch(url))。useFetch 是语法糖,URL 会自动生成 key。需要精细控制 key(比如同一接口不同参数要分开缓存)时,用 useAsyncData 自己传 key。

关键点:这两个在服务端会执行一次,把数据序列化进 HTML,客户端水合时直接复用,不会再发一次请求。这就是 SSR 的核心收益:首屏有数据、SEO 能抓到内容。

但注意:只在 setup 顶层 await 调用才有这个效果。放在点击事件里调用就是纯客户端请求了:

// 点击按钮时调用 → 只在客户端执行,不经过服务端
async function reload() {
  const data = await $fetch('/api/user/1')
}

server/api:顺手写后端接口

server/api/ 下的文件自动变成 API 路由,Nuxt 3 自带了个轻量后端:

// server/api/posts.ts → GET /api/posts
export default defineEventHandler(async (event) => {
  return [
    { id: 1, title: '第一篇' },
    { id: 2, title: '第二篇' },
  ]
})

// server/api/posts/[id].ts → GET /api/posts/123
export default defineEventHandler((event) => {
  const id = getRouterParam(event, 'id')
  return { id, title: `第 ${id} 篇` }
})

// server/api/login.post.ts → POST /api/login(文件名后缀限定方法)
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  if (body.username === 'admin' && body.password === '123456') {
    return { token: 'xxx' }
  }
  throw createError({ statusCode: 401, statusMessage: '账号或密码错误' })
})

读请求用 readBody(event)(POST body)、getQuery(event)(查询参数)、getRouterParam(event, 'id')(路径参数)。抛错用 createError,Nuxt 会转成对应 HTTP 状态码。

水合(hydration)的三个经典坑

水合是指服务端渲染好的 HTML 到客户端后,Vue 接管并绑定事件的过程。服务端和客户端渲染结果必须完全一致,否则控制台报 hydration mismatch,轻则闪一下,重则整棵树重新渲染。

坑 1:渲染时用了随机数或当前时间

<script setup>
// ❌ 服务端和客户端各算一次,结果必然不一致
const id = Math.random()
const now = new Date().toLocaleString()
</script>

解法:随机值放 onMounted 里生成(只在客户端跑),或包一层 <ClientOnly>:

<ClientOnly>
  <p>{{ Math.random() }}</p>
  <template #fallback>加载中…</template>
</ClientOnly>

坑 2:在服务端访问浏览器 API

// ❌ 服务端没有 window/localStorage,直接报错
const token = localStorage.getItem('token')

解法:用 onMounted 包起来,或者判断 if (import.meta.client)。Nuxt 3 的 useState 是跨端共享状态的安全写法:

// ✅ 服务端和客户端都能安全读写
const counter = useState('counter', () => 0)

坑 3:第三方库只在客户端初始化

图表库、富文本编辑器这类操作 DOM 的库,服务端渲染时直接引入会炸。用动态导入 + <ClientOnly>:

<script setup>
const Chart = defineAsyncComponent(() => import('~/components/MyChart.vue'))
</script>

<template>
  <ClientOnly>
    <Chart :data="chartData" />
  </ClientOnly>
</template>

实战案例

来自个人全栈项目的一个博客前台(就是你现在看的这种形态):文章列表页 SSR,文章详情页 SSR,评论区用 Giscus(客户端组件)。

第一个坑是文章详情页的”阅读时长”。我一开始在 setup 里用 new Date() 算”X 分钟前”,列表页和详情页都报 hydration mismatch——服务端算出来”3 分钟前”,客户端水合时变成”4 分钟前”,字符串对不上。改成服务端只渲染绝对时间(2026-10-30),相对时间(“3 分钟前”)放 onMounted 里客户端替换,问题消失。

第二个坑是 useFetch 的 key。文章列表有分页,/api/posts?page=1 和 ?page=2,我一开始没传 key,Nuxt 按 URL 自动生成 key 本来是对的;但后来加了个”按标签筛选”,筛选条件变了 URL 没变(用的 POST body 传参),切标签时数据不刷新——因为 key 没变,Nuxt 以为是同一个请求。改成 useAsyncData(\posts-${tag}-${page}`, …)` 手动拼 key,解决。

现在的 checklist:凡是 Date.now()、Math.random()、window、localStorage,写之前先问一句”这行在服务端跑会怎样”。能过这一问,水合问题基本绝迹。

参考资料


分享这篇文章:


相关文章

上一篇
Vue 3 组合式 API 入门:ref 还是 reactive,先把这道选择题做对
下一篇
NestJS 后端入门:Controller、Provider、Module 与依赖注入