今天我们将把前面学到的所有知识(响应式、组件通信、Composables、Pinia、Router)融合在一起,构建一个功能完整、代码规范的 智能天气查询应用 (Smart Weather Dashboard)。
相比 Todo List,天气应用更能体现异步请求、状态管理、动态路由和逻辑复用。
🌤️ 项目目标:Smart Weather Dashboard
功能列表:
- 搜索城市:输入城市名,获取实时天气。
- 历史记录:自动保存最近搜索的 5 个城市(使用 Pinia + localStorage)。
- 多页面:
- 首页 (
/):显示当前天气和搜索框。 - 历史页 (
/history):显示搜索历史列表。
- 首页 (
- 自定义 Hook:封装
useWeather处理 API 请求逻辑。 - UI 风格:使用原生 CSS + 简单的响应式布局(无需额外 UI 库,保持轻量)。
⚠️ 强制要求:全程使用
<script setup>和 Composition API。禁止出现export default { data() {} }。
🛠️ 第一步:项目初始化
# 1. 创建项目 (选择 Vue + Router)
npm create vue@latest weather-app
cd weather-app
npm install# 2. 安装 Pinia 和 Axios (用于请求)
npm install pinia axios# 3. 启动
npm run dev
注:为了演示方便,我们将使用 Open-Meteo 免费 API,它不需要 API Key,直接可用。
🧠 第二步:状态管理 (Pinia)
我们需要一个 Store 来管理搜索历史,这样即使刷新页面,历史记录依然存在。
文件: src/stores/history.js
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'export const useHistoryStore = defineStore('history', () => {// 初始化:从 localStorage 读取const saved = localStorage.getItem('weather_history')const cities = ref(saved ? JSON.parse(saved) : [])// 监听变化,自动同步到 localStoragewatch(cities, (newVal) => {localStorage.setItem('weather_history', JSON.stringify(newVal))}, { deep: true })// 添加城市到历史 (去重,并保持最新在前)function addCity(cityData) {const index = cities.value.findIndex(c => c.id === cityData.id)if (index !== -1) {// 如果已存在,移到最前面cities.value.splice(index, 1)}cities.value.unshift(cityData)// 只保留最近 5 个if (cities.value.length > 5) {cities.value.pop()}}function clearHistory() {cities.value = []}return { cities, addCity, clearHistory }
})
🔌 第三步:逻辑复用 (Composable)
这是 Vue 3 的精髓。我们将天气查询逻辑完全剥离到 useWeather.js 中,组件只负责展示。
文件: src/composables/useWeather.js
import { ref } from 'vue'
import axios from 'axios'export function useWeather() {const weatherData = ref(null)const loading = ref(false)const error = ref(null)// Open-Meteo 需要先通过地理编码 API 获取经纬度// 这里为了简化,我们假设用户输入的是标准城市名,实际项目中通常需要专门的 Geocoding API// 演示用:硬编码几个城市的坐标映射,或使用 open-meteo 的 geocoding 接口const fetchWeather = async (cityName) => {loading.value = trueerror.value = nullweatherData.value = nulltry {// 1. 先获取城市经纬度 (使用 Open-Meteo Geocoding API)const geoRes = await axios.get('https://geocoding-api.open-meteo.com/v1/search', {params: { name: cityName, count: 1, language: 'zh', format: 'json' }})if (!geoRes.data.results || geoRes.data.results.length === 0) {throw new Error('未找到该城市,请检查拼写')}const { latitude, longitude, name, country } = geoRes.data.results[0]// 2. 根据经纬度获取天气const weatherRes = await axios.get('https://api.open-meteo.com/v1/forecast', {params: {latitude,longitude,current_weather: true,timezone: 'auto'}})const current = weatherRes.data.current_weather// 组装数据weatherData.value = {city: name,country: country,temp: current.temperature,wind: current.windspeed,code: current.weathercode,time: current.time}return { id: `${latitude}-${longitude}`, name, country } // 返回用于存储历史的信息} catch (err) {error.value = err.message || '请求失败'return null} finally {loading.value = false}}return {weatherData,loading,error,fetchWeather}
}
🏠 第四步:核心组件开发
1. 首页组件 (src/views/HomeView.vue)
<script setup>
import { ref } from 'vue'
import { useWeather } from '../composables/useWeather'
import { useHistoryStore } from '../stores/history'
import { useRouter } from 'vue-router'const cityInput = ref('')
const { weatherData, loading, error, fetchWeather } = useWeather()
const historyStore = useHistoryStore()
const router = useRouter()const handleSearch = async () => {if (!cityInput.value.trim()) returnconst result = await fetchWeather(cityInput.value.trim())if (result) {// 搜索成功,添加到历史historyStore.addCity(result)}
}
</script><template><div class="home-container"><h1>🌤️ 智能天气查询</h1><!-- 搜索框 --><div class="search-box"><input v-model="cityInput" @keyup.enter="handleSearch"placeholder="输入城市名 (如: Beijing, Shanghai, London)" /><button @click="handleSearch" :disabled="loading">{{ loading ? '查询中...' : '搜索' }}</button></div><!-- 错误提示 --><div v-if="error" class="error-msg">❌ {{ error }}</div><!-- 天气展示卡片 --><div v-if="weatherData" class="weather-card"><h2>{{ weatherData.city }}, {{ weatherData.country }}</h2><div class="temp">{{ weatherData.temp }}°C</div><div class="details"><span>🍃 风速: {{ weatherData.wind }} km/h</span><span>🕒 更新时间: {{ new Date(weatherData.time).toLocaleString() }}</span></div><p class="hint">天气代码: {{ weatherData.code }}</p></div><!-- 空状态 --><div v-if="!loading && !weatherData && !error" class="empty-state">👆 输入城市开始查询</div><div class="nav-link"><router-link to="/history">查看搜索历史 →</router-link></div></div>
</template><style scoped>
.home-container { max-width: 600px; margin: 40px auto; text-align: center; }
.search-box { display: flex; gap: 10px; margin-bottom: 20px; }
input { flex: 1; padding: 10px; border-radius: 6px; border: 1px solid #ddd; font-size: 16px; }
button { padding: 10px 20px; background: #42b983; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 16px; }
button:disabled { background: #ccc; }
.weather-card { background: #f9f9f9; padding: 20px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.temp { font-size: 48px; font-weight: bold; color: #42b983; margin: 10px 0; }
.details { display: flex; justify-content: space-around; color: #666; }
.error-msg { color: #ff4d4f; margin-bottom: 20px; }
.nav-link { margin-top: 30px; }
a { color: #42b983; text-decoration: none; }
</style>
2. 历史页组件 (src/views/HistoryView.vue)
<script setup>
import { useHistoryStore } from '../stores/history'
import { useRouter } from 'vue-router'const historyStore = useHistoryStore()
const router = useRouter()const goToCity = (cityName) => {// 编程式导航回首页,并可以通过 query 传递参数(可选)// 这里简单跳转,用户在首页再次输入或我们可以优化 useWeather 支持直接加载router.push('/')// 实际项目中可以触发一个全局事件或直接调用 store 动作来预加载alert(`即将查询:${cityName} (请在首页输入)`);
}const clearAll = () => {if(confirm('确定清空历史吗?')) {historyStore.clearHistory()}
}
</script><template><div class="history-container"><h1>📜 搜索历史</h1><div v-if="historyStore.cities.length === 0" class="empty">暂无历史记录</div><ul v-else class="history-list"><li v-for="(city, index) in historyStore.cities" :key="index" @click="goToCity(city.name)"><span class="name">{{ city.name }}</span><span class="country">{{ city.country }}</span></li></ul><button v-if="historyStore.cities.length > 0" @click="clearAll" class="clear-btn">清空历史</button><div class="nav-link"><router-link to="/">← 返回首页</router-link></div></div>
</template><style scoped>
.history-container { max-width: 600px; margin: 40px auto; }
.history-list { list-style: none; padding: 0; }
.history-list li {background: white; border: 1px solid #eee;padding: 15px; margin-bottom: 10px;border-radius: 8px; cursor: pointer;display: flex; justify-content: space-between;transition: transform 0.2s;
}
.history-list li:hover { transform: translateX(5px); border-color: #42b983; }
.clear-btn { background: #ff4d4f; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; width: 100%; margin-top: 20px;}
.nav-link { margin-top: 20px; text-align: center; }
</style>
🔗 第五步:路由配置 (src/router/index.js)
确保路由已经配置好这两个视图。
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import HistoryView from '../views/HistoryView.vue'const router = createRouter({history: createWebHistory(import.meta.env.BASE_URL),routes: [{ path: '/', name: 'home', component: HomeView },{ path: '/history', name: 'history', component: HistoryView }]
})export default router
🚀 运行与测试
- 运行
npm run dev。 - 在首页输入 "Beijing",点击搜索。
- 观察:Loading 状态 -> 显示天气卡片。
- 检查:浏览器 LocalStorage 中是否多了
weather_history字段。
- 点击“查看搜索历史”。
- 观察:列表中出现 "Beijing"。
- 再搜索 "London"。
- 观察:历史记录更新,London 排在第一位。
- 刷新页面。
- 观察:历史记录依然存在(Pinia + localStorage 生效)。
💡 核心知识点复盘 (Checklist)
在这个小项目中,你强制使用了以下 Vue 3 特性:
- ✅
<script setup>: 所有组件都使用了语法糖,代码简洁。 - ✅ 响应式 API:
ref(cityInput, weatherData),computed(虽未显式使用但逻辑中包含),watch(Pinia 中监听持久化)。 - ✅ Composables:
useWeather完美封装了异步逻辑和状态,组件变得极其干净。 - ✅ Pinia:
useHistoryStore实现了跨组件(虽然目前只有两个页面,但逻辑是解耦的)的状态共享和持久化。 - ✅ Vue Router: 实现了页面切换和编程式导航。
- ✅ 单向数据流: 搜索框修改本地 state -> 触发 action -> 更新 store -> 视图更新。
🎓 毕业总结
恭喜你!完成了 Vue 3 五日速成训练营。
你已经从一个对 Vue 3 陌生的开发者,成长为能够:
- 理解并运用 Composition API 思维。
- 设计可复用的 Composables。
- 构建基于 Pinia 和 Router 的企业级应用架构。
- 独立开发完整的 SPA 单页应用。
接下来的进阶之路:
- TypeScript: 尝试将上面的 JS 代码全部重构为 TS,体验类型安全的快感。
- UI 框架: 引入 Element Plus 或 Naive UI,快速美化界面。
- 服务端渲染 (SSR): 学习 Nuxt 3,它是 Vue 3 的全能框架,解决 SEO 和首屏速度问题。
- 测试: 学习 Vitest 和 Vue Test Utils,为你的组件编写单元测试。
Vue 的世界非常广阔,保持好奇,继续编码吧!💻🔥
