Vue 实战项目
学了 13 篇基础,是时候把它们串起来做点真东西了。这一章给三个完整、可运行的项目——Todo App、天气查询、购物车,每个都从需求出发,给出完整代码和要点讲解。三个项目分别覆盖 Vue 3 最核心的能力:响应式状态(ref / computed / watch)、异步请求(fetch + async)、组件化(props / emit 父子通信)。建议你用 Vite 脚手架建个工程,把代码复制进去亲手跑一遍,再改需求加功能,这才是真正"上手" Vue。
实战 1:Todo App(Composition API + localStorage)
需求:做一个经典的待办事项应用。支持添加(输入回车)、完成(勾选)、删除,数据自动存到浏览器 localStorage,刷新页面也不丢。
涉及知识:Composition API 三件套——ref(基本响应式)、computed(派生数据)、watch(副作用监听);v-model 双向绑定、v-for 列表渲染、:class 动态类、localStorage 持久化。
<!-- App.vue — 待办事项 App:增删 + 完成 + localStorage 持久化 -->
<template>
<div class="todo-app">
<h1>我的待办</h1>
<input v-model="newTodo" @keyup.enter="addTodo" placeholder="输入后回车添加" />
<button @click="addTodo">添加</button>
<p class="stats">
共 {{ todos.length }} 条 · 已完成 {{ finished }}
<span v-if="remaining"> · 还剩 {{ remaining }} 条</span>
<span v-else> · 全部搞定,真棒!</span>
</p>
<ul>
<li v-for="todo in todos" :key="todo.id">
<input type="checkbox" v-model="todo.done" />
<span :class="{ done: todo.done }">{{ todo.text }}</span>
<button @click="removeTodo(todo.id)">删除</button>
</li>
</ul>
</div>
</template>
<script setup>
import { ref, computed, watch } from "vue";
// ref 让基本类型/对象变成响应式;读用 .value,模板里自动解包
const todos = ref(loadTodos());
const newTodo = ref("");
// 初始数据:从 localStorage 读,失败兜底空数组
function loadTodos() {
try {
return JSON.parse(localStorage.getItem("todos") || "[]");
} catch {
return [];
}
}
// computed 派生数据:依赖的 ref 变了自动重算,有缓存
const finished = computed(() => todos.value.filter(t => t.done).length);
const remaining = computed(() => todos.value.length - finished.value);
// watch 深度监听 todos:任何一条改动都同步到 localStorage
watch(todos, (val) => {
localStorage.setItem("todos", JSON.stringify(val));
}, { deep: true });
function addTodo() {
const text = newTodo.value.trim();
if (!text) return;
todos.value.push({ id: Date.now(), text, done: false });
newTodo.value = "";
}
function removeTodo(id) {
todos.value = todos.value.filter(t => t.id !== id);
}
</script>
<style scoped>
.todo-app { max-width: 480px; margin: 40px auto; font-family: sans-serif; }
.done { text-decoration: line-through; color: #999; }
li { list-style: none; margin: 8px 0; }
.stats { color: #666; font-size: 14px; }
input { padding: 6px; }
</style>要点讲解:
ref:把任意值包成响应式;脚本里读写要带.value,但模板里自动解包,所以{{ todos.length }}不用写.value。computed:派生数据。依赖的 ref 一变就自动重算,且有缓存(依赖没变不会重算),比 methods 里写函数更高效。watch+ deep:{ deep: true }让监听深入到数组每一项的变化——勾选、删除、新增都能捕获,然后同步写回 localStorage。v-model:双向绑定的语法糖。v-model="todo.done"让勾选框状态和todo.done同步,改一边另一边自动变。- 响应式心智:你全程没有调用
document.querySelector或手动改 DOM——只是改数据,视图自己更新。这是 Vue 与原生 JS 最大的区别。 - localStorage 兜底:用
try/catch包裹JSON.parse,防止存的数据被手动改成非法 JSON 导致整页崩溃。
实战 2:天气查询(fetch + async/await)
需求:输入城市名,调用 OpenWeatherMap 的免费 API 拿到当前天气,展示温度、天气描述、湿度风速;处理加载中、出错、查询成功三种状态。
涉及知识:async / await 异步语法、fetch 请求、try / catch / finally 异常处理、多个 ref 分管 UI 状态(loading / error / data)、:disabled 动态属性、v-if 条件渲染。先到 openweathermap.org/api 免费注册拿一个 API key。
<!-- Weather.vue — 天气查询:输入城市 → fetch 实时天气 -->
<template>
<div class="weather">
<h1>天气查询</h1>
<input v-model="city" @keyup.enter="fetchWeather" placeholder="城市名,如 Beijing" />
<button @click="fetchWeather" :disabled="loading">
{{ loading ? "查询中..." : "查询" }}
</button>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="weather" class="result">
<h2>{{ weather.name }} ({{ weather.sys.country }})</h2>
<p class="temp">{{ Math.round(weather.main.temp) }}°C</p>
<p>{{ weather.weather[0].description }}</p>
<p>湿度 {{ weather.main.humidity }}% · 风速 {{ weather.wind.speed }} m/s</p>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
// 四个独立 ref 分别管:输入值、结果、加载态、错误信息
const city = ref("Beijing");
const weather = ref(null);
const loading = ref(false);
const error = ref("");
// async 函数:用 fetch 调用 OpenWeatherMap 免费 API
async function fetchWeather() {
if (!city.value.trim()) {
error.value = "请输入城市名";
return;
}
loading.value = true;
error.value = "";
weather.value = null;
try {
const key = "YOUR_API_KEY"; // 替换为你自己的 key(openweathermap.org 免费注册)
const url = `https://api.openweathermap.org/data/2.5/weather?q=${city.value}&units=metric&lang=zh_cn&appid=${key}`;
const res = await fetch(url);
if (!res.ok) throw new Error("城市不存在或网络异常");
weather.value = await res.json();
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.weather { max-width: 480px; margin: 40px auto; font-family: sans-serif; }
.temp { font-size: 48px; font-weight: bold; margin: 10px 0; color: #4f8cff; }
.error { color: #e74c3c; }
input { padding: 6px; }
</style>要点讲解:
- 状态分离:用三个独立 ref(
loading、error、weather)而不是塞进一个大对象——每个 ref 各管一摊,模板里v-if判断更清晰。 async / await:比.then()链更像同步代码。await fetch(url)等响应回来,再await res.json()解析 body。- 错误兜底:
res.ok是 false(4xx / 5xx)时主动throw,让流程跳进catch,统一设置error.value。网络错误fetch也会直接 reject。 :disabled:查询进行时按钮置灰,避免用户连点发重复请求——这是任何"请求中"按钮的标配。- API key 安全:demo 里把 key 写在前端只为教学;真实生产应放后端代理,否则 key 会暴露在浏览器网络面板里被人盗用。
- 可扩展方向:加城市搜索联想、五日预报、根据天气切换背景图、把查询历史存进 localStorage——每个都是练手机会。
实战 3:购物车(reactive + 父子组件通信)
需求:展示一个购物车列表,每条商品能加/减数量、删除,底部实时显示总件数和总价。把单条商品拆成 CartItem 子组件,通过 props 接数据、emit 出事件。
涉及知识:reactive(对象响应式)、computed(总价/总数派生)、单文件组件 import、defineProps(父→子)、$emit(子→父)、v-for + :key。
<!-- Cart.vue — 购物车父组件:管理状态,展示商品,接收子组件事件 -->
<template>
<div class="cart">
<h1>购物车</h1>
<CartItem
v-for="item in items"
:key="item.id"
:item="item"
@increase="increase"
@decrease="decrease"
@remove="remove"
/>
<div class="summary">
<p>已选 {{ totalCount }} 件</p>
<p class="total">合计:¥{{ totalPrice.toFixed(2) }}</p>
</div>
</div>
</template>
<script setup>
import { reactive, computed } from "vue";
import CartItem from "./CartItem.vue";
// reactive 适合"对象/数组"整体响应式,改属性自动更新
const items = reactive([
{ id: 1, name: "苹果", price: 5.0, qty: 2 },
{ id: 2, name: "香蕉", price: 3.5, qty: 1 },
{ id: 3, name: "橙子", price: 4.2, qty: 3 },
]);
// computed:总件数、总价都从 items 派生,依赖变自动重算
const totalCount = computed(() => items.reduce((s, i) => s + i.qty, 0));
const totalPrice = computed(() => items.reduce((s, i) => s + i.qty * i.price, 0));
// 父组件响应子组件 emit 出来的事件
function increase(id) {
const it = items.find(i => i.id === id);
if (it) it.qty++;
}
function decrease(id) {
const it = items.find(i => i.id === id);
if (it && it.qty > 1) it.qty--;
}
function remove(id) {
const idx = items.findIndex(i => i.id === id);
if (idx !== -1) items.splice(idx, 1);
}
</script>
<style scoped>
.cart { max-width: 480px; margin: 40px auto; font-family: sans-serif; }
.summary { margin-top: 16px; padding-top: 12px; border-top: 1px solid #eee; text-align: right; }
.total { font-size: 20px; font-weight: bold; color: #e74c3c; }
</style>
<!-- ============================================================ -->
<!-- CartItem.vue — 子组件:展示单条商品,通过 emit 通知父组件 -->
<!-- ============================================================ -->
<template>
<div class="item">
<span class="name">{{ item.name }}</span>
<span class="price">¥{{ item.price.toFixed(2) }}</span>
<button @click="$emit('decrease', item.id)">-</button>
<span class="qty">{{ item.qty }}</span>
<button @click="$emit('increase', item.id)">+</button>
<button class="del" @click="$emit('remove', item.id)">删除</button>
</div>
</template>
<script setup>
// defineProps 是编译器宏,无需 import;声明父组件传入的 item
defineProps({
item: { type: Object, required: true },
});
</script>
<style scoped>
.item { display: flex; align-items: center; gap: 8px; padding: 8px 0; }
.name { width: 80px; }
.price { width: 60px; color: #666; }
.qty { width: 30px; text-align: center; }
button { padding: 2px 8px; cursor: pointer; }
.del { color: #e74c3c; margin-left: 8px; }
</style>要点讲解:
reactivevsref:对象 / 数组推荐reactive,访问不用带.value,直接items.push(...)就行;基本类型只能用ref。- 单向数据流:父用
:item="item"把数据传给子组件(props 向下),子组件不直接改父的数据,而是$emit('increase', id)通知(事件向上)。这是 Vue 父子通信的铁律。 defineProps:编译器宏,声明能接收哪些 props。无需 import,且能声明类型 / 默认值 / 必填,自带运行时校验。$emit携带参数:第二个参数item.id会传到父组件事件处理函数increase(id),父组件据此修改源数据。:key必填:v-for必须给稳定的 key(这里是item.id),让 Vue 高效复用 DOM;用数组下标当 key 在删项时会引发状态错乱。- computed 自动重算:加减数量改的是
items,totalCount/totalPrice这两个 computed 依赖items,自动更新——你完全不用手动调"重新计算总价"。
小结
三个项目分别对应 Vue 三大核心场景:状态管理(Todo)、异步交互(天气)、组件化(购物车)。它们都用到前面学的基础——ref / reactive / computed / watch、指令、事件、props / emit——只是组合起来解决真实问题。建议你至少把其中一个改造成自己的版本:给 Todo 加优先级和分类、给天气加多日预报、给购物车加优惠券计算——做过一遍比读十遍更扎实。
恭喜!到这里 Vue 系列就真正完结了。接下来挑一个方向深入——结合 Vue Router + Pinia 做完整后台、用 Nuxt 做 SSR、用 Vitepress 搭文档站、读 Element Plus 源码——边做边学,进步最快。
← 上一篇 Vue 状态管理
返回 Vue 教程目录