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>

要点讲解:

实战 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>

要点讲解:

实战 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>

要点讲解:

小结

三个项目分别对应 Vue 三大核心场景:状态管理(Todo)、异步交互(天气)、组件化(购物车)。它们都用到前面学的基础——ref / reactive / computed / watch、指令、事件、props / emit——只是组合起来解决真实问题。建议你至少把其中一个改造成自己的版本:给 Todo 加优先级和分类、给天气加多日预报、给购物车加优惠券计算——做过一遍比读十遍更扎实。

恭喜!到这里 Vue 系列就真正完结了。接下来挑一个方向深入——结合 Vue Router + Pinia 做完整后台、用 Nuxt 做 SSR、用 Vitepress 搭文档站、读 Element Plus 源码——边做边学,进步最快。

← 上一篇 Vue 状态管理

返回 Vue 教程目录

✈️💬