Nuxt 3 学习版 Demo:跟着做一遍,从跑起来到自己会改

这不是一篇「讲透原理」的文章,而是一份可以照着敲的学习手册。 每一步都告诉你:做什么 → 为什么 → 代码 → 你会看到什么 → 动手改一下。

这份 Demo 是怎么设计的

我做这个 Demo 时定了一个原则:每个知识点都要能在浏览器里「看见」。

原因很简单。看文档的时候,useState、useFetch、defineEventHandler 这些词你都认识,但关掉文档就写不出来。因为没有反馈------你不知道自己写对了没有。

所以这里的学习路径是:先跑起来,然后一次只加一个能力,每加一次就去页面上验证一次。等你走完,这 6 个文件你都能自己改动,而不是只会 npm run dev。

学完你会什么

学完这一遍,你应该能独立完成这些事(可以自我检验):

  • 知道新加一个页面该放哪个文件,URL 会变成什么
  • 能写一个组件,且不用 import 就能在页面里用
  • 能把一段逻辑抽到 composables/ 里复用
  • 能解释 useState 为什么不能用 ref 代替
  • 能说清 useFetch 和 $fetch 的区别,并在正确场景用对
  • 能在 server/api/ 里加一个带参数的接口
  • 能做出带动态参数、且标题正确的详情页
  • 能处理两种 404

前置知识与环境

只需要两样东西:

  1. Vue 3 基础 :知道 ref、computed、defineProps、v-model、v-for 大概在干嘛
  2. 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,它现在展示的是目录结构说明。我们来加个新页面:

  1. 新建文件 pages/hello.vue
  2. 写点内容:
vue 复制代码
<template>
  <div>
    <h1>Hello Nuxt</h1>
    <p>这是我加的第一个页面</p>
  </div>
</template>
  1. 不用重启、不用注册路由 ,直接访问 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 做了三件你可能没意识到的事:

  1. 在服务端执行,数据随 HTML 一起下发 → 用户不会看到「白屏 → 转圈 → 出内容」
  2. 结果存进 payload,客户端 hydration 时直接读缓存 → 不会重复请求
  3. 自动类型推导 → 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

相关推荐
小大宇44 分钟前
Vue知识点-ElementPlus-06 单选+多选+下拉+日期选择
前端·vue.js·elementui
hiahiahia1231 小时前
Streaming Model Output
开发语言·前端
paopaokaka_luck1 小时前
基于springboot+vue的益暖微光公益服务平台(DeepSeek公益助手、善款信息追溯、志愿服务积分闭环、ECharts运营分析、活动状态自动流转)
java·前端·javascript·spring boot·mysql·架构·echarts
栀椩1 小时前
Electron 内嵌页面跨域问题(CORS/PNA)解决全记录
前端·javascript·electron
asaotomo1 小时前
截图识题、AI 讲解、笔记沉淀:Chrome 扩展体验
前端·chrome·笔记
前端架构师-老李1 小时前
HTML与CSS基础第五课—HTML的空格、换行、横线相关知识
前端·css·html
SEO_juper1 小时前
Chrome 扩展一键查看当前页面 SEO 信息:不用切页面,点一下全出来
前端·javascript·chrome
Nymph_Zhu2 小时前
vue3+element-plus 文件上传
前端·typescript·vue3·element-plus
echome8882 小时前
CSS 布局:Flex 和 Grid 怎么选,附常见坑
前端·css·html