做H5的天气效果,很多人的第一反应是“找一张好看的天气背景图,再放个温度数字”,但实际上手后会发现,天气功能真正的难点不在视觉,而在数据。实时气温从哪来、接口挂了页面怎么兜底、雨雪粒子在不同手机上性能差多少,这些都是躲不开的问题。我最近在“心知天气”这套方案上完整跑了一版H5天气效果,这里把申请密钥、请求封装、天气代码映射、动效实现、定位兜底和踩坑记录全部拆开讲,适合要做活动落地页、旅游页面或办公小工具天气模块的开发者参考。
1. 为什么直接用天气数据服务,而不是自己造
1.1 自建气象数据的坑,比想象中多
先说一个容易上头的事:看起来“天气数据”不就是温度、风速、天气现象吗?网上抓一份回来存起来不就完了?我见过不少团队一开始就是这种思路,结果全都倒在稳定性和合法性上。
第一,气象数据源本身不稳定。你从一个天气网站抓HTML,人家改一次前端结构,你的解析逻辑就得重写;再严重点,对方上了验证码或访问频率限制,你的服务器IP直接被封,页面就会开始显示上个月的数据。第二,数据精度和更新频率对不上。很多公开页面展示的是“整点更新”的数据,你抓下来之后,H5页面里显示的和用户手机自带天气App差一大截,用户立刻就觉得“你这是假天气”。第三,合规问题。气象数据属于专业领域数据,商用和公开发布是有规范和授权要求的,自己爬取展示一旦流量起来,后续风险很高。
所以天气这个功能,自建数据源的成本远不止“写个爬虫”这么简单。比较理性的做法是把数据获取交给专业的天气API服务,前端专注做展示和交互。H5场景下,你需要的不是“自己拥有气象网络”,而是“稳定地拿一份干净、结构化的JSON”。
1.2 商业天气API到底省了哪些事
接商业天气API之后,最直接的变化是数据契约变得清晰了。你请求一个接口,它返回固定结构的JSON,里面有天气现象code、温度、体感温度、湿度、更新时间,甚至空气质量。字段什么时候更新、超时怎么处理、配额怎么算,都有文档说清楚。
稳定性方面也有明显差别。头部天气服务商一般有多级数据源和容灾,不会因为某一个天气网站改版就断供。多语言、多单位(摄氏度/华氏度)、城市拼音/经纬度查询这些都是现成的,省掉了翻译和换算的麻烦。
我做个简单对比:
| 维度 | 自建/爬虫 | 商业天气API |
|---|---|---|
| 数据稳定性 | 受目标站点影响大 | 有服务等级保障 |
| 返回格式 | 需要维护解析逻辑 | 结构固定,文档齐全 |
| 更新频率 | 取决于抓取策略 | 接口自带实时更新 |
| 合规性 | 高风险 | 授权明确,可商用 |
| 开发效率 | 需要长期维护 | 一次接入,长期可用 |
1.3 “心知天气”这个选型,具体好在哪
“心知天气”本质上是面向开发者的天气数据服务,接入方式简单,注册后拿key,通过HTTP接口取数据。我选它的原因有几点:
一是文档中文且参数直观,location、unit、language这几个参数一眼就懂,对H5团队来说上手成本低。二是免费配额够用,做活动页和开发调试阶段可以不花钱先跑通。三是它提供实时天气、逐日预报、空气质量等多个接口,后续要扩展“穿衣指数”之类功能也顺路。
缺点也要说清楚:免费配额有请求频率限制,如果你的H5页面流量很大,建议换付费档或者让服务端转发;另外,浏览器直连的话,跨域和域名白名单需要提前配置,不然上线第一天就会被CORS报错卡住。
2. 接入前的准备:密钥、参数与跨域
2.1 注册应用与密钥管理
接入的第一步是在服务商控制台创建一个应用,拿到一串密钥(一般是key字段)。创建应用的时候会让你填域名信息,本地开发可以先填localhost,上线前再改成正式域名。
密钥管理建议注意两点:
- 不要把key直接提交到Git仓库。前端代码里经常需要通过环境变量注入,比如
.env文件,并且.env要进.gitignore。 - 线上H5如果直接在前端代码里带key,用户打开控制台就能看到。对活动页这种低敏场景还能接受,但如果是长期产品,最好让后端或云函数做一层转发,key只存在于服务端。
2.2 常用接口和参数说明
我用得最多的是这三个接口:
| 用途 | 接口路径(以服务商文档为准) | 关键参数 |
|---|---|---|
| 实时天气 | /v3/weather/now.json | location、unit、language |
| 逐日预报 | /v3/weather/daily.json | location、days、start |
| 空气质量 | /v3/airquality/now.json | location |
参数细节上,location很关键。它支持城市拼音形式,例如location=demo_city这种(实际开发时填目标城市拼音或城市ID);也可以用经纬度,形式通常是纬度:经度,例如location=12.3456:78.9000,这里只是一个示例坐标,实际使用时由定位结果返回当前经纬度。用经纬度查询的好处是更精准,坏处是“示例城市”这种城市名没法直接用,必须走坐标。
unit取值c或f,国内H5页面基本用c。language取zh-Hans时,返回的text字段直接就是中文天气现象,比如“晴”“多云”“小到中雨”。
实时天气接口返回的JSON结构大概是这样的:
{ "results": [ { "location": { "name": "示例城市", "country": "CN" }, "now": { "text": "晴", "code": "0", "temperature": "26", "feels_like": "28", "humidity": "45" }, "last_update": "2025-01-06T10:30+08:00" } ] }注意看code字段,它是天气现象的数字编码,前端动画就是靠它来决定展示晴天、下雨还是下雪。后面的天气现象映射表会详细说。
2.3 开发环境的跨域处理
浏览器直接请求天气API域名会发生跨域。如果你本地用Vite开发,最简单的做法是配一个代理,把/api开头的请求转发到天气服务域名。
// vite.config.js export default { server: { proxy: { '/api': { target: 'https://api.weather-service.example.com', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } }这样前端代码里请求统一写成/api/v3/weather/now.json,不用在代码里写完整的外网地址。生产环境的话,一般是在Nginx层做反向代理:
location /api/ { proxy_pass https://api.weather-service.example.com/; proxy_set_header Host api.weather-service.example.com; }除了代理,还要确认服务商的域名白名单里加上了你的H5域名。有些天气服务要求先在控制台配置“允许访问的域名”,否则就算代理通了也会返回权限错误。
3. 核心实现:从接口数据到天气动效
3.1 先封装一个最小请求模块
拿到接口后,我习惯先做一个请求函数,把key、参数、错误处理统一封装起来。这样页面其他模块调用时不需要关心接口细节。
const API_KEY = '这里填你申请的key' const BASE_URL = '/api/v3/weather/now.json' export async function getNowWeather({ location = 'demo_city', unit = 'c' } = {}) { const params = new URLSearchParams({ key: API_KEY, location, unit, language: 'zh-Hans' }) const response = await fetch(`${BASE_URL}?${params.toString()}`) if (!response.ok) { throw new Error(`天气接口HTTP异常:${response.status}`) } const data = await response.json() // 部分天气服务在出错时会返回 status_code 或 status 字段 if (data.status_code && data.status_code !== 'ok') { throw new Error(data.status || '天气接口返回异常') } const first = data.results?.[0] if (!first) { throw new Error('天气数据为空') } return first }这里我用URLSearchParams来拼参数,它会自动处理特殊字符。如果你习惯手拼字符串,记得对中文和冒号做encodeURIComponent,不然城市名带中文时很容易出乱码。
另一个小细节是防止重复请求。页面可能同时有几个组件都需要天气数据,如果各自发一次请求,不仅浪费配额,还容易触发频率限制。我加了一个简单的请求锁:
let weatherPromise = null function fetchWeatherOnce(params) { if (!weatherPromise) { weatherPromise = getNowWeather(params).finally(() => { weatherPromise = null }) } return weatherPromise }这样同一时间只有一个请求在飞,其他调用方直接复用这个Promise。
3.2 天气代码映射:这张表得做好
天气服务返回的code字段,是驱动动画的核心。不同的服务商编码可能略有不同,我在项目里整理了一份映射,把code转成业务语义,再绑定到对应的样式类:
| code | 中文含义 | 页面主题 |
|---|---|---|
| 0 | 晴 | 艳阳高照 |
| 1 | 多云 | 云层移动 |
| 2 | 阴 | 灰暗色调 |
| 3 | 阵雨 | 雨滴稀疏 |
| 4 | 雷阵雨 | 闪电+雨幕 |
| 7 | 小雨 | 雨滴缓慢 |
| 8 | 中雨 | 雨滴加速 |
| 9 | 大雨 | 雨幕变密 |
| 14 | 小雪 | 雪花飘落 |
| 15 | 中雪 | 雪花加速 |
| 16 | 大雪 | 大雪+白雾 |
| 30 | 雾 | 迷雾粒子 |
| 23 | 沙尘暴 | 黄褐色调 |
实际开发时,我先建一个weather-theme的枚举对象:
const WEATHER_THEME_MAP = { 0: 'sunny', 1: 'cloudy', 2: 'overcast', 3: 'shower', 4: 'thunderstorm', 7: 'light-rain', 8: 'moderate-rain', 9: 'heavy-rain', 14: 'light-snow', 15: 'moderate-snow', 16: 'heavy-snow', 30: 'fog', 23: 'sandstorm' } export function getWeatherTheme(code) { return WEATHER_THEME_MAP[code] || 'unknown' }页面拿到天气数据后,把返回的code传给getWeatherTheme,得到主题类名,然后设置到页面的根节点上。CSS里定义.weather-sunny、.weather-light-rain这些类名对应的背景和动效。
3.3 用Canvas做雨雪粒子动效
天气动效里最重头的就是雨和雪。我最初试过纯CSS模拟雨丝,但效果一般,后来改成Canvas粒子方案。
简单说下原理:在页面上叠加一块全屏透明的Canvas,根据天气type生成若干个粒子对象,每一帧更新粒子坐标并重绘。
以雨滴为例,最简版可以这样写:
function createRainPainter(canvas, { wind = 1, speed = 8 } = {}) { const ctx = canvas.getContext('2d') let raf = null const drops = [] const count = 80 for (let i = 0; i < count; i++) { drops.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height }) } function frame() { ctx.clearRect(0, 0, canvas.width, canvas.height) ctx.strokeStyle = 'rgba(160, 190, 220, 0.65)' ctx.lineWidth = 1 drops.forEach((d) => { d.x += wind d.y += speed if (d.y > canvas.height) { d.y = -8 d.x = Math.random() * canvas.width } if (d.x > canvas.width) { d.x = 0 } ctx.beginPath() ctx.moveTo(d.x, d.y) ctx.lineTo(d.x + 0.6, d.y + 12 * (speed / 8)) ctx.stroke() }) raf = requestAnimationFrame(frame) } frame() return function stop() { if (raf) { cancelAnimationFrame(raf) raf = null } } }核心思路就是:每个雨滴在每次帧刷新时向下移动speed个像素,向侧面偏移wind个像素,超出底部就重置到顶部。requestAnimationFrame会匹配屏幕刷新率,动画看起来会比较顺滑。
雪花的实现类似,区别是雪花下落更慢、有左右摇摆,绘制时可以用小圆形代替线段。
晴天和阴天则不需要Canvas,用CSS动画就够了。比如晴天的太阳光晕呼吸效果:
.weather-sunny .sun { animation: breathe 6s ease-in-out infinite; } @keyframes breathe { 0%, 100% { transform: scale(1); opacity: 0.9; } 50% { transform: scale(1.08); opacity: 1; } }如果目标设备不支持Canvas,可以做一个降级:直接显示一张静态天气背景图,至少不会出现空白页。
3.4 把数据组装到页面
数据到页面的组装,一般包含这几块信息:
- 城市名:显示当前城市,定位失败时显示默认城市
- 天气图标/大背景:根据天气主题展示不同画面
- 温度:实时温度,单位是摄氏度
- 天气现象文字:晴、多云、小雨等
- 辅助信息:体感温度、湿度、更新时间
以温度为例,接口里返回的temperature是字符串,前端需要转成数值再渲染:
const weather = await getNowWeather({ location: 'demo_city' }) document.querySelector('.city-name').textContent = weather.location.name document.querySelector('.temperature').textContent = `${Math.round(Number(weather.now.temperature))}°` document.querySelector('.weather-text').textContent = weather.now.text注意数据加载完成后再设置内容,不要让页面先展示一个空壳。加载中的状态我用一个骨架屏来撑住,后面会讲。
4. 定位、缓存、异常兜底
4.1 定位逻辑:能定位就定位,不行就给默认城市
H5页面在城市定位上最容易踩坑。浏览器有navigator.geolocation接口,但使用有限制:
- 必须在HTTPS环境下,本地开发用
http://localhost一般可以,线上如果还是HTTP,接口会被浏览器拦截。 - 用户必须授权地理位置权限,拒绝权限后就拿不到坐标。
- 内置浏览器的定位能力不稳定,有些WebView不会对网页暴露定位能力。
我的流程是这样的:先尝试navigator.geolocation.getCurrentPosition拿坐标,拿不到就回退到城市选择器,用户可以从热门城市列表里手动选。
function getPosition() { return new Promise((resolve, reject) => { if (!navigator.geolocation) { reject(new Error('当前浏览器不支持地理定位')) return } navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 8000, maximumAge: 5 * 60 * 1000 }) }) }拿到坐标后转成纬度:经度的格式传给天气接口。这里要注意,不同天气服务的经纬度顺序可能不同,有的要求纬度:经度,有的要求经度:纬度,接入前一定看文档确认。
如果定位失败,我会用一个静态数组保存20个热门城市,做成一个横向滚动选择条。用户点哪个城市就查哪个城市,交互很简单,也规避了搜索接口的额外对接成本。
4.2 缓存策略与弱网兜底
天气数据属于“短时数据”,但它不是“实时秒级数据”。你完全可以在一段时间内复用上一次的数据,减少API调用量。我用本地缓存做了一个30分钟的过期时间:
const CACHE_PREFIX = 'h5_weather_' const MAX_AGE = 30 * 60 * 1000 function getCache(location) { const key = CACHE_PREFIX + location const raw = localStorage.getItem(key) if (!raw) return null const parsed = JSON.parse(raw) if (Date.now() - parsed.timestamp > MAX_AGE) return null return parsed.data } function setCache(location, data) { const key = CACHE_PREFIX + location localStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), data })) }当接口请求失败时,我会优先读取缓存里的旧数据展示,同时在页面上显示一个“数据更新失败”的轻提示,而不是直接让用户看到空白。
重试按钮也要给用户准备。最简单的做法是加一个遮罩层,提示“天气信息加载失败了,点我重试”,点击后重新走一遍获取流程。
4.3 骨架屏、可访问性与机型适配
天气数据请求有网络延迟,页面会经历“空白期”。我用一个骨架屏撑着,用灰色色块模拟天气图标和文字的位置,数据返回后再替换成真实内容。这样用户观感会好很多,不会误以为页面坏了。
可访问性也值得注意。H5页面如果会被读屏软件读取,最好给天气容器加上aria-label,比如“当前示例城市,晴,26摄氏度,湿度百分之四十五”。这个成本很低,但体验提升明显。
机型适配方面,我遇到最多的是刘海屏和全面屏的安全区问题。天气信息通常放在页面顶部,需要适配env(safe-area-inset-top),否则在部分手机上信息会被挖孔区域遮住。
5. 实战中踩过的坑与排查速查表
5.1 跨域、HTTPS定位失效的典型现场
第一个坑,本地直接打开一个index.html,用file://协议去请求天气API,结果控制台报CORS错误。这个不用想,file://协议没有origin头,很多接口直接拒绝。正确做法是本地起一个静态服务器,再用代理转发。
第二个坑,线上H5配了Nginx代理,但还是偶尔报502。排查后发现目标天气服务域名只能从指定出口IP访问,服务器IP不在白名单里。解决办法是在服务商后台把服务器IP加到白名单,或者确认代理转发时没有丢弃必要的请求头。
第三个坑,内置浏览器里位置权限弹窗不出现。最常见的场景是某些超级App的网页容器没有给HTML5定位授权,getCurrentPosition方法直接走error回调。这种情况下不要硬扛,回退到IP定位或者城市选择器即可。
第四个坑,API key暴露。我在开发版里把key写死在代码里,结果被同事提交到了一个公开代码仓库,当天就被人盗刷了配额。后来整改成key走服务端转发,前端再用短时效的临时token去换取数据。
5.2 请求返回异常速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 返回“认证失败” | key错误或已被删除 | 检查控制台里的key是否一致 |
| 返回“域名不允许” | 未配置域名白名单 | 在控制台加上当前H5域名 |
| 请求正常但数据为空 | location拼写错误或城市不存在 | 用接口提供的城市搜索确认 |
| 频繁提示“请求频率超出” | 超过免费配额 | 加缓存+节流,或升级套餐 |
| 温度显示“-” | unit参数传了不支持的值 | 统一传c或f |
| 数据是上一天的 | 本地缓存过期时间太久 | 检查缓存逻辑,缩短MAX_AGE |
5.3 让动画省电的几条建议
移动端Canvas粒子动画最容易遇到的问题就是掉帧和发热。分享几个实测有效的手段:
- 控制粒子数量。中低端手机雨滴粒子数降到40左右,中高端机再提到80。
- Canvas画布尺寸不要超过物理像素太多。按
devicePixelRatio缩放是必要的,但不要无脑设为3。 - 页面不可见时暂停动画。监听
visibilitychange事件,在后台时cancelAnimationFrame,切回前台再恢复,既能省电也能减少发热。 - 动画属性优先用
transform和opacity,这些属性走GPU合成,避免用filter: blur()这种代价较高的效果。 - 如果检测到低端机型,直接降级为静态背景图,不做粒子。
这些优化做完,中端Android机上基本能保持流畅,不会因为一个天气动效把页面整体拖垮。
6. 收个尾:这类H5天气页面的几个心得
把整个流程跑完之后,我最大的感受是:天气动效项目的技术重点不在于“画得好看”,而在于数据和效果之间那层映射关系是否稳。接口返回的code写死前,一定要对照服务商文档逐条确认;渲染动效前,一定要考虑低端机的情况;定位失败时,一定要给用户一条体面的退路。
另外,密钥和跨域这类工程细节最好在项目启动第一天就处理好,不要等到联调阶段才被CORS和权限弹窗卡住。缓存策略也值得多花半小时设计,它不仅能省API配额,还能让你的页面在弱网环境下显得更可靠。
如果你之后要做类似的项目,我的建议是:先花一天时间把接口文档读透,再花半天做一张完整的天气code映射表,剩下的时间其实都花在动效和兜底上。把“数据到视觉”这条管线理顺,H5天气效果就没有想象中那么复杂了。