这不是一篇「讲透原理」的文章,而是一份可以照着敲的学习手册。 每一步都告诉你:做什么 → 为什么 → 代码 → 你会看到什么 → 动手改一下。
这份 Demo 是怎么设计的
我做这个 Demo 时定了一个原则:每个知识点都要能在浏览器里「看见」。
原因很简单。看文档的时候,useState、useFetch、defineEventHandler 这些词你都认识,但关掉文档就写不出来。因为没有反馈------你不知道自己写对了没有。
所以这里的学习路径是:先跑起来,然后一次只加一个能力,每加一次就去页面上验证一次。等你走完,这 6 个文件你都能自己改动,而不是只会 npm run dev。
学完你会什么
学完这一遍,你应该能独立完成这些事(可以自我检验):
- 知道新加一个页面该放哪个文件,URL 会变成什么
- 能写一个组件,且不用
import就能在页面里用 - 能把一段逻辑抽到
composables/里复用 - 能解释
useState为什么不能用ref代替 - 能说清
useFetch和$fetch的区别,并在正确场景用对 - 能在
server/api/里加一个带参数的接口 - 能做出带动态参数、且标题正确的详情页
- 能处理两种 404
前置知识与环境
只需要两样东西:
- Vue 3 基础 :知道
ref、computed、defineProps、v-model、v-for大概在干嘛 - Node 18+
bash
node -v # 我测试用的 v23.10.0,18 以上都可以
TypeScript 不用提前学,这个 Demo 里的 TS 只有「给参数标类型」这一层。
学习路线图
整个 Demo 就是 5 个阶段,建议按顺序做,别跳:
| 阶段 | 目标 | 涉及文件 |
|---|---|---|
| 0 | 跑起来,认识项目骨架 | package.json、nuxt.config.ts |
| 1 | 理解页面怎么被渲染出来 | app.vue、pages/ |
| 2 | 学会套布局、写组件 | layouts/、components/ |
| 3 | 学会共享状态与请求数据 | composables/、useFetch |
| 4 | 学会写后端接口、动态路由 | server/api/、pages/posts/ |
| 5 | 学会错误处理与收尾 | error.vue、SEO |
阶段 0:先跑起来
做什么
bash
cd nuxt-demo
npm install
npm run dev
你会看到
终端打印出:
arduino
➜ Local: http://localhost:3000/
浏览器打开这个地址,看到一个首页:标题、4 个统计卡片、3 张文章卡片、一个加减计数器。
首页的数据全部来自后端接口,不是写死在页面里的。这一点很关键,后面阶段 3、4 会回来解释。
顺便记住 4 个命令
| 命令 | 干什么 | 什么时候用 |
|---|---|---|
npm run dev |
启动开发服务器,改代码自动刷新 | 学习期间一直开着 |
npm run build |
构建生产版本 | 要上线时 |
npm run generate |
生成纯静态 HTML | 部署到静态托管 |
npm run preview |
预览构建结果 | 检查线上表现 |
学习阶段你只需要第一条。
认识两个文件
package.json 里依赖少得可怜:
json
{
"devDependencies": {
"nuxt": "^3.14.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
}
}
没有 axios、没有 pinia、没有 express、没有 vue-router 配置------因为 Nuxt 都内置了。这也是我一直推荐新手从 Nuxt 入门的原因:少一层选择,就少一次卡住。
nuxt.config.ts 是整个项目唯一的配置文件,我写得极简:
ts
export default defineNuxtConfig({
compatibilityDate: '2024-11-01', // 决定 Nitro 运行时行为,3.12+ 必填
devtools: { enabled: true }, // 右下角的调试面板
css: ['~/assets/css/main.css'], // 全局样式,~ 代表项目根目录
app: {
head: {
htmlAttrs: { lang: 'zh-CN' },
title: 'Nuxt 3 Demo'
}
}
})
补充一个新手容易踩的坑 :.gitignore 一定要包含构建产物,否则仓库会很快变大:
gitignore
node_modules
.nuxt # Nuxt 生成的临时目录,含类型文件
.nitro
.output # 构建产物
.data
.env
.env.*
!.env.example # 示例配置要提交
阶段小结
你已经确认了三件事:环境没问题、项目能跑、配置文件很薄。 如果这一步就报错了,先看文末的「卡住了怎么办」。
阶段 1:页面是怎么出来的
做什么
打开 app.vue,你会发现它只有三行模板:
vue
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
为什么
所有 Nuxt 应用的入口都是这个结构:
<NuxtPage />------ 「当前 URL 对应的那个页面」<NuxtLayout>------ 「包在页面外面的骨架」
页面和 URL 怎么对应
Nuxt 的规则只有一条:pages/ 下的路径,就是 URL。
| 文件 | URL |
|---|---|
pages/index.vue |
/ |
pages/about.vue |
/about |
pages/posts/index.vue |
/posts |
pages/posts/[id].vue |
/posts/1、/posts/2...... |
方括号表示「这一段是变量」。
动手改一下(重要)
打开 pages/about.vue,它现在展示的是目录结构说明。我们来加个新页面:
- 新建文件
pages/hello.vue - 写点内容:
vue
<template>
<div>
<h1>Hello Nuxt</h1>
<p>这是我加的第一个页面</p>
</div>
</template>
- 不用重启、不用注册路由 ,直接访问
http://localhost:3000/hello
你会看到
页面出来了。这就是文件路由的全部魔法------没有路由表。
更进一步:把 pages/hello.vue 删掉,再访问 /hello,会看到 404 页面。
对比一下传统的
vue-router:你需要在router/index.ts里手写{ path: '/hello', component: Hello },删了组件忘了删路由,就会出现「配置里还在、点进去报错」的死配置。Nuxt 直接消灭了这类问题。
站内跳转不要用 <a>
vue
<!-- ❌ 用 a 标签会整页刷新,丢失 SPA 体验 -->
<a href="/about">关于</a>
<!-- ✅ 用 NuxtLink,客户端路由跳转 -->
<NuxtLink to="/about">关于</NuxtLink>
NuxtLink 还会做一件有用的事:鼠标悬停时预取目标页面的数据,点进去几乎没有等待。
阶段小结
app.vue=NuxtLayout+NuxtPage,不用改- 新建文件 = 新建路由,删除文件 = 路由消失
- 站内链接一律用
NuxtLink
阶段 2:布局和组件
2.1 布局:把导航和页脚抽出去
打开 layouts/default.vue:
vue
<template>
<div class="shell">
<AppHeader />
<main class="main">
<slot /> <!-- 页面内容塞在这里 -->
</main>
<footer class="footer">
<span>Nuxt 3 Demo</span> · <span>{{ new Date().getFullYear() }}</span>
</footer>
</div>
</template>
只要文件名是 default.vue,它就会自动套到所有页面上。首页、列表页、详情页共用同一个导航栏,不用每页复制粘贴。
怎么看出来的:随便点几个页面,顶部导航栏不会重新渲染,也不会闪。
2.2 组件:components/ 里放进去就能用
打开 components/PostCard.vue,看它的开头:
vue
<script setup lang="ts">
export interface Post {
id: number
title: string
summary: string
tags: string[]
date: string
readingTime: number
}
const props = defineProps<{ post: Post }>()
</script>
然后回到 pages/index.vue,找到这行:
vue
<PostCard v-for="post in latest" :key="post.id" :post="post" />
注意:pages/index.vue 的 <script> 里没有 import PostCard from '...'。
为什么不用 import
Nuxt 启动时会扫描 components/ 目录,生成 .nuxt/components.d.ts,然后自动帮你注册。
这条规则同样适用于:
composables/里的函数utils/里的工具函数server/utils/里的服务端工具- Vue 的
ref、computed,Nuxt 的useFetch、useRoute......
代价是什么 :新手会问「这个变量哪来的」。VS Code 里 Ctrl + 点击能跳到定义,习惯就好。
如果编辑器提示「找不到
useFetch的类型」,是因为.nuxt/还没生成,先跑一次npm run dev就好了。这是高频问题,记住它会省你半小时。
动手改一下
现在 PostCard 只显示了第一个标签:
vue
<span class="tag">{{ post.tags[0] }}</span>
改成显示全部:
vue
<span v-for="tag in post.tags" :key="tag" class="tag">{{ tag }}</span>
保存后回到首页------3 张卡片会同时变化,因为 PostCard 被复用了 3 次,而且列表页也在用同一个组件。
阶段小结
layouts/default.vue= 全局骨架,靠<slot />塞页面components/*.vue= 自动注册的积木,不用 import- 改一个组件,所有复用处同时生效
阶段 3:共享状态与数据请求
这一阶段是整个 Demo 最核心的部分,也是新手最容易写错的地方。
3.1 useState:为什么不能用 ref
先看常见错误:
ts
// ❌ 很多人这么写
export const count = ref(0)
在纯前端项目里这没问题。但 Nuxt 默认是 SSR(服务端渲染) :你的代码会在服务器上跑,而模块级变量在服务器上是所有用户共享的。
后果:A 用户点了一下 +1,B 用户刷新页面,看到的也是 1。
正确写法(composables/useCounter.ts):
ts
export const useCounter = () => {
// 第一个参数是唯一 key,第二个参数是初始化函数
const count = useState<number>('demo-counter', () => 0)
const double = computed(() => count.value * 2)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => (count.value = 0)
return { count, double, increment, decrement, reset }
}
useState 把状态挂在当前这次请求的 payload 上:服务端渲染完序列化给客户端,客户端 hydration 时接着用。所以它既能在组件间共享,又不会串用户。
key 一定要唯一 ,建议写成 模块名-用途,别写 'count',早晚撞车。
3.2 用起来
打开 components/CounterPanel.vue:
vue
<script setup lang="ts">
// 没有任何 import
const { count, double, increment, decrement, reset } = useCounter()
</script>
回到首页,点「+ 1」,数字和「两倍」会同步变化------因为 double 是 computed。
动手改一下
useCounter() 已经返回了 isPositive,但 CounterPanel.vue 没解构它。先补上:
vue
<script setup lang="ts">
const { count, double, isPositive, increment, decrement, reset } = useCounter()
</script>
再在数字旁边用起来:
vue
<span v-if="isPositive" class="tag">正数</span>
3.3 useFetch:SSR 取数据
打开 pages/index.vue 开头:
vue
<script setup lang="ts">
const { data: stats } = await useFetch('/api/stats')
const { data: list } = await useFetch('/api/posts')
const latest = computed(() => (list.value?.items ?? []).slice(0, 3))
</script>
useFetch 做了三件你可能没意识到的事:
- 在服务端执行,数据随 HTML 一起下发 → 用户不会看到「白屏 → 转圈 → 出内容」
- 结果存进 payload,客户端 hydration 时直接读缓存 → 不会重复请求
- 自动类型推导 →
stats.value.posts有完整提示,因为接口就在同项目里
验证方法 :浏览器里按 Ctrl + U(查看网页源代码),搜索「最新文章」或任意一个文章标题。如果你能搜到,说明数据是服务端渲染好的;如果搜不到,说明它是到了浏览器才请求的。
3.4 useFetch 和 $fetch 的区别(必背)
这是新手混得最厉害的一组:
useFetch |
$fetch |
|
|---|---|---|
| 用在哪 | 组件里渲染数据 | 事件回调里提交数据 |
| 服务端执行 | ✅ 会 | ❌ 只在调用处 |
| 结果进 payload | ✅ | ❌ |
| 避免重复请求 | ✅ | ❌ |
| 能响应式重载 | ✅ | ❌ |
一句话记住:渲染用 useFetch,点按钮用 $fetch。
vue
<script setup lang="ts">
// ✅ 页面要展示的数据
const { data: posts } = await useFetch('/api/posts')
// ✅ 用户点了才发
async function submit() {
await $fetch('/api/feedback', { method: 'POST', body: { text } })
}
</script>
还有一个反直觉的点:别在 setup 顶层用 await $fetch() 拿渲染数据。它语法上能跑,但结果不进 payload,客户端会再请求一次,你等于白写了 SSR。
阶段小结
- 共享状态用
useState,别用模块顶层ref - 渲染数据用
useFetch,交互请求用$fetch - 验证 SSR 是否生效:查看网页源代码里有没有内容
阶段 4:写后端接口与动态路由
4.1 一个文件 = 一个接口
Nuxt 内置了 Nitro 服务端,server/api/ 下的文件自动变成 HTTP 接口:
| 文件 | 接口 |
|---|---|
server/api/stats.get.ts |
GET /api/stats |
server/api/posts.get.ts |
GET /api/posts |
server/api/posts/[id].get.ts |
GET /api/posts/1 |
文件名里的 .get 就是 HTTP 方法。如果你写 posts.post.ts,它就只接受 POST。
看 server/api/posts.get.ts:
ts
export default defineEventHandler((event) => {
const { q, tag } = getQuery(event)
let items = posts // 来自 server/utils/posts.ts,自动导入
if (q) {
const keyword = String(q).toLowerCase()
items = items.filter(
(p) => p.title.toLowerCase().includes(keyword)
|| p.summary.toLowerCase().includes(keyword)
)
}
if (tag) items = items.filter((p) => p.tags.includes(String(tag)))
return {
total: items.length,
// 列表不需要正文,裁掉减小体积
items: items.map(({ content, ...rest }) => rest)
}
})
四个 API 记住就够日常用了:
| API | 作用 |
|---|---|
defineEventHandler |
定义处理函数 |
getQuery(event) |
取 ?q=xxx |
getRouterParam(event, 'id') |
取路径里的变量 |
createError({ statusCode, message }) |
抛标准 HTTP 错误 |
重要边界 :server/ 下的代码只在服务端运行,可以读 process.env、连数据库、用 Node API。反过来,public/ 下的文件会原样发给用户,千万别往里放密钥。
4.2 动手:直接调接口看看
在浏览器地址栏输入:
bash
http://localhost:3000/api/posts?q=路由
你会看到返回的 JSON,只剩标题里带「路由」的文章。
再试试:
bash
http://localhost:3000/api/stats
这就是首页那 4 个统计卡片的真实数据来源。
这一步很重要:接口和页面是两套东西,能单独在浏览器打开看,调试时先调接口、再调页面,效率高很多。
4.3 动态路由 [id].vue
pages/posts/[id].vue 负责 /posts/1 这种地址:
vue
<script setup lang="ts">
const route = useRoute()
// route.params.id 就是 URL 里的那段
const { data: post, error } = await useFetch(`/api/posts/${route.params.id}`)
</script>
对应的接口 server/api/posts/[id].get.ts:
ts
export default defineEventHandler((event) => {
const id = Number(getRouterParam(event, 'id'))
const post = posts.find((p) => p.id === id)
if (!post) {
throw createError({
statusCode: 404,
message: `没有找到 id 为 ${id} 的文章`
})
}
return post
})
动手改一下
访问 http://localhost:3000/posts/999,看看会发生什么。
你应该看到「这篇文章不存在 / 404 / 没有找到 id 为 999 的文章」------这句话就是上面 createError 里的 message。
然后把 message 改掉,刷新页面,文案就变了。改了接口,页面自动生效,这就是同项目前后端的好处。
4.4 动态 SEO:标题必须传函数
详情页的标题依赖异步数据,这里有个坑:
ts
// ❌ 只会拿到初始的空标题,数据回来也不更新
useHead({ title: `${post.value?.title} · Nuxt 3 Demo` })
// ✅ 传函数,Nuxt 会追踪依赖,数据到了自动更新
useHead({
title: () => (post.value
? `${post.value.title} · Nuxt 3 Demo`
: '文章 · Nuxt 3 Demo')
})
验证:打开详情页,看浏览器标签页的标题是不是文章名。如果显示的是「文章」这种兜底文案,说明你写成了第一版。
阶段小结
server/api/xxx.get.ts=GET /api/xxx- 接口可以单独在浏览器里测
[id]方括号 = 动态段- 动态标题用
useHead(() => ...)
阶段 5:错误处理与收尾
两处 404,别只做一处
这是新手最容易漏的点:接口的 404 和路由的 404 是两回事。
接口 404(文章不存在)→ 在页面里接:
vue
<template>
<article v-if="post">...</article>
<div v-else class="notfound">
<p>{{ error?.statusCode || 404 }}</p>
<h2>这篇文章不存在</h2>
<p>{{ error?.data?.message }}</p>
<NuxtLink to="/posts">回到列表</NuxtLink>
</div>
</template>
路由 404 (地址根本不存在)→ 走全局 error.vue:
vue
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps<{ error: NuxtError }>()
const handleError = () => clearError({ redirect: '/' })
</script>
<template>
<div class="wrap">
<p class="code">{{ props.error.statusCode }}</p>
<h1>{{ props.error.statusCode === 404 ? '页面走丢了' : '出了点问题' }}</h1>
<button @click="handleError">回到首页</button>
</div>
</template>
clearError({ redirect: '/' }) 里的 redirect 不能省------只清错误不跳转,页面会卡在错误态。
验证方法 :访问 http://localhost:3000/abcdefg,应该看到自定义的 404 页,而不是 Nuxt 默认那套。
最后检查一遍
到这里,你的清单应该能全部打勾了。打开终端确认一下项目还是健康的:
bash
npm run build
构建通过,说明没有类型错误、没有引用错误。这一步比 dev 严格得多,提交代码前建议跑一次。
卡住了怎么办
按出现频率排序,八成问题在这 6 条里:
1. 编辑器红线:找不到 ref / useFetch 的类型 .nuxt/ 目录还没生成。跑一次 npm run dev,等它编译完。
2. 页面白屏,终端报 500 看终端报错的第一行,通常是某个 useFetch 的路径写错了(少了 /api 前缀),或者接口里 getRouterParam 的 key 拼错了。
3. 数据不更新 检查你传的是不是 computed 或 ref。传死值的话,useFetch 不会重发请求:
ts
// ❌ 只在初始化时请求一次
useFetch('/api/posts', { query: { q: keyword.value } })
// ✅ 跟着变化重新请求
useFetch('/api/posts', { query: { q: computed(() => route.query.q) } })
4. 共享状态串数据 / 刷新就重置 串数据 = 用了模块顶层 ref;刷新就重置 = 用了普通 ref 而不是 useState(后者会进 payload,前者不会)。
5. 搜索时点返回键要按好几次 router.push 写了太多次。搜索场景应该用 router.replace,不往历史栈里塞记录。
6. server/ 里读环境变量是 undefined 运行时变量要用 useRuntimeConfig(),配在 nuxt.config.ts 的 runtimeConfig 里。直接读 process.env 只对构建期常量有效。
技术栈 :Nuxt 3.14 / Vue 3.5 / TypeScript / Nitro 运行环境 :Node 18+(实测 Node 23.10.0) 项目结构 :详见 /about 页面,或直接看 README.md