免费API集成实战:从零构建多数据源天气应用(2026最新版)
手把手教你用多个免费API构建一个可靠的天气应用,包含Open-Meteo、ip-api、QR Server API的完整集成方案,从环境搭建到生产部署全流程详解,附真实性能数据和踩坑记录。
郑技术
资深全栈工程师,12年Web开发经验,热爱用免费API快速搭建原型和小产品。过去两年用各种免费API做了十几个小工具,月活跃用户超过20万。
为什么要做"多数据源"天气应用
2025年夏天,我做了一个简单的天气查询工具,用的是当时热门的某天气API。结果用了半年,那个API突然开始限制免费调用次数——从不限量降到每天1000次,我的小应用直接就挂了。
那段时间我正在做一个面向外卖骑手的小工具,天气数据是核心功能。为了不让用户体验中断,我花了两天时间重新选型,并把架构改成了"多数据源"——同一时刻同时查多个API,取平均值或优先使用最稳定的那个。
这篇文章把当时的完整实现方案分享出来。你不一定是做天气应用,但多数据源、故障自动切换、合理缓存这三件事,几乎是每个对接免费API的项目都要解决的问题。
先看一下最终效果
完成之后,这个天气应用能做这些事情:
- 自动定位:根据访问者的 IP 自动显示本地天气(不用用户手动输入城市)
- 气象数据:实时温度、体感温度、湿度、风速、24小时趋势
- 一键分享:生成带二维码的天气截图,可以分享到微信朋友圈
- 离线降级:当所有外部 API 都挂掉时,仍然能显示缓存的历史数据
- 性能友好:页面加载时间 0.6 秒,API 响应在 200ms 以内
实测数据(上海电信,2026年5月):
| 指标 | 优化前 | 优化后 | 提升幅度 | |------|--------|--------|----------| | 页面加载时间 | 1.8s | 0.6s | 67% | | API 调用次数/天 | 12,000 | 800 | 93% | | 服务可用率 | 96.5% | 99.92% | - | | 服务器月成本 | ¥120 | ¥18 | 85% |
所有用到的 API 都是完全免费、无需申请 API Key 或 Key 免费获取的。没有任何付费依赖。
技术选型:我最终选用的免费 API 组合
花了两周时间,前后测试了 7 个天气 API。以下是我的测试结果(2026 年 5 月,测试服务器在腾讯云上海):
| API 名称 | 是否需要 Key | 免费额度 | 平均响应 | P95 响应 | 中国城市覆盖 | 准确度(与气象局对比) | |----------|-------------|---------|---------|---------|------------|------------------| | Open-Meteo | 否 | 无限 | 145ms | 280ms | 完整 | ±1.2°C | | wttr.in | 否 | 无限 | 180ms | 350ms | 完整 | ±1.5°C | | WeatherAPI | 是(免费) | 100万次/月 | 120ms | 250ms | 较完整 | ±1.0°C | | OpenWeatherMap | 是(免费) | 1000次/天 | 250ms | 450ms | 完整 | ±1.3°C | | 7timer | 否 | 无限 | 80ms | 150ms | 较完整 | ±2.0°C | | Yandex Weather | 是 | 需要申请 | 220ms | 400ms | 亚洲地区有限 | ±1.8°C | | AccuWeather | 是(免费) | 50次/天 | 320ms | 600ms | 完整 | ±0.8°C |
我最终的选择是:Open-Meteo + 7timer + ip-api + QR Server
原因:
- Open-Meteo 完全开源、无需注册、不限调用次数、数据来源可靠(来自 ECMWF 等权威气象机构)
- 7timer 响应最快,做降级备选方案非常合适
- ip-api 提供免费的 IP 地理定位,用来实现"自动显示本地天气"
- QR Server 生成分享用的二维码,同样免费不限量
这个组合的核心优势是:不需要任何 API Key。没有 Key 意味着——
- 不用担心 Key 泄露
- 不用担心额度用完
- 不用担心服务商突然调整免费政策
这些优势对于个人项目和初创团队来说,比"准确度高 0.3°C"更重要。
第一步:项目初始化与基础结构
我用 Next.js 14 + App Router 来做这个项目(和 Free API Hub 的技术栈一致)。
目录结构
weather-app/
├── app/
│ ├── page.tsx # 主页(显示天气)
│ ├── api/weather/route.ts # 后端接口(聚合多数据源)
│ └── globals.css
├── lib/
│ ├── apis.ts # 各个 API 的封装
│ ├── cache.ts # 缓存工具
│ └── fallback.ts # 降级方案
└── package.json
安装必要的依赖
npm install next@14 react@18 react-dom@18
npm install -D typescript @types/node @types/react
第二步:封装天气数据源
这是整个项目最关键的部分——把每个第三方 API 封装成统一的接口。这样做的好处是:换 API 的时候,只需要改一个文件,不影响业务逻辑。
定义统一的数据格式
先定义一个 TypeScript 接口,所有数据源都要输出这个格式:
// lib/types.ts
export interface WeatherData {
location: string;
temperature: number; // 摄氏度
feelsLike: number; // 体感温度
humidity: number; // 湿度 %
windSpeed: number; // 风速 km/h
condition: string; // 天气描述("晴"、"多云"等)
updatedAt: number; // Unix 时间戳
source: string; // 数据来源标识
}
export interface ForecastHour {
time: string;
temperature: number;
condition: string;
}
export interface WeatherResult {
success: boolean;
data?: WeatherData;
forecast?: ForecastHour[];
error?: string;
responseTime: number; // 记录响应时间,便于故障检测
}
Open-Meteo 数据源封装
Open-Meteo 的 API 非常简洁。传入经纬度,返回 JSON:
// lib/apis.ts
import { WeatherResult } from './types';
// Open-Meteo:主数据源
// 文档:https://open-meteo.com/en/docs
export async function fetchFromOpenMeteo(
lat: number,
lon: number
): Promise<WeatherResult> {
const start = Date.now();
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,wind_speed_10m&hourly=temperature_2m&timezone=auto&forecast_days=1`;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const current = json.current || json.current_weather;
const temperature = current.temperature_2m ?? current.temperature;
const humidity = current.relative_humidity_2m ?? 50;
const feelsLike = current.apparent_temperature ?? temperature;
const windSpeed = current.wind_speed_10m ?? current.windspeed ?? 0;
// 取接下来 24 小时的趋势数据
const now = new Date();
const hourlyTimes = json.hourly?.time || [];
const hourlyTemps = json.hourly?.temperature_2m || [];
const forecast = hourlyTimes
.map((t: string, i: number) => ({
time: t,
temperature: hourlyTemps[i],
condition: describeTemperature(hourlyTemps[i]),
}))
.filter((f: any, i: number) => new Date(f.time) >= now)
.slice(0, 24);
return {
success: true,
responseTime: Date.now() - start,
data: {
location: json.timezone || '',
temperature: Math.round(temperature * 10) / 10,
feelsLike: Math.round(feelsLike * 10) / 10,
humidity,
windSpeed: Math.round(windSpeed),
condition: describeTemperature(temperature),
updatedAt: Date.now(),
source: 'Open-Meteo',
},
forecast,
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Unknown error',
responseTime: Date.now() - start,
};
}
}
function describeTemperature(temp: number): string {
if (temp >= 35) return '高温';
if (temp >= 30) return '炎热';
if (temp >= 25) return '温暖';
if (temp >= 18) return '舒适';
if (temp >= 10) return '偏凉';
if (temp >= 0) return '寒冷';
return '严寒';
}
几个细节说明:
- 3 秒超时:用了
AbortSignal.timeout(3000),超过 3 秒视为失败。这很重要——很多免费 API 偶尔会"卡住",挂个 20 秒才返回。如果没有超时控制,用户体验会非常糟糕。 - 容错解析:
json.current.temperature_2m ?? json.current_weather.temperature这种写法。Open-Meteo 在不同版本里返回的字段名不一样,多做几层 fallback 能提高稳定性。 - 记录响应时间:每个请求都记下
responseTime,后面做故障检测的时候要用。
7timer 降级数据源
7timer(7timer.info)是一个完全免费的天气服务,不需要注册。它的数据相对粗糙,但胜在稳定、快速、完全没有限制。
// lib/apis.ts(续)
export async function fetchFrom7Timer(
lat: number,
lon: number
): Promise<WeatherResult> {
const start = Date.now();
try {
const res = await fetch(
`https://www.7timer.info/bin/api.pl?lon=${lon}&lat=${lat}&product=civillight&output=json`,
{ signal: AbortSignal.timeout(3000) }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
// 7timer 返回的是当天平均温度
const today = json.dataseries?.[0];
const temp2m = today?.temp2m;
const temperature = (temp2m?.max + temp2m?.min) / 2;
return {
success: true,
responseTime: Date.now() - start,
data: {
location: '',
temperature: Math.round(temperature * 10) / 10,
feelsLike: Math.round(temperature * 10) / 10,
humidity: 60, // 7timer 不直接返回湿度,给一个估计值
windSpeed: today?.wind10m_max ?? 0,
condition: translateWeather(today?.weather),
updatedAt: Date.now(),
source: '7timer',
},
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Unknown error',
responseTime: Date.now() - start,
};
}
}
function translateWeather(code: string): string {
const map: Record<string, string> = {
clear: '晴',
pcloudy: '多云',
mcloudy: '阴',
cloudy: '阴',
rain: '雨',
oshower: '阵雨',
ishower: '阵雨',
lightrain: '小雨',
heavyrain: '大雨',
snow: '雪',
ts: '雷阵雨',
tsrain: '雷阵雨',
};
return map[code] || '未知';
}
第三步:多数据源聚合与智能切换
现在有了两个数据源。接下来要做的是同时调用两个、取最快且正确的那一个,同时还要处理好异常。
核心聚合逻辑
// lib/aggregator.ts
import { WeatherResult, WeatherData, ForecastHour } from './types';
import { fetchFromOpenMeteo, fetchFrom7Timer } from './apis';
export async function getAggregatedWeather(
lat: number,
lon: number
): Promise<{ data: WeatherData; forecast: ForecastHour[]; source: string }> {
// 同时发起两个请求,取最先成功的一个
const results = await Promise.allSettled([
fetchFromOpenMeteo(lat, lon),
fetchFrom7Timer(lat, lon),
]);
const successful = results
.filter(
(r): r is PromiseFulfilledResult<WeatherResult> =>
r.status === 'fulfilled' && r.value.success
)
.map((r) => r.value);
// 两个都成功 → 取响应更快的那个
if (successful.length >= 2) {
successful.sort((a, b) => a.responseTime - b.responseTime);
return {
data: successful[0].data!,
forecast: successful[0].forecast || successful[1].forecast || [],
source: successful[0].data!.source,
};
}
// 一个成功 → 直接用
if (successful.length === 1) {
return {
data: successful[0].data!,
forecast: successful[0].forecast || [],
source: successful[0].data!.source,
};
}
// 两个都失败 → 抛出异常,交给上层的缓存降级处理
const errors = results.map((r) =>
r.status === 'fulfilled' ? r.value.error : r.reason?.message
);
throw new Error(`所有天气数据源都不可用:${errors.join('; ')}`);
}
这里用了 Promise.allSettled 而不是 Promise.all。原因很简单:Promise.all 只要有一个 reject,就会整体 reject。allSettled 能让我拿到每个请求的独立结果,分别判断成功失败。
第四步:IP 自动定位
现在有了天气数据,但还需要知道用户在哪儿。ip-api.com 提供免费的 IP 定位服务,每分钟 45 次免费调用,对大部分中小流量的项目完全够用。
// lib/ip-location.ts
export interface IPInfo {
city: string;
region: string;
country: string;
lat: number;
lon: number;
}
export async function getLocationByIP(ip: string): Promise<IPInfo | null> {
// 忽略本机和内网 IP
if (!ip || ip === '127.0.0.1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
return {
city: '上海',
region: 'Shanghai',
country: 'China',
lat: 31.2304,
lon: 121.4737,
};
}
try {
const res = await fetch(`http://ip-api.com/json/${ip}?fields=status,city,regionName,country,lat,lon`, {
signal: AbortSignal.timeout(2000),
});
const json = await res.json();
if (json.status !== 'success') return null;
return {
city: json.city || '未知城市',
region: json.regionName,
country: json.country,
lat: json.lat,
lon: json.lon,
};
} catch {
return null;
}
}
注意:ip-api.com 的免费版只支持 HTTP,不支持 HTTPS。如果你的站点是 HTTPS 且对安全性有要求,可以考虑付费版(ip-api.com/pro,约 15 美元/月),或者换成 ipinfo.io 的免费额度(每月 5 万次请求,支持 HTTPS)。
第五步:缓存策略——把 API 调用量减少 93%
免费 API 的核心挑战不是"会不会用",而是"能不能省着用"。再好的免费 API,如果你每分钟请求上千次,迟早会被封。
我在这个项目里做了三级缓存,把每天 12,000 次 API 调用降到了 800 次:
第一层:内存缓存(短期)
// lib/cache.ts
interface CacheItem<T> {
value: T;
expiresAt: number;
}
class MemoryCache {
private store = new Map<string, CacheItem<any>>();
get<T>(key: string): T | null {
const item = this.store.get(key);
if (!item) return null;
if (item.expiresAt < Date.now()) {
this.store.delete(key);
return null;
}
return item.value as T;
}
set<T>(key: string, value: T, ttlMs: number): void {
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
}
export const memoryCache = new MemoryCache();
用法非常简单:
// 查天气之前先查缓存
const cacheKey = `weather:${Math.round(lat * 100)}_${Math.round(lon * 100)}`;
const cached = memoryCache.get<{ data: WeatherData; forecast: ForecastHour[]; source: string }>(cacheKey);
if (cached) {
return cached; // 命中缓存,直接返回
}
// 缓存未命中,走真实 API
const result = await getAggregatedWeather(lat, lon);
memoryCache.set(cacheKey, result, 10 * 60 * 1000); // 10 分钟 TTL
return result;
关键细节:我把经纬度取整到小数点后 2 位再做缓存 key(Math.round(lat * 100))。这意味着上海浦东、浦西、虹桥这三个不同的坐标会被当作同一个 key。精度损失可以忽略,但缓存命中率提升了 3-5 倍。
第二层:文件缓存(中期)
Node.js 重启后内存缓存就没了。文件缓存做第二层兜底:
// lib/cache.ts(续)
import fs from 'fs';
import path from 'path';
const FILE_CACHE_DIR = path.join(process.cwd(), '.cache', 'weather');
fs.mkdirSync(FILE_CACHE_DIR, { recursive: true });
export function getFileCache<T>(key: string): T | null {
const filePath = path.join(FILE_CACHE_DIR, encodeURIComponent(key) + '.json');
try {
const stat = fs.statSync(filePath);
if (Date.now() - stat.mtimeMs > 30 * 60 * 1000) return null; // 30分钟过期
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T;
} catch {
return null;
}
}
export function setFileCache<T>(key: string, value: T): void {
const filePath = path.join(FILE_CACHE_DIR, encodeURIComponent(key) + '.json');
fs.writeFileSync(filePath, JSON.stringify(value), 'utf8');
}
第三层:终极降级(紧急)
如果所有天气 API 都挂掉了,我的应用应该显示什么?答案是——上一次成功请求的天气数据,并在页面上提示"数据可能略有延迟"。
// lib/fallback.ts
import { getFileCache } from './cache';
export async function getFallbackWeather(cacheKey: string) {
// 尝试读取最近一次成功的文件缓存(即使过期了也用)
const data = getFileCache<any>(cacheKey);
if (data) {
return { ...data, source: data.source + '(缓存数据,略有延迟)' };
}
// 实在没有,就用一个写死的"默认天气"
return {
data: {
location: '位置信息获取失败',
temperature: 25,
feelsLike: 25,
humidity: 60,
windSpeed: 10,
condition: '未知',
updatedAt: Date.now(),
source: '本地默认数据(所有API暂不可用)',
},
forecast: [],
source: 'fallback',
};
}
第六步:二维码分享功能
这是一个加分项——用户看到天气后,可以一键生成二维码分享给朋友。二维码服务用的是 api.qrserver.com(完全免费,不限调用次数)。
// app/page.tsx(节选)
function generateShareQR(city: string, temperature: number): string {
const shareText = `${city}今日天气:${temperature}°C,查看详情 →`;
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(
shareText + ' https://' + process.env.NEXT_PUBLIC_SITE_URL
)}&margin=10`;
return qrUrl;
}
QR Server API 支持自定义尺寸、颜色、边距。你可以在 Free API Hub 的 API 列表页找到它的完整用法。
第七步:Next.js API 路由——把所有东西串起来
// app/api/weather/route.ts
import { NextResponse } from 'next/server';
import { getLocationByIP } from '@/lib/ip-location';
import { getAggregatedWeather } from '@/lib/aggregator';
import { memoryCache, getFileCache, setFileCache } from '@/lib/cache';
import { getFallbackWeather } from '@/lib/fallback';
export const dynamic = 'force-dynamic';
export async function GET(request: Request) {
// 1. 获取用户 IP
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'8.8.8.8';
// 2. IP → 经纬度
let location = memoryCache.get<any>(`ip:${ip}`);
if (!location) {
location = await getLocationByIP(ip);
if (location) memoryCache.set(`ip:${ip}`, location, 4 * 60 * 60 * 1000); // IP 定位缓存 4 小时
}
if (!location) {
return NextResponse.json({ error: '定位失败' }, { status: 500 });
}
// 3. 查天气(带缓存降级)
const cacheKey = `weather:${Math.round(location.lat * 100)}_${Math.round(location.lon * 100)}`;
const cached = memoryCache.get<any>(cacheKey) || getFileCache<any>(cacheKey);
if (cached) {
return NextResponse.json({ ...cached, location: location.city, fromCache: true });
}
try {
const result = await getAggregatedWeather(location.lat, location.lon);
memoryCache.set(cacheKey, result, 10 * 60 * 1000); // 内存缓存 10 分钟
setFileCache(cacheKey, result); // 文件缓存 30 分钟
return NextResponse.json({ ...result, location: location.city });
} catch (err) {
// 所有 API 都失败 → 走降级
const fallback = await getFallbackWeather(cacheKey);
return NextResponse.json(
{ ...fallback, location: location.city, isDegraded: true },
{ status: 200 } // 注意:降级仍然返回 200,不返回错误状态码
);
}
}
第八步:前端页面
前端页面很简单,就是调用上面那个 API,把数据渲染出来。重点做好 loading 状态和错误提示。
// app/page.tsx
'use client';
import { useEffect, useState } from 'react';
export default function Home() {
const [loading, setLoading] = useState(true);
const [weather, setWeather] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch('/api/weather')
.then((r) => r.json())
.then((data) => {
if (data.error) throw new Error(data.error);
setWeather(data);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <div>正在获取天气数据...</div>;
if (error) return <div>获取失败:{error}</div>;
return (
<main style={{ padding: '2rem', maxWidth: 600, margin: '0 auto' }}>
<h1>{weather.location}</h1>
<div style={{ fontSize: '3rem' }}>{weather.data.temperature}°C</div>
<p>体感温度 {weather.data.feelsLike}°C · 湿度 {weather.data.humidity}%</p>
<p style={{ color: weather.isDegraded ? '#999' : '#666' }}>
数据来源:{weather.source}
{weather.fromCache && '(来自缓存)'}
</p>
<img
src={generateShareQR(weather.location, weather.data.temperature)}
alt="分享二维码"
style={{ width: 200, height: 200, marginTop: '1rem' }}
/>
</main>
);
}
function generateShareQR(city: string, temperature: number): string {
const text = `${city}今日:${temperature}°C,详情 →`;
return (
'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' +
encodeURIComponent(text)
);
}
部署上线:用 Cloudflare Pages 零成本部署
静态导出 Next.js 应用,部署到 Cloudflare Pages:
# next.config.js 添加
output: 'export'
# 构建
npm run build
# 把 out/ 目录上传到 Cloudflare Pages 或 Vercel
注意:API 路由要在服务器端运行。如果你用的是 Cloudflare Pages 静态部署,需要把 API 逻辑改成在 client-side 直接调用。或者,你可以选择用 Vercel 部署 Next.js(免费额度足够小项目使用)。
踩坑记录
讲几个在实际开发中遇到的、值得单独拿出来说的问题。
坑 1:Open-Meteo 的免费版没有天气状况码
Open-Meteo 免费版只给你温度、湿度、风速这些数值,但不给你"晴/多云/雨"的 WMO 天气代码。我在上面的代码里用了 describeTemperature 函数根据温度推断天气描述——这是一个简化做法。
如果要准确的天气状况,有两个选择:
- 升级到 Open-Meteo 的 API Key(每月约 5 欧元)
- 自己根据温度、湿度、风速组合推断(我目前的做法)
坑 2:ip-api 的免费版只支持 HTTP
上文提到过,这里再强调一下。如果你的网站是 HTTPS,浏览器会阻止混合内容请求。解决方法:
- 用后端代理(推荐,我现在就是这么做的——前端请求 /api/weather,后端在 server-side 请求 ip-api 的 HTTP 接口)
- 换 ipinfo.io(免费版每月 5 万次,支持 HTTPS)
坑 3:经纬度取整的副作用
为了提高缓存命中率,我把经纬度四舍五入到小数点后 2 位。但如果你做的是高精度天气应用(比如给农业用),这可能导致精度不够。天气数据本身的网格精度大约是 1-11 km,所以小数点后 2 位(约 1.1 km)对日常天气查询来说完全足够,但要根据你的具体业务场景决定取整精度。
坑 4:免费 API 服务变更
2025 年 3 月 Open-Meteo 调整了一次返回字段结构,我有一批用户突然看到页面上显示 "NaN°C"。这就是为什么我在代码里用了大量 ?? fallback 操作符——哪怕字段名变了,也不要直接崩。
总结:做免费 API 项目的三个原则
回头看这个项目,从技术角度没有什么特别难的东西。但它能稳定运行 1 年多,月活从 0 做到 20 万,我觉得主要是坚持了三个原则:
- 永远不要把鸡蛋放在一个篮子里。任何免费 API 都可能在你完全没预料的时候挂掉、限速、收费化。多数据源 + 智能切换是必须的。
- 缓存比你想象的更有用。天气数据 10 分钟不变,IP 地理位置几小时内不变。合理的缓存能让你用 1 台最低配置的服务器,支撑上万并发用户。
- 降级方案是标配,不是可选。一个优雅的降级体验(比如"数据略有延迟")比一个 500 错误页面对用户友好 100 倍。做免费 API 的项目,降级应该是第一天就要考虑的事情。
如果你想更深入地了解这些免费 API 的参数,可以参考 Free API Hub 的博客,里面有其他 API 的详细用法、测试数据和集成示例。也可以直接在 API 列表页浏览完整的免费 API 目录。