3个坑填平,手写实现天气预报模块
学会语法却不知怎么搭项目?这是很多初级开发者的通病。代码能跑,一集成就崩,或者性能差到没法看。今天不整虚的,直接上手手写实现一个完整的天气预报模块。
从数据获取到前端展示,全流程拆解。你会看到如何绕过常见的API陷阱,如何设计可扩展的目录结构,以及如何优化加载体验。
项目目标与核心难点
别急着写代码,先想清楚我们要解决什么。
一个合格的天气预报模块,核心诉求只有三个:准(数据实时)、快(响应迅速)、稳(异常可兜底)。
很多教程只教你怎么调API,却忽略了“稳”。比如,用户所在的城市API突然挂了怎么办?缓存过期了怎么办?网络波动导致JSON解析失败怎么办?
我们的目标很明确:
- 零依赖前端:原生JavaScript + CSS,不引入Vue/React,方便你理解底层逻辑。
- 后端代理:使用Node.js + Express做中间层,隐藏API Key,处理跨域。
- 本地缓存:利用LocalStorage或Redis(简化版用Local)降低请求频率,提升加载速度。
- 容错机制:API失败时,展示离线数据或友好提示,而不是白屏。
这个结构虽然简单,但涵盖了真实项目中的90%痛点。
目录结构设计
工欲善其事,必先利其器。混乱的文件结构是维护噩梦。
建议采用如下结构,清晰分层:
weather-app/
├── client/ # 前端资源
│ ├── index.html # 入口页面
│ ├── css/
│ │ └── style.css # 样式文件
│ └── js/
│ ├── main.js # 主逻辑
│ ├── api.js # 网络请求封装
│ └── utils.js # 工具函数(格式化时间、单位转换)
├── server/ # 后端服务
│ ├── index.js # 服务器入口
│ ├── config.js # 配置文件(API Key等)
│ └── routes/
│ └── weather.js# 路由处理
├── package.json # 依赖管理
└── README.md
为什么要把API Key放在后端?
这是安全底线。如果在前端代码里直接写API Key,任何人都能F12查看并盗用。虽然OpenWeatherMap等部分API支持限制来源IP,但通过后端代理是最稳妥的做法。
关于模块化:
api.js 只负责发请求,utils.js 只负责数据加工,main.js 负责UI渲染。这样当你要换数据源(比如从OpenWeatherMap换成和风天气)时,只需要改 api.js,其他代码几乎不用动。
核心代码实现
下面进入硬核部分。我们将分前端和后端两部分讲解。
1. 后端:Node.js 代理服务
创建 server/index.js。这里我们使用 Express 框架,因为它轻量且生态丰富。
const express = require('express');
const http = require('http');
const path = require('path');
const { WEATHER_API_KEY } = require('./config');const app = express();
const server = http.createServer(app);// 1. 设置JSON解析
app.use(express.json());// 2. 静态文件服务,方便开发时直接访问前端
app.use(express.static(path.join(__dirname, '../client')));// 3. 核心接口:/api/weather
app.get('/api/weather', async (req, res) => {const { city } = req.query;// 参数校验:防止恶意请求if (!city) {return res.status(400).json({ error: 'City name is required' });}try {// 假设使用 OpenWeatherMap 免费APIconst apiEndpoint = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${WEATHER_API_KEY}&units=metric`;// 使用 fetch (Node 18+ 内置) 或 axiosconst response = await fetch(apiEndpoint);if (!response.ok) {throw new Error(`API Error: ${response.status}`);}const data = await response.json();// 4. 数据预处理:只返回前端需要的字段,减少传输体积const formattedData = {city: data.name,temp: Math.round(data.main.temp),feelsLike: Math.round(data.main.feels_like),humidity: data.main.humidity,description: data.weather[0].description,icon: data.weather[0].icon,windSpeed: data.wind.speed};res.json(formattedData);} catch (error) {console.error('Fetch Error:', error);// 5. 异常捕获:返回统一错误格式res.status(500).json({ error: 'Failed to fetch weather data', message: error.message });}
});// 6. 启动服务
const PORT = 3000;
server.listen(PORT, () => {console.log(`Server running on http://localhost:${PORT}`);
});
关键点解析:
encodeURIComponent(city):城市名可能包含中文或空格,必须进行URL编码,否则API会报错404。这是新手最常踩的坑之一。- 数据裁剪:API返回的JSON可能有几KB,我们只挑温度、湿度、图标等必要字段。这在移动端弱网环境下至关重要。
- 异常处理:
try-catch包裹整个异步操作。如果API超时或网络断开,前端能收到500状态码,从而展示友好提示,而不是卡死。
2. 前端:原生JS请求与渲染
打开 client/js/api.js,封装一个通用的请求函数。
// api.js
export const fetchWeather = async (city) => {try {const response = await fetch(`/api/weather?city=${encodeURIComponent(city)}`);if (!response.ok) {throw new Error('Network response was not ok');}return await response.json();} catch (error) {console.error('Fetch failed:', error);// 这里可以抛出特定错误,让 main.js 处理throw error;}
};
接着是 client/js/main.js,负责UI交互。
// main.js
import { fetchWeather } from './api.js';const searchBtn = document.getElementById('search-btn');
const cityInput = document.getElementById('city-input');
const weatherDisplay = document.getElementById('weather-display');
const loadingIndicator = document.getElementById('loading');
const errorMsg = document.getElementById('error-msg');// 1. 事件监听
searchBtn.addEventListener('click', handleSearch);
cityInput.addEventListener('keypress', (e) => {if (e.key === 'Enter') handleSearch();
});async function handleSearch() {const city = cityInput.value.trim();// 前端二次校验if (!city) {showError('请输入城市名称');return;}// 2. 状态管理:显示LoadingshowLoading(true);hideError();hideWeather();try {// 3. 发起请求const data = await fetchWeather(city);// 4. 渲染数据renderWeather(data);} catch (error) {// 5. 错误处理console.error('Error:', error);showError('获取天气失败,请稍后重试');} finally {// 6. 无论成功失败,都要关闭LoadingshowLoading(false);}
}function renderWeather(data) {const html = `<div class="weather-card"><h2>${data.city}</h2><div class="temp">${data.temp}°C</div><p>${data.description}</p><div class="details"><span>体感: ${data.feelsLike}°C</span><span>湿度: ${data.humidity}%</span><span>风速: ${data.windSpeed} m/s</span></div><img src="https://openweathermap.org/img/wn/${data.icon}@2x.png" alt="Weather Icon"></div>`;weatherDisplay.innerHTML = html;weatherDisplay.style.display = 'block';
}// 辅助函数:Loading, Error, Hide
function showLoading(show) {loadingIndicator.style.display = show ? 'block' : 'none';
}function showError(msg) {errorMsg.textContent = msg;errorMsg.style.display = 'block';
}function hideError() {errorMsg.style.display = 'none';
}function hideWeather() {weatherDisplay.style.display = 'none';
}
这段代码体现了什么工程思维?
- 状态分离:Loading、Error、Success 是三种互斥状态。
finally块确保 Loading 一定会消失,避免UI卡住。 - DOM操作最小化:使用
innerHTML一次性注入模板,而不是逐个创建节点。虽然不如虚拟DOM高效,但在简单场景下性能足够,且代码可读性更好。 - 异步/await:避免了回调地狱,逻辑线性清晰。
运行与测试
代码写完了,怎么验证?
安装依赖:
npm install express(注:Node.js 18+ 内置 fetch,无需额外安装 axios 或 node-fetch)
启动服务:
node server/index.js访问测试: 打开浏览器访问
http://localhost:3000。
测试用例:
- 正常场景:输入 "Beijing",检查是否显示正确温度和图标。
- 异常场景1:输入空字符串,点击搜索。应显示“请输入城市名称”。
- 异常场景2:输入不存在的城市 "Xyzabc"。API应返回404,后端捕获后返回500,前端显示“获取天气失败”。
- 网络断开:拔掉网线,点击搜索。
fetch会抛出 TypeError,前端同样捕获并显示错误提示。
这里有一个重要的测试技巧:使用浏览器的开发者工具,将网络状态设置为 "Slow 3G"。你会发现 Loading 动画变得非常重要。如果此时页面没有反馈,用户会疯狂点击按钮,导致并发请求堆积。
优化扩展
基础功能跑通后,怎么让它更像生产级产品?
1. 增加本地缓存(LocalStorage)
用户每次刷新页面都请求API是浪费资源。我们可以这样优化:
在 main.js 的 handleSearch 中:
async function handleSearch() {const city = cityInput.value.trim();if (!city) return;// 1. 先查本地缓存const cachedData = localStorage.getItem(`weather_${city}`);if (cachedData) {const parsedData = JSON.parse(cachedData);// 判断缓存是否过期(例如1小时)if (Date.now() - parsedData.timestamp < 3600000) {renderWeather(parsedData.data);return;}}// 2. 缓存失效,请求APIshowLoading(true);try {const data = await fetchWeather(city);// 3. 存入缓存localStorage.setItem(`weather_${city}`, JSON.stringify({data: data,timestamp: Date.now()}));renderWeather(data);} catch (error) {// 4. API失败,尝试使用过期缓存(降级策略)if (cachedData) {const parsedData = JSON.parse(cachedData);renderWeather(parsedData.data);showError('数据可能已过期');} else {showError('获取天气失败');}} finally {showLoading(false);}
}
这个策略叫“Stale-While-Revalidate”。即使网络断了,用户也能看到上一次的数据,体验不会中断。
2. 地理定位(Geolocation)
让用户手动输入城市很麻烦。我们可以自动获取位置。
在 main.js 添加:
if (navigator.geolocation) {navigator.geolocation.getCurrentPosition((position) => {// 拿到经纬度后,逆地理编码获取城市名,或者直接用经纬度调API// 这里简化处理,假设我们能用经纬度查天气fetchWeatherByCoords(position.coords.latitude, position.coords.longitude);},(error) => {console.log('定位失败,请手动输入城市');});
}
注意:浏览器会弹出权限请求。如果用户拒绝,必须提供手动输入框作为兜底。
3. 防抖搜索
如果用户想实现“输入即搜索”(Input Search),必须加防抖。
function debounce(func, wait) {let timeout;return function executedFunction(...args) {const later = () => {clearTimeout(timeout);func(...args);};clearTimeout(timeout);timeout = setTimeout(later, wait);};
}// 使用
const debouncedSearch = debounce(handleSearch, 500);
cityInput.addEventListener('input', debouncedSearch);
这能避免用户每敲一个字母就发一次请求,极大节省API配额。
小结与避坑指南
回顾一下,我们从零搭建了一个手写实现的天气预报模块。
常见避坑清单:
- 跨域问题:永远不要在前端直接调第三方API,一定要过一遍自己的后端。
- API Key泄露:前端代码是公开的,Key放在后端配置文件中。
- 单位混淆:OpenWeatherMap 默认单位是开尔文(K),记得加
&units=metric转成摄氏度(C),或者在前端做K - 273.15转换。 - 时区问题:API返回的时间戳通常是UTC时间。展示给中国用户时,需要转换为本地时区(
new Date(timestamp * 1000).toLocaleString('zh-CN'))。
这个模块的价值:
它不仅仅是一个天气查询工具,更是一个微服务架构的雏形。你可以把天气模块封装成一个独立的Service,通过RESTful API暴露给其他项目调用。
在实际工作中,这种“小而美”的模块往往比大而全的系统更容易落地和复用。
最后,抛出一个问题给你:
你公司项目里是怎么处理第三方API依赖的?是直接前端调用,还是统一走网关代理?对于API限流(Rate Limiting),你们是用 Redis 令牌桶,还是简单的本地内存计数?
欢迎在评论区分享你的实战经验,我们一起交流踩坑心得。