1. 先想清楚这个项目到底要解决什么
很多朋友第一次做微信小程序,都会选天气预报当练手项目。这个选择其实相当聪明:功能清晰、页面直观、数据源公开,而且能覆盖请求发送、数据处理、定位权限、缓存优化这些小程序开发的“标配技能”。但真正动手之后才发现,教程里随便带过的那些环节,才是最花时间的部分。
我当时做这个模拟天气Demo,前后花了两个星期。不是代码写不出来,而是每一步都卡在“看起来简单但没人讲透”的细节上:接口服务商怎么选、直接请求为什么会失败、定位权限处理不好用户直接退出页面、缓存策略不对导致每次打开都在转圈圈。这篇内容就是把我踩过的坑和最终验证可行的方案完整拆开,从项目结构设计一直讲到最后能跑起来的真机版本。
标题里强调“从0到1”“完整可运行源码”,我理解的意思是:一个基础为0的开发者,照着这篇文章的步骤走,凌晨两三点前能把一个天气小程序稳稳跑起来,而不是只看到一堆代码片段。所以下面每个环节,我都会解释为什么这样做,以及不做会怎样。
2. 功能与数据源选型:不是代码先跑起来,而是先想清楚数据从哪来
2.1 功能清单拆解:最小可用版本做什么
天气预报小程序如果什么都想做,反而做不出来。我当时给自己列了一个最小可用版本(MVP)清单,核心只有三件事:
- 展示当前城市的实时天气:温度、天气现象、风速、湿度、空气质量这些基础项。
- 展示未来几天的天气预报:日期、白天气温、夜间气温、天气现象。
- 支持城市定位和手动切换:打开小程序自动获取定位,定位失败时能手动选择其他城市。
这个清单看起来简单,但它决定了页面的数据结构、接口选型和缓存设计。很多新手一上来就想着做穿衣建议、旅行指数、日出日落时间,结果每个功能都要接一套数据字段,页面越写越重,最后连核心的实时天气都没做好。先跑通核心闭环,后续再迭代,进度反而更快。
2.2 天气数据接口选型:正规授权是第一优先级
天气数据的来源,我建议直接对接有正规授权的天气数据服务商,不要自己抓第三方网页,也不要找那种来源不明的免费接口。
对比下来常用的是三类:
| 接口类型 | 典型用途 | 需要关心的点 |
|---|---|---|
| 天气数据接口 | 实时天气、逐日预报、生活指数 | 免费额度、单位制、更新频率 |
| 定位SDK或接口 | 获取当前经纬度 | 授权合规、室内定位精度 |
| 逆地理编码接口 | 经纬度转城市名称与行政区划 | 配额限制、结果缓存策略 |
我当时选了某天气数据服务商的免费开发者版。免费版虽然每天调用次数有限,但对个人学习和演示完全够用。更难处理的其实是“密钥”的安全问题:直接把API密钥写在小程序代码里,等于把密码贴在门口。以后要上线的话,请求必须走你自己的服务端,或者用云函数转发,密钥只留在服务端。
一个很容易忽略的问题:同一款天气接口,不同套餐返回的字段结构可能不一样,单位也不一样。有的接口温度用摄氏度,有的用华氏度,风速有km/h和m/s两种,降水量有的是毫米有的是英寸。代码里一定要做一层“字段标准化”的转换,否则后面做缓存、做对比时,数据会很难保持一致。
2.3 项目目录结构:给后续维护留好余地
小程序项目的目录,很多人习惯把所有代码堆在pages里,页面多了以后改一个公共逻辑要翻好几个文件。我推荐从第一天就分好层:
├── pages │ ├── index # 首页:实时天气+逐日预报 │ └── city # 城市选择页 ├── utils │ ├── request.js # 请求封装 │ ├── weather.js # 天气字段标准化 │ ├── cache.js # 缓存读写与过期判断 │ └── location.js # 定位与逆地理编码封装 ├── config │ └── index.js # 全局配置(接口地址、缓存时长等) └── app.js / app.json # 小程序入口与全局配置这个结构不复杂,但很有效。请求封装负责统一加公共参数和错误处理,页面里就不用重复写回调逻辑;缓存逻辑单独抽出去,后面要调整缓存时间只改一处。项目虽小,分层意识能帮你少走很多弯路。
3. 初始化与全局配置:从空目录到能跑的骨架
3.1 注册与准备:没有测试号也能开工
开发微信小程序的第一步是在小程序后台注册一个账号,拿到AppID。个人主体也能注册,流程很简单,跟着后台引导填资料就行。如果你的账号一时半会儿下不来,开发者工具还支持“测试号”,只是部分能力受限,比如不能真机预览和发布,但用来跑通本地开发流程足够。
创建项目时,建议选择“不使用云服务”的纯前端模板起步。云开发后面需要再加,一开始就引入反而增加配置复杂度,分散精力。一个干净的JavaScript模板,比TypeScript或云开发模板更适合第一次做小程序的人,原因很简单:报错信息更直接,依赖更少。
3.2 app.json与tabBar:设置应用级配置
全局配置里最重要的字段是pages和window。pages数组决定哪些页面参与编译,第一个页面就是启动页。window字段统一配置导航栏标题、背景色和文字样式。
{ "pages": ["pages/index/index", "pages/city/city"], "window": { "navigationBarTitleText": "今日天气", "navigationBarBackgroundColor": "#4A90D9", "navigationBarTextStyle": "white", "backgroundColor": "#EFF3F8" }, "permission": { "scope.userLocation": { "desc": "你的位置信息将用于获取当前位置的实时天气" } } }这里有一个容易被忽略的点:permission.scope.userLocation中的描述文案,在申请定位权限时用户会直接看到。一定要写清楚你的用途,比如“用于获取当前位置的实时天气”。如果描述含糊,用户很可能直接拒绝授权,后面所有定位逻辑都白做。另外,现在很多小程序版本对位置接口有额外的隐私协议要求,需要在后台配置用户隐私保护指引,并在代码里检测用户是否已经同意隐私协议,这个等真机调试时再细说。
3.3 tabBar的取舍:第一版建议不要做多标签
很多人喜欢上来就做一个带tabBar的应用,底部放“天气”“城市”“设置”三个标签。我对模拟项目X第一版的态度是完全不做tabBar,只保留首页和城市选择页。原因很简单:城市选择页通过wx.navigateTo跳转,返回逻辑天然存在,不需要tabBar来维护页面栈。而且多tab意味着每个页面都要处理数据刷新时机,开发和调试成本翻倍。
等以后要加入城市管理、个人设置这类独立模块时,再上tabBar也不迟。前期少一个重量级交互,就能把更多精力放在天气数据的展示质量上。
4. 页面骨架与样式实现:先让假数据长得像真页面
4.1 WXML结构拆分:数据、状态、容器分离
开发页面时,我习惯先把UI拆成几个独立的语义模块,每个模块对应一段明确的业务含义。首页的WXML结构大概是这样:
<view class="page"> <!-- 顶部天气信息区 --> <view class="weather-header"> <view class="city-row"> <text class="city-name" bindtap="openCityPicker">{{city}}</text> <text class="update-time">{{updateTime}}</text> </view> <view class="temp-display">{{currentTemp}}°</view> <view class="weather-desc">{{weatherText}}</view> </view> <!-- 实时指标区 --> <view class="metrics-grid"> <view class="metric-item"> <text class="metric-label">湿度</text> <text class="metric-value">{{humidity}}%</text> </view> <view class="metric-item"> <text class="metric-label">风速</text> <text class="metric-value">{{windSpeed}} km/h</text> </view> <view class="metric-item"> <text class="metric-label">能见度</text> <text class="metric-value">{{visibility}} km</text> </view> </view> <!-- 逐日预报区 --> <view class="forecast-list"> <view class="forecast-row" wx:for="{{forecast}}" wx:key="date"> <text>{{item.week}}</text> <text>{{item.weatherText}}</text> <text>{{item.low}}° / {{item.high}}°</text> </view> </view> <!-- 加载状态 --> <view class="loading" wx:if="{{loading}}">加载中...</view> <!-- 错误状态 --> <view class="error" wx:elif="{{error}}"> <text>{{errorMsg}}</text> <button bindtap="retry">重新加载</button> </view> </view>这里面的细节很多,举几个容易踩坑的点。
wx:key必须给到列表渲染的一个稳定字段,比如日期字符串。如果不写wx:key,小程序会发出警告,而且后续列表重排时性能会变差,状态也可能串位。加载状态和错误状态用了wx:if和wx:elif的互斥,避免同时出现白色屏和提示语互相遮挡。城市名那行加了bindtap跳转,因为这是用户最常用的操作之一,点击成本越短越好。
4.2 WXSS实现要点:rpx适配与视觉层级
微信小程序的样式单位推荐用rpx。它的规则是:任何屏幕宽度都算作750rpx,所以iPhone 375px的屏幕,1px等于2rpx。用rpx写尺寸,不需要再为不同机型做媒体查询,这是小程序相比普通H5最舒服的一点。
样式设计上有两个方向:浅色卡片风和深色沉浸风。我第一版选了浅色背景加白色卡片,因为对新手调试更友好,元素边界清楚。等后续想换风格,只需要替换背景色和卡片的背景变量,文字和布局几乎不用动。建议把颜色定义成CSS变量放在app.wxss里:
page { --primary-bg: #EFF3F8; --card-bg: #FFFFFF; --text-primary: #1F2937; --text-secondary: #6B7280; }天气类应用最怕的其实是“数据为空时页面塌陷”。比如接口没返回湿度字段,{{humidity}}%就会显示成“undefined%”。这个需要在数据初始化和标准化阶段处理成默认值,后面第5章会专门讲。
4.3 数据绑定:先给页面一套完整的假数据
在接通真实接口之前,先在data里塞一份和真实接口结构一致的假数据,把页面跑起来,确认UI没有问题,再回头做接口对接。这一步非常值得。
假数据要模拟真实数据的边界:温差大的情况、湿度90%以上的情况、负温度的情况。我当时只写了“28°晴”这种理想数据,后来接真接口时,页面能正常显示,但遇到“-12°大雪”的场景,样式上出现了一些未定义行为,比如某些字段没有值、单位显示异常。所以假数据要多写几组,最好能覆盖晴天、雨天、雪天、极端温度和各字段缺失的情况。
5. 接口对接与数据处理:核心链路在这里
5.1 请求封装:把Promise和错误处理统一起来
小程序自带wx.request,但直接在页面里调用,会出现大量重复代码,也不方便统一处理会话失效和接口异常。我把它封装进utils/request.js:
function request(options) { const baseUrl = config.apiBaseUrl; return new Promise((resolve, reject) => { wx.request({ url: `${baseUrl}${options.path}`, method: options.method || 'GET', data: options.data || {}, timeout: 10000, success(res) { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(res.data); } else if (res.statusCode === 401 || res.statusCode === 403) { reject(new Error('接口鉴权失败,请检查密钥')); } else { reject(new Error(`服务端返回异常:${res.statusCode}`)); } }, fail(err) { reject(new Error(`网络请求失败:${err.errMsg}`)); } }); }); }注意几个细节:10秒超时必须显式设置,不设置的话弱网环境下请求可能挂很久,用户以为小程序卡死。401和403单独拆出来提示,因为这类错误和普通网络错误处理方式不同,通常是密钥或者IP白名单问题。小程序请求域名必须是HTTPS,并且要在后台配置请求合法域名,否则真机上会直接被拦截,这个可以在第7章排错时详聊。
5.2 字段标准化:一劳永逸解决单位与命名问题
天气接口返回的字段命名五花八门,有的叫tmp,有的叫temp,湿度有的叫hum,有的叫relative_humidity。页面里直接引用这些乱七八糟的命名,后面容易把自己绕晕。
我建了utils/weather.js,把接口原始数据统一转成内部数据结构:
function normalizeWeather(raw) { const _temp = Number(raw.temp || raw.temperature || raw.tmp); const _humidity = Number(raw.humidity || raw.hum || raw.relative_humidity); return { city: raw.city || '未知城市', weatherText: raw.condition || raw.text || raw.weather || '未知天气', currentTemp: convertToCelsius(_temp), humidity: _humidity >= 0 && _humidity <= 1 ? Math.round(_humidity * 100) : Math.round(_humidity), windSpeed: raw.windSpeed || raw.wind_speed || 0, ... }; }这里最关键的是单位换算逻辑。假如接口返回的是开尔文温度,直接减273.15;返回的是华氏度,要转成(F - 32) * 5 / 9。我当时被这个坑折磨了大半天:接口文档明明写了“温度”,结果返回的是K,显示成“302°”,我一度以为是自己没写对。
湿度也有类似的坑。有的接口返回0到1之间的浮点数,有的返回0到100的整数。统一成百分比之前要判断取值范围,不然会出现“0.85%”这种诡异显示。
5.3 渲染映射:把标准化数据送入页面
标准化之后,页面这边的逻辑就非常简单了:
const data = normalizeWeather(res.data.now); this.setData({ city: data.city, currentTemp: data.currentTemp, weatherText: data.weatherText, humidity: data.humidity, windSpeed: data.windSpeed });有朋友会问,为什么不直接在页面里做单位转换?因为同样的字段未来可能用在城市选择页、分享卡片、图表等场景里,转换逻辑越靠底层,维护成本越低。
不过也有一个很容易忽视的问题:接口里“逐日预报”的数组排序和隔天边界。有的接口按日期升序返回未来7天,有的第一项是“今天”,有的第一项是“明天”。我建议在标准化时就把第一项明确设置为“今天”,如果接口语义不同,就补一项占位或做shift操作,保证页面上显示的永远是用户预期的顺序。
5.4 降级策略:接口失败时不至于一片空白
真实场景里,接口一定会有失败的时候。我在请求失败时做了这样的降级逻辑:
- 如果有缓存数据,优先展示缓存,并在页面顶部提示“数据可能不及时”。
- 如果没有缓存,就展示错误状态页,提供“重新加载”按钮。
- 定位失败时,自动切到一个默认城市,同时弹出提示。
这套降级策略不是浪费时间,而是决定用户对你的小程序是否信任的关键。很多时候用户第一次打开你的小程序,恰好网络不好,如果直接白屏,基本不会再打开第二次。有一版缓存兜底,至少能看到上一次能看的内容。
6. 定位与城市切换:权限流程比想象中麻烦
6.1 授权前的检查:别直接调getLocation
很多教程上来就写wx.getLocation,但真机上一运行,用户手机弹窗的是“小程序需要获取你的位置”,用户没看懂就点了拒绝。更麻烦的是,用户一旦在弹窗上点了“拒绝”,下次再调用甚至不会弹窗,只会直接进入fail回调。
所以我做的第一件事是检查授权状态:
function checkLocationAuth() { return new Promise((resolve, reject) => { wx.getSetting({ success(res) { const granted = res.authSetting['scope.userLocation']; if (granted === true) { resolve(true); } else if (granted === false) { reject(new Error('用户已拒绝位置授权')); } else { reject(new Error('尚未询问过授权')); } } }); }); }这里还涉及到隐私协议的问题。如果小程序后台配置了用户隐私保护指引,那么在调用位置接口前,最好先确认用户已经同意隐私协议。否则直接调getLocation,某些基础库版本上会返回errMsg: "getLocation:fail privacy permission is not authorized"。我一个朋友就遇到过这种情况,他自己不是后端,看报错半天没反应到隐私协议上。
6.2 逆地理编码:拿到坐标之后转成城市名
授权通过后,调用wx.getLocation拿到经纬度,但这只是WGS84或GCJ02坐标,不是用户认识的城市名,所以需要逆地理编码。一般有两种做法:
- 用天气服务商自带的定位转换接口,传入经纬度返回城市ID。
- 用地图服务的逆地理编码接口,返回省市区信息。
我用的是第二种,因为它返回的行政区划信息更完整,可以顺便解决重名城市的问题。拿到城市名后,再去请求天气接口,传入城市ID或城市拼音。这里有一个优化点:逆地理编码接口同样有配额限制,而且也不是每次都需要调用。应该把经纬度对应的城市信息缓存起来,同一个城市十分钟内不要重复转换。
6.3 手动选城市:兜底方案必须做
定位授权被拒绝后,页面不能什么都做不了。我在首页右上角加了一个城市点击入口,跳到城市选择页。城市选择页维护一个常用城市列表,用户点击某个城市后,把选择结果存到全局缓存,再返回首页刷新数据。
这个兜底方案在我上线测试时救了一命。因为很多测试用户就是在开发者工具里直接点了拒绝授权,如果没有手动选择城市的功能,他们根本看不到任何天气信息,整个小程序就成了“欢迎使用,请授权”的死循环。一个小功能,把用户流失率降下来不少。
7. 缓存策略:让第二次打开的小程序不再转圈
7.1 为什么缓存是天气预报的刚需
天气数据本身变化频率不高,但你每次冷启动都去请求一遍,不仅慢,还容易撞上接口服务的免费额度。所以我给模拟天气Demo设计的缓存策略是一个经典的“时间过期”方案:
const CACHE_KEY_PREFIX = 'weather_cache_'; const CACHE_TTL = 30 * 60 * 1000; // 30分钟 function getCache(cityId) { const key = `${CACHE_KEY_PREFIX}${cityId}`; const cached = wx.getStorageSync(key); if (!cached) return null; const now = Date.now(); if (now - cached.timestamp > CACHE_TTL) return null; return cached.data; } function setCache(cityId, data) { const key = `${CACHE_KEY_PREFIX}${cityId}`; wx.setStorageSync(key, { timestamp: Date.now(), data }); }缓存key必须带上城市标识,不然切换城市时,上一座城市的数据会把新城市的覆盖掉。缓存里同时存数据和写入时间,判断是否过期时直接拿当前时间戳减写入时间。过期时间我设了30分钟,看起来不长,但足够让大多数用户在早上出门前看到的天气是新鲜的,又不会频繁打接口。
7.2 冷启动与热切换的读取顺序
页面加载时,我先读缓存,再判断是否过期。如果没过期直接渲染,同时在后台静默请求一次最新数据来更新缓存;如果过期,则显示loading,等请求成功后渲染并写入缓存。静默刷新这个设计有一个好处:用户感知上是“秒开”,数据又不会太陈旧。
示例逻辑简化版:
onLoad() { const cached = getCache(this.data.cityId); if (cached) { this.applyWeatherData(cached); this.refreshWeather(false); } else { this.refreshWeather(true); } }代码里区分了showLoading参数:有缓存时刷新不显示loading,避免页面闪烁;无缓存时显示loading,给用户一个反馈。这个体验细节,很多初学者都会漏掉。
7.3 缓存写入的边界:别把异常数据存进去
缓存也不是什么都能存。如果接口已经返回了错误对象,或者标准化后发现关键字段缺失,直接写入缓存,下次用户看到的可能是“undefined%”之类的错误内容。我建议在写入前加一道校验:
function isWeatherDataValid(data) { return data && typeof data.currentTemp === 'number' && data.weatherText; } if (isWeatherDataValid(normalizedData)) { setCache(cityId, normalizedData); }校验函数不需要太复杂,抓住几个核心字段就够了。有了这一层,就不会出现“缓存了错误信息,而错误信息还会持续占用30分钟”的诡异现象。
8. 真机调试与排错:从“模拟器没问题”到“手机一片白”
8.1 常见卡点一:域名白名单和HTTPS证书
在开发者工具里请求天气接口,通常不会遇到域名校验,但上真机预览时,小程序会强制检查域名是否在后台“request合法域名”中。我一开始不知道这个机制,真机上始终进不了接口,打开调试模式也只有一个“url not in domain list”的提示,当时真的血压飙升。
解决方式很简单:去小程序后台的“开发设置”里找到服务器域名配置,把天气服务商和逆地理编码服务商的HTTPS域名加进去。需要注意,域名不能带路径、不能带协议头,而且要确保证书是有效的、不是自签名的。个人开发者在未认证的小程序上,这个配置对真机预览也生效。
如果只是想临时调试,可以在开发者工具右上角勾选“不校验合法域名”。但这个选项只对本地工具生效,真机预览不适用。
8.2 常见卡点二:密钥权限不足或请求超限
接口请求返回403或401,通常有两个原因:密钥本身没有权限访问对应套餐,或者IP不在白名单中。某些天气服务商要求设置IP白名单,而你在开发者工具里用电脑访问通过了,真机走的是手机网络,IP白名单没放行,就会在手机上反复失败。
排查思路是先把错误信息完整打印出来,确认是哪些状态码,再去后台调整白名单。我当时看到一个401,第一反应是密钥抄错了,核对半天没发现异常,后来才发现同一款接口的免费套餐里,某些子接口并不包含在权限范围内,需要单独申请开通,或者换一个套餐。
8.3 常见卡点三:定位权限与隐私协议叠加
真机调试中定位失败的概率比开发者工具里高得多,因为工具里可以随便模拟位置,手机上却要面对真实的权限弹窗、低精度定位和隐私协议三重问题。我建议真机调试定位时,用一个“调试辅助按钮”直接打印当前授权状态、隐私协议同意状态和经纬度三个值,一次性看清卡在哪一环。
function debugLocation() { wx.getPrivacySetting({ success: (res) => console.log('隐私协议状态', res.privacyContractName, res.needAuthorization), fail: (err) => console.error('获取隐私协议状态失败', err) }); wx.getSetting({ success: (res) => console.log('位置授权状态', res.authSetting) }); }这个辅助函数不会影响最终代码,但在排错时能节省大量时间。
8.4 单位与数据显示的隐蔽坑
在开发者工具里显示正常的温度,真机上看觉得“不对劲”,多半是单位处理不一致。我遇到过一个很典型的问题:同一个接口的“逐日预报”返回的温度是K,但实时天气返回的是摄氏度。单独看每个接口都没问题,放在一起就有“今天28°C,明天275K”的闹剧。最后只能用标准化函数把两个接口各自转换,再统一成内部单位,页面才能保持一致。
另一个隐蔽问题是时区。有的接口返回的日期是“2025-06-01 00:00:00”,看起来没问题,但如果用new Date(str)解析,不同机型对带不带时区标识的解析规则不同,可能导致星期几计算错误。我的做法是不用字符串解析,让服务商直接返回时间戳,或者自己按固定格式拼日期字符串,避免引擎差异。
9. 上线之外,还能往哪些方向优化
9.1 把密钥藏到服务端
第一个要优化的方向,就是把目前暴露在前端代码里的API密钥迁移到云函数或自己的服务端中转。前端只请求自己的域名,由服务端统一管理密钥、控制频率、记录日志。这样做既安全,也方便后续做多用户限流和数据统计。一个简单的云函数接收城市参数,返回标准化后的天气数据,比前端直接对接服务商更稳。
没有条件上云函数的话,至少要做到:在代码仓库里不要把密钥提交进去,部署时用环境变量注入。
9.2 增加生活指数与自定义卡片
天气数据的价值不止于温度。后续可以接入穿衣指数、运动指数、紫外线指数,结合用户所在城市的经纬度算出日出日落时间。这些数据会让页面从“能显示天气”进阶为“真正打动用户”。加需求的时候要克制,先加一个用户最可能看的生活指数,比如“今天穿什么”,而不是一口气塞满所有指数。
9.3 做更好的缓存和更新策略
30分钟缓存可以继续优化成“分接口缓存”:实时天气缓存15分钟,逐日预报缓存2小时,生活指数缓存4小时。因为不同数据的变化节奏不一样,统一用一种过期时间要么造成频繁请求,要么数据太旧。同一套缓存工具,给不同数据传入不同TTL,改动量很小,回报明显。
9.4 包体积与渲染性能
如果以后页面内容变多,逐日预报列表可以做虚拟滚动或只渲染前7条;背景图尽量用网络图片而非本地大图;图片资源压缩到合理范围。小程序的体验评分工具会给出包体提示,跟着它清理无用资源和代码,比加一堆性能插件更直接。
10. 我的收尾建议:请按这个顺序动手
如果你打算凭这篇文章自己完整做一遍,我建议你严格按这个顺序走:先注册账号拿到AppID,再搭一个只显示假数据的单页面,然后接天气接口,再接定位,最后做缓存和真机排错。不要上来就想着把城市选择页、生活指数、图标动画一起做完。核心数据链路先通,其他都是锦上添花。
我自己在写模拟天气Demo的过程中,最大的体会不是“会写代码就行”,而是“每一步的决策都要有依据”。为什么选这个服务商,为什么缓存30分钟,为什么这么设计错误降级,这些问题的答案会直接决定项目的稳定性和后续维护成本。源码我已经整理成了可以直接替换AppID和密钥运行的版本,评论区也有获取方式。跑通之后建议你改一个小细节试试,比如把缓存时间改成10分钟,再观察一下接口调用次数和页面加载速度的变化——亲手感受一次优化过程,比记住十个功能点更有用。