文章详情页的天气不是构建时写死的。
实际链路是:
ArticleMeta.astro -> 输出发布日期、经纬度和占位文案 -> article.ts 读取 data-* -> Open-Meteo Archive API -> 渲染温度、天气、降水和图标这里用的是 Open-Meteo 的历史天气接口,不需要 token。页面打开后按文章发布日期请求一次。
元信息入口
天气入口在 src/components/ArticleMeta.astro。
先把文章日期格式化成 YYYY-MM-DD:
const weatherDate = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',}).format(post.data.date);然后输出一个普通的 meta item:
<span class="meta-item meta-weather" data-article-weather data-weather-location="融安" data-weather-latitude="25.2147" data-weather-longitude="109.4036" data-weather-date={weatherDate}> <CloudSun size={14} data-weather-icon /> <span data-weather-text>天气加载中</span></span>经纬度、地点和日期都放在 data-* 上。Astro 只负责把静态信息输出到 HTML,不直接请求天气接口。
初始化
文章脚本在 src/scripts/article.ts 里统一初始化。
function initArticleBehavior() { resetArticleBehavior(); initArticleWeather(); initArticleGalleries(onArticleCleanup); initReadingProgress(); initTables(); initKatexBlocks();}天气逻辑只找一个节点:
function initArticleWeather() { const element = document.querySelector<HTMLElement>('[data-article-weather]'); if (!element) return;
const latitude = element.dataset.weatherLatitude; const longitude = element.dataset.weatherLongitude; const location = element.dataset.weatherLocation || '融安'; const date = element.dataset.weatherDate; if (!latitude || !longitude || !date) return;}缺少经纬度或日期就直接跳过,不影响文章正文。
请求接口
请求参数用 URLSearchParams 拼,不手写 query string:
const params = new URLSearchParams({ latitude, longitude, start_date: date, end_date: date, daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max', timezone: 'Asia/Shanghai',});接口地址:
fetch(`https://archive-api.open-meteo.com/v1/archive?${params.toString()}`, { signal: controller.signal,})start_date 和 end_date 都是文章发布日期,所以每次只取一天的数据。
请求的字段:
type WeatherDaily = { time?: string[]; weather_code?: number[]; temperature_2m_max?: number[]; temperature_2m_min?: number[]; precipitation_sum?: number[]; wind_speed_10m_max?: number[];};接口返回的是数组,页面只取第一天:
function getWeatherDay(daily: WeatherDaily, fallbackDate: string): WeatherDay | null { const date = daily.time?.[0] || fallbackDate; const day: WeatherDay = { date, weather_code: daily.weather_code?.[0], temperature_2m_max: daily.temperature_2m_max?.[0], temperature_2m_min: daily.temperature_2m_min?.[0], precipitation_sum: daily.precipitation_sum?.[0], wind_speed_10m_max: daily.wind_speed_10m_max?.[0], };
return typeof day.temperature_2m_max === 'number' || typeof day.temperature_2m_min === 'number' ? day : null;}没有最高温、最低温时认为这次结果不可用。
缓存
天气数据用 sessionStorage 做短缓存。
const WEATHER_CACHE_TTL = 10 * 60 * 1000;缓存 key 包含地点、经纬度和日期:
const cacheKey = `article-weather:archive:${location}:${latitude}:${longitude}:${date}`;const cached = getWeatherCache(cacheKey);
if (cached) { renderWeather(element, cached); return;}读取时顺便判断过期:
function getWeatherCache(key: string): WeatherDay | null { try { const raw = window.sessionStorage.getItem(key); if (!raw) return null;
const cached = JSON.parse(raw) as WeatherCache; if (!cached.day || cached.expires < Date.now()) { window.sessionStorage.removeItem(key); return null; }
return cached.day; } catch { return null; }}写入失败也不需要报错:
function setWeatherCache(key: string, day: WeatherDay) { try { const cache: WeatherCache = { expires: Date.now() + WEATHER_CACHE_TTL, day, }; window.sessionStorage.setItem(key, JSON.stringify(cache)); } catch { // Ignore storage failures; the live response can still render. }}缓存只是减少同一会话内重复请求,不是数据来源。
天气码映射
Open-Meteo 返回的是 WMO weather code:
type WeatherIcon = keyof typeof WEATHER_ICON_PATHS;type WeatherCondition = { description: string; icon: WeatherIcon;};
const DEFAULT_WEATHER_CONDITION: WeatherCondition = { description: '天气', icon: 'cloudSun',};
const WEATHER_CODE_CONDITIONS: Record<number, WeatherCondition> = { 0: { description: '晴', icon: 'sun' }, 1: { description: '晴', icon: 'sun' }, 2: { description: '多云', icon: 'cloudSun' }, 3: { description: '阴', icon: 'cloud' }, 45: { description: '雾', icon: 'cloudFog' }, 48: { description: '雾', icon: 'cloudFog' }, 51: { description: '毛毛雨', icon: 'cloudRain' }, 53: { description: '毛毛雨', icon: 'cloudRain' }, 55: { description: '毛毛雨', icon: 'cloudRain' }, 56: { description: '毛毛雨', icon: 'cloudRain' }, 57: { description: '毛毛雨', icon: 'cloudRain' }, 61: { description: '小雨', icon: 'cloudRain' }, 63: { description: '中雨', icon: 'cloudRain' }, 65: { description: '大雨', icon: 'cloudRain' }, 66: { description: '小雨', icon: 'cloudRain' }, 67: { description: '中雨', icon: 'cloudRain' }, 71: { description: '雪', icon: 'cloudSnow' }, 73: { description: '雪', icon: 'cloudSnow' }, 75: { description: '雪', icon: 'cloudSnow' }, 77: { description: '雪', icon: 'cloudSnow' }, 80: { description: '阵雨', icon: 'cloudRain' }, 81: { description: '阵雨', icon: 'cloudRain' }, 82: { description: '阵雨', icon: 'cloudRain' }, 85: { description: '阵雪', icon: 'cloudSnow' }, 86: { description: '阵雪', icon: 'cloudSnow' }, 95: { description: '雷雨', icon: 'cloudLightning' }, 96: { description: '雷雨', icon: 'cloudLightning' }, 99: { description: '雷雨', icon: 'cloudLightning' },};function getWeatherCondition(code: number | undefined): WeatherCondition { return code === undefined ? DEFAULT_WEATHER_CONDITION : WEATHER_CODE_CONDITIONS[code] || DEFAULT_WEATHER_CONDITION;}天气文案
温度取整:
function formatTemperature(value: number | undefined) { if (typeof value !== 'number' || Number.isNaN(value)) return '--'; return `${Math.round(value)}°C`;}最终显示文本:
function formatWeatherText(day: WeatherDay, condition: WeatherCondition) { const high = formatTemperature(day.temperature_2m_max); const low = formatTemperature(day.temperature_2m_min); const temperature = high === '--' && low === '--' ? '' : `${high}/${low}`; const rain = typeof day.precipitation_sum === 'number' && day.precipitation_sum > 0 ? `降水 ${Number(day.precipitation_sum.toFixed(1))}mm` : '';
return [temperature, condition.description, rain].filter(Boolean).join(' · ');}比如最后会变成:
31°C/24°C · 阵雨 · 降水 2.4mm图标
初始图标用 CloudSun:
<CloudSun size={14} data-weather-icon />请求完成后从天气码映射里取图标,只更新当前这个 SVG:
function renderWeather(element: HTMLElement, day: WeatherDay) { const text = element.querySelector<HTMLElement>('[data-weather-text]'); const icon = element.querySelector<SVGSVGElement>('[data-weather-icon]'); const condition = getWeatherCondition(day.weather_code);
element.classList.remove('is-loading', 'has-error'); element.classList.add('is-ready');
if (text) text.textContent = formatWeatherText(day, condition);
if (icon) { const iconName = condition.icon; icon.innerHTML = WEATHER_ICON_PATHS[iconName]; icon.dataset.weatherIcon = iconName; }
element.title = formatWeatherTitle(day);}title 里放更完整的数据:
function formatWeatherTitle(day: WeatherDay) { const parts = [ typeof day.temperature_2m_max === 'number' ? `最高 ${formatTemperature(day.temperature_2m_max)}` : '', typeof day.temperature_2m_min === 'number' ? `最低 ${formatTemperature(day.temperature_2m_min)}` : '', typeof day.precipitation_sum === 'number' ? `降水 ${Number(day.precipitation_sum.toFixed(1))} mm` : '', typeof day.wind_speed_10m_max === 'number' ? `最大风速 ${Math.round(day.wind_speed_10m_max)} km/h` : '', '历史天气数据源 Open-Meteo Archive', ].filter(Boolean);
return parts.join(',');}鼠标停在天气上,就能看到最高温、最低温、降水、最大风速和数据源。
状态样式
天气 meta 有三个状态:
.meta-weather.is-loading { color: var(--text-muted);}
.meta-weather.is-ready { border-color: color-mix(in srgb, var(--quote-border) 26%, transparent); background: color-mix(in srgb, var(--quote-border) 7%, transparent);}
.meta-weather.has-error { color: var(--text-muted);}加载时压低存在感,成功后只给一点边框和底色,失败时显示兜底文案。
错误处理
请求失败、返回异常、没有可用温度,都会走同一个错误渲染:
function renderWeatherError(element: HTMLElement) { const text = element.querySelector<HTMLElement>('[data-weather-text]');
element.classList.remove('is-loading', 'is-ready'); element.classList.add('has-error');
if (text) text.textContent = '天气暂不可用'; element.title = '发布日历史天气请求失败';}fetch 里只区分 abort:
fetch(`https://archive-api.open-meteo.com/v1/archive?${params.toString()}`, { signal: controller.signal,}) .then((response) => { if (!response.ok) throw new Error(`Weather request failed: ${response.status}`); return response.json() as Promise<{ daily?: WeatherDaily }>; }) .then((data) => { const day = data.daily ? getWeatherDay(data.daily, date) : null; if (!day || !element.isConnected) { if (element.isConnected) renderWeatherError(element); return; }
setWeatherCache(cacheKey, day); renderWeather(element, day); }) .catch((error) => { if ((error as DOMException).name === 'AbortError') return; if (element.isConnected) renderWeatherError(element); });页面切换或重新初始化时取消未完成的请求:
const controller = new AbortController();
onArticleCleanup(() => controller.abort());最后整理一下:
ArticleMeta.astro输出天气占位和请求参数。article.ts在浏览器里请求 Open-Meteo Archive API。- 请求按文章发布日期取一天历史天气。
sessionStorage缓存十分钟。- 成功时更新图标、文案和
title。 - 失败时只显示“天气暂不可用”,不影响文章阅读。
评论
GitHub 登录后可评论。
评论区会在滚动到这里时自动加载。