第 4 章:让页面动起来
约 4092 字大约 14 分钟
2025-11-20
本章学习目标
- 理解 Vue 的响应式原理(边做边学)
- 实现按钮点击获取数据
- 处理加载状态和错误情况
- 学习 FastAPI 路由参数
- 了解 Vibe Coding 与 AI 辅助编程
- 🎉 重点:打造一个真正可交互的页面!
上一章我们让前端成功调用了后端 API,但页面还是"死"的——数据加载完就不动了。这一章我们要让它"活"起来!
本章结束后你会得到什么
一个交互式页面:点击按钮可以刷新数据、显示加载状态、优雅处理错误——这才像个真正的应用!
4.1 Vue 响应式基础
什么是"响应式"?
简单说,就是数据变了,页面自动更新。
你改了一个变量的值,用到这个变量的地方会自动刷新显示。不需要你手动去更新 DOM。
4.1.1 ref:让数据"活"起来
在 Vue 3 中,我们用 ref 来创建响应式数据。
打开 frontend/app/pages/index.vue,我们来改造它:
<template>
<div class="container">
<h1 class="title">🎉 我的第一个全栈页面</h1>
<!-- 显示计数器 -->
<div class="counter">
<p>你点击了 <strong>{{ count }}</strong> 次</p>
<button @click="count++">点我 +1</button>
</div>
<!-- 显示后端返回的数据 -->
<div v-if="pending" class="loading">加载中...</div>
<div v-else-if="error" class="error">出错了:{{ error.message }}</div>
<div v-else class="greeting">
{{ data?.text }}
</div>
<p class="hint">👆 这段文字是从后端 API 获取的!</p>
</div>
</template>
<script setup>
// 创建一个响应式变量
const count = ref(0)
// 调用后端 API
const { data, pending, error } = await useFetch('http://localhost:8000/api/greeting')
</script>
<style scoped>
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: system-ui, sans-serif;
}
.title {
font-size: 2.5rem;
margin-bottom: 2rem;
}
.counter {
margin-bottom: 2rem;
text-align: center;
}
.counter button {
margin-top: 0.5rem;
padding: 0.5rem 1.5rem;
font-size: 1rem;
background: #42b883;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
}
.counter button:hover {
background: #3aa876;
}
.loading {
color: #666;
font-size: 1.2rem;
}
.error {
color: #e74c3c;
font-size: 1.2rem;
}
.greeting {
font-size: 1.5rem;
padding: 1rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 1rem;
margin-bottom: 1rem;
}
.hint {
color: #666;
font-size: 0.9rem;
}
</style>看到效果了吗?
保存后,页面应该出现一个计数器。点击按钮,数字会自动增加!
这就是响应式的魔力:你只需要改 count 的值,页面自动更新。
4.1.2 理解 ref
// 创建响应式变量
const count = ref(0)
// 在模板中直接使用
{{ count }} // 显示 0
// 在 script 中需要用 .value
count.value++ // 变成 1为什么模板里不用 .value?
Vue 帮你自动处理了。在 <template> 里可以直接用 count,在 <script> 里要用 count.value。
这是 Vue 3 的设计,我们只需要关注数据,而Vue 会自动处理 DOM 更新。
4.2 按钮点击刷新数据
现在计数器能动了,但后端数据还是一次性加载的。让我们添加一个按钮来手动刷新数据!
4.2.1 后端:添加随机问候语
先让后端返回不同的数据,这样我们才能看出刷新效果。
修改 backend/src/backend/__init__.py:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import random
app = FastAPI()
# 配置跨域
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 问候语列表
greetings = [ // [!code ++]
"你好,这是来自后端的问候!", // [!code ++]
"欢迎来到全栈开发的世界!", // [!code ++]
"今天也要加油写代码哦!", // [!code ++]
"休息一下,喝杯水吧~", // [!code ++]
"代码写累了?出去走走!", // [!code ++]
"你已经很棒了,继续保持!", // [!code ++]
] // [!code ++]
@app.get("/")
async def root():
return {"message": "Hello World!"}`
@app.get("/api/greeting")
async def greeting(): // [!code highlight]
# 随机返回一条问候语
text = random.choice(greetings) // [!code ++]
return {"text": text} // [!code ++]小技巧
random.choice() 会从列表中随机选一个元素。每次调用 API 都会返回不同的问候语!
4.2.2 前端:添加刷新按钮
修改 frontend/app/pages/index.vue:
<template>
<div class="container">
<h1 class="title">🎉 我的第一个全栈页面</h1>
<!-- 计数器 -->
<div class="counter">
<p>你点击了 <strong>{{ count }}</strong> 次</p>
<button @click="count++">点我 +1</button>
</div>
<!-- 问候语展示 -->
<div class="greeting-box">
<div v-if="pending" class="loading">
⏳ 加载中...
</div>
<div v-else-if="error" class="error">
❌ 出错了:{{ error.message }}
</div>
<div v-else class="greeting">
{{ data?.text }}
</div>
<!-- 刷新按钮 -->
<button class="refresh-btn" @click="refresh" :disabled="pending">
{{ pending ? '刷新中...' : '🔄 换一句' }}
</button>
</div>
<p class="hint">👆 点击按钮获取新的问候语!</p>
</div>
</template>
<script setup>
// 计数器
const count = ref(0)
// 调用后端 API,useFetch 返回的 refresh 函数可以重新请求
const { data, pending, error, refresh } = await useFetch('http://localhost:8000/api/greeting')
</script>
<style scoped>
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: system-ui, sans-serif;
padding: 2rem;
}
.title {
font-size: 2.5rem;
margin-bottom: 2rem;
}
.counter {
margin-bottom: 2rem;
text-align: center;
}
.counter button {
margin-top: 0.5rem;
padding: 0.5rem 1.5rem;
font-size: 1rem;
background: #42b883;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.2s;
}
.counter button:hover {
background: #3aa876;
}
.greeting-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.loading {
color: #666;
font-size: 1.2rem;
padding: 1rem 2rem;
}
.error {
color: #e74c3c;
font-size: 1.2rem;
padding: 1rem 2rem;
background: #fdeaea;
border-radius: 1rem;
}
.greeting {
font-size: 1.5rem;
padding: 1rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 1rem;
text-align: center;
min-width: 300px;
}
.refresh-btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.refresh-btn:hover:not(:disabled) {
background: #2980b9;
transform: scale(1.05);
}
.refresh-btn:disabled {
background: #bdc3c7;
cursor: not-allowed;
}
.hint {
color: #666;
font-size: 0.9rem;
margin-top: 1rem;
}
</style>🎉 试试看!
保存后刷新页面,点击"换一句"按钮,你会看到问候语在变化!
注意观察:
- 点击按钮时显示"刷新中..."
- 按钮在请求时会禁用(防止重复点击)
- 请求完成后显示新的问候语
4.3 useFetch 详解
刚才我们用了 useFetch,它是 Nuxt 提供的数据获取工具,非常强大。
4.3.1 返回值说明
const { data, pending, error, refresh } = await useFetch('/api/xxx')| 返回值 | 类型 | 说明 |
|---|---|---|
data | Ref | 响应数据 |
pending | Ref<boolean> | 是否正在加载 |
error | Ref | 错误信息(如果有) |
refresh | Function | 重新请求的函数 |
4.3.2 为什么用 await?
// 这样写会等待第一次请求完成
const { data } = await useFetch('/api/xxx')
// 页面渲染时,data 已经有值了服务端渲染 (SSR)
Nuxt 默认开启 SSR,意味着页面在服务器上渲染好再发给浏览器。
await useFetch 会在服务器上等待数据加载完成,这样用户看到的页面直接就有数据,体验更好,对 SEO 也更友好。
4.4 FastAPI 路由参数
现在后端只有一个固定的 API。如果我们想根据用户输入返回不同的数据呢?
4.4.1 路径参数
修改 backend/src/backend/__init__.py,添加新的 API:
@app.get("/api/hello/{name}")
async def hello(name: str):
return {"message": f"你好,{name}!欢迎来到全栈世界!"}现在访问 http://localhost:8000/api/hello/张三,会返回:
{"message": "你好,张三!欢迎来到全栈世界!"}路径参数语法
{name} 是路径参数的占位符,FastAPI 会自动把 URL 中对应位置的值赋给函数参数 name。
而且 FastAPI 会自动进行类型转换和验证!
4.4.2 查询参数
再添加一个带查询参数的 API:
@app.get("/api/greet")
async def greet(name: str = "朋友", mood: str = "开心"):
moods = {
"开心": "😄",
"难过": "😢",
"生气": "😠",
"惊讶": "😲",
}
emoji = moods.get(mood, "😊")
return {"message": f"{emoji} 你好,{name}!今天{mood}吗?"}访问方式:
http://localhost:8000/api/greet→ 使用默认值http://localhost:8000/api/greet?name=小明→ 指定 namehttp://localhost:8000/api/greet?name=小明&mood=惊讶→ 指定 name 和 mood
路径参数 vs 查询参数
- 路径参数:
/api/hello/{name}→/api/hello/张三 - 查询参数:
/api/greet?name=张三→ 用?和&连接
一般来说,必须的参数用路径参数,可选的参数用查询参数。
4.4.3 前端调用带参数的 API
修改 frontend/app/pages/index.vue,添加输入框和个性化问候:
<template>
<div class="container">
<h1 class="title">🎉 我的第一个全栈页面</h1>
<!-- 个性化问候 -->
<div class="name-input">
<input // [!code ++]
v-model="userName" // [!code ++]
type="text" // [!code ++]
placeholder="输入你的名字" // [!code ++]
@keyup.enter="fetchGreeting" // [!code ++]
/>
<button @click="fetchGreeting" :disabled="!userName || greetPending">
{{ greetPending ? '请求中...' : '打个招呼' }} // [!code ++]
</button>
</div>
<div v-if="greetData" class="personal-greeting">
{{ greetData.message }} // [!code ++]
</div>
<hr class="divider" />
<!-- 随机问候语 -->
<div class="greeting-box">
<div v-if="pending" class="loading">⏳ 加载中...</div>
<div v-else-if="error" class="error">❌ 出错了:{{ error.message }}</div>
<div v-else class="greeting">{{ data?.text }}</div>
<button class="refresh-btn" @click="refresh" :disabled="pending">
{{ pending ? '刷新中...' : '🔄 换一句' }}
</button>
</div>
</div>
</template>
<script setup>
// 用户名输入
const userName = ref('')
// 个性化问候
const greetData = ref(null)
const greetPending = ref(false)
async function fetchGreeting() {
if (!userName.value) return
greetPending.value = true
try {
const response = await $fetch(`http://localhost:8000/api/hello/${userName.value}`)
greetData.value = response
} catch (e) {
console.error('请求失败:', e)
} finally {
greetPending.value = false
}
}
// 随机问候语
const { data, pending, error, refresh } = await useFetch('http://localhost:8000/api/greeting')
</script>
<style scoped>
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: system-ui, sans-serif;
padding: 2rem;
}
.title {
font-size: 2.5rem;
margin-bottom: 2rem;
}
.name-input {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.name-input input {
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #ddd;
border-radius: 0.5rem;
outline: none;
transition: border-color 0.2s;
}
.name-input input:focus {
border-color: #667eea;
}
.name-input button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
background: #667eea;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.name-input button:hover:not(:disabled) {
background: #5a6fd6;
}
.name-input button:disabled {
background: #bdc3c7;
cursor: not-allowed;
}
.personal-greeting {
font-size: 1.5rem;
padding: 1rem 2rem;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
border-radius: 1rem;
margin-bottom: 1rem;
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.divider {
width: 80%;
max-width: 400px;
border: none;
border-top: 2px dashed #ddd;
margin: 2rem 0;
}
.greeting-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.loading {
color: #666;
font-size: 1.2rem;
}
.error {
color: #e74c3c;
font-size: 1.2rem;
padding: 1rem 2rem;
background: #fdeaea;
border-radius: 1rem;
}
.greeting {
font-size: 1.5rem;
padding: 1rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 1rem;
text-align: center;
min-width: 300px;
}
.refresh-btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.refresh-btn:hover:not(:disabled) {
background: #2980b9;
transform: scale(1.05);
}
.refresh-btn:disabled {
background: #bdc3c7;
cursor: not-allowed;
}
</style>🎉 恭喜!
现在你的页面真正"活"起来了!
试试看:
- 输入你的名字,点击"打个招呼"
- 按回车键也能提交
- 点击"换一句"获取随机问候语
- 注意加载状态和按钮禁用的效果
4.5 理解 $fetch
刚才我们用了 $fetch,它和 useFetch 有什么区别?
// useFetch - 组件级别的数据获取,带响应式
const { data, pending, error, refresh } = await useFetch('/api/xxx')
// $fetch - 更底层的请求函数,适合在事件处理中使用
const response = await $fetch('/api/xxx')什么时候用哪个?
- useFetch:页面加载时获取数据,需要加载状态和错误处理
- $fetch:按钮点击等事件触发的请求,手动控制状态
简单说:自动的用 useFetch,手动的用 $fetch。
4.6 Vibe Coding:让 AI 帮你写代码
写了这么多代码,累不累?
从第 3 章到现在,你已经写了不少代码了——创建项目、配置跨域、写 Vue 组件、写 FastAPI 接口...
有没有想过:能不能让 AI 帮我写?
答案是:可以! 而且这已经成为一种新的编程方式。
4.6.1 什么是 Vibe Coding?
Vibe Coding(氛围编程) 是 AI 大神 Andrej Karpathy 在 2025 年初提出的概念。
简单说就是:用自然语言告诉 AI 你想要什么,让 AI 帮你生成代码。
传统编程:你写每一行代码
Vibe Coding:你描述需求,AI 写代码,你审核和调整Andrej Karpathy 是谁?
- 前 Tesla AI 总监
- OpenAI 创始成员之一
- 斯坦福大学 CS231n(深度学习课程)讲师
他说的话在 AI 圈子里还是很有分量的。
4.6.2 常用的 AI 编程工具
| 工具 | 特点 | 适合场景 |
|---|---|---|
| GitHub Copilot | VS Code 插件,实时补全 | 写代码时自动补全 |
| Cursor | AI 原生编辑器,基于 VS Code | 整体项目开发 |
| Claude Code | 命令行工具,擅长理解上下文 | 复杂任务、重构 |
| ChatGPT / Claude | 对话式,解释和生成代码 | 学习、问问题 |
学生福利
GitHub Copilot 对学生免费!用你的学校邮箱申请 GitHub Education 即可。
4.6.3 Vibe Coding 的正确打开方式
⚠️ 重要警告:AI 不是万能的!
我见过太多这样的情况:
- 让 AI 写了一大堆代码,自己完全看不懂
- AI 生成的代码有 bug,但自己不知道怎么改
- 过度依赖 AI,基础知识一点没学到
- 面试的时候被问基础问题,一问三不知
AI 是工具,不是替代品。
正确的使用方式:
✅ 正确 ❌ 错误
───────────────────────────────────────────
先理解原理,再用 AI 加速 直接让 AI 写,完全不看
让 AI 解释代码,帮助学习 复制粘贴,不管对错
用 AI 处理重复性工作 所有代码都让 AI 写
审核 AI 生成的每一行代码 盲目信任 AI 的输出4.6.4 一个实际的例子
比如你想添加一个新功能:"在页面上显示当前时间,每秒更新"
传统方式:自己查文档、写代码、调试...
Vibe Coding 方式:
你:帮我写一个 Vue 3 组件,显示当前时间,每秒自动更新。
使用 Composition API 和 <script setup> 语法。
AI:好的,这是代码...(生成代码)
你:(审核代码)这里用 setInterval,组件销毁时需要清理吗?
AI:是的,需要在 onUnmounted 中清理...(解释并完善)关键点
注意上面的对话:
- 你知道要问什么(Vue 3、Composition API、script setup)
- 你能审核代码(发现 setInterval 需要清理)
- 你在学习(通过和 AI 对话理解原理)
这才是 Vibe Coding 的正确姿势!
4.6.5 给初学者的建议
建议
- 前几章不要用 AI 写代码——先把基础打牢
- 遇到不懂的可以问 AI——让它解释代码,帮助学习
- 后面熟练了再用 AI 加速——处理重复性工作
- 永远保持好奇心——AI 生成的代码,要知道它为什么这样写
记住:会用 AI 的程序员 和 只会让 AI 写代码的人,是完全不同的两种人。
前者是驾驭工具的人,后者是被工具驾驭的人。
4.7 小结
本章回顾
- ✅ 学习了 Vue 的
ref响应式变量 - ✅ 使用
useFetch获取数据并处理加载状态 - ✅ 实现了按钮点击刷新数据
- ✅ 学习了 FastAPI 的路径参数和查询参数
- ✅ 用
$fetch在事件中发送请求 - ✅ 了解了 Vibe Coding 和 AI 辅助编程
- ✅ 页面真正变得可交互了!
动手练习
试着扩展一下:
- 添加一个心情选择器,调用
/api/greet接口 - 给个性化问候添加错误处理(比如网络断开的情况)
- 尝试添加一个"清空"按钮,清除问候语
- (可选)试试用 AI 辅助完成上面的练习,但要确保你理解每一行代码
这些练习能帮你更好地理解响应式和 API 交互。
下一章预告
页面会交互了,但还是太简陋了。下一章我们要开发一个漂亮的个人主页,学习页面布局和组件拆分!
准备好让你的网站变好看了吗?🎨