
简介这是一份基于微信原生框架开发的货币汇率小程序完整项目源码面向小程序入门开发者、前端工程师及有汇率工具定制需求的个人或团队也适合作为移动端实战练习与毕业设计的参考范本。压缩包共一百六十一个文件大小仅为1.1MB以一百四十七张PNG效果截图与图标素材为主配合JS逻辑、JSON配置、WXSS样式和WXML页面文件结构清晰模块划分明确目前已有八十六人学习或下载。源码开放且几乎没有第三方依赖涵盖货币添加、汇率展示、本地配置等核心逻辑目录中页面与工具模块区分清楚便于理解应用初始化、页面跳转和数据处理流程自带效果截图方便对照UI与交互开发者既能快速跑通原生小程序开发流程也可灵活定制货币列表与刷新策略甚至扩展微信支付、分享等微信生态能力作为课程设计或个人作品均可快速落地。1. 货币汇率小程序在原生开发框架下到底要做哪些事微信小程序 货币汇率 原生开发框架表面是查汇率实际是一道典型工程题数据是公开的难点是把接口、换算、列表、刷新这条链路在小程序里跑顺。原生框架意味着不走 uni-app/HBuilderX 编译链WXML、WXSS、JS 各司其职反而能看清运行时模型逻辑层与视图层分离、setData 是唯一跨层通道、请求要过合法域名校验。“源码 效果截图示例”说明这是能直接导入开发者工具跑起来的项目实例适合两类人做微信小程序毕业设计的学生以及想快速确认汇率接口接入细节的工程师。读完后拿到任何同类源码包都能在半小时内理清数据结构、页面职责和刷新策略。2. 汇率接口选型与 wx.request 封装原生小程序的数据接入方案汇率小程序的第一道坎不是页面是数据从哪来、请求怎么写、为什么在开发者工具里能跑通、一上真机就报错。这一章按“选接口 → 封装请求 → 配域名”三步走每一步都有对应的代码和排查点。2.1 免费汇率接口怎么选限流、字段与更新频率常见做法是直接用无需鉴权的公开汇率接口或者国内聚合类接口。选型时我一般看三件事请求是否要 key、返回的基准货币是什么、免费额度够不够。下表是几个高频选项接口是否需要 key基准货币更新频率说明Frankfurter欧洲央行数据否默认 EUR工作日每日字段简单支持?base切换基准适合学习和演示聚合数据国内通道是appkey可按需选实时/每日国内访问快免费额度有限OpenExchangeRates是USD小时级免费档限制请求次数商用要付费中行外汇牌价否美元、港币等每日页面反爬较强不适合直接抓取选型的核心逻辑是如果只想本地快速跑通Frankfurter 最省事因为它同时支持latest和timeseries两个端点前者拿当前汇率后者拿历史走势正好覆盖列表页和趋势图两个场景。如果项目要求展示的是人民币对主要外币的牌价且面向国内用户聚合数据这类接口在可用性上更稳妥代价是必须处理 appkey、签名和时间戳。返回字段的差异要特别注意。Frankfurter 的响应大致是{ base: USD, date: 2025-01-15, rates: { CNY: 7.24, JPY: 157.3 } }rates里不包含 base 自身计算时必须自己补一个{ [base]: 1 }。有些接口返回的是字段名不同的结构源码里解析汇率的那一段需要按接口文档逐字段核对这也是拿到源码包后第一个要改的地方。2.2 用 Promise 封装 wx.request超时中断与重试参数wx.request本身是回调式 API页面里到处嵌套 success/fail 会让代码很难维护。原生框架下的通用做法是封装一个返回 Promise 的请求模块再把超时和重试统一进去。下面是我常用的最小封装// utils/request.js —— 基于 wx.request 的 Promise 封装 const REQUEST_TIMEOUT 5000; // 毫秒汇率接口 5 秒足够 function request(options {}) { return new Promise((resolve, reject) { wx.request({ url: options.url, method: options.method || GET, data: options.data || {}, timeout: options.timeout || REQUEST_TIMEOUT, header: Object.assign({ Content-Type: application/json }, options.header), success(res) { if (res.statusCode 200 res.data) { resolve(res.data); } else { reject(new Error(HTTP_ res.statusCode)); } }, fail(err) { // err.errMsg 形如 request:fail timeout 或 request:fail url not in domain list reject(new Error(err.errMsg || NETWORK_ERROR)); } }); }); } module.exports { request };timeout参数需要基础库 2.10.0 以上项目配置文件里的libVersion如果低于这个版本超时会不生效。success 分支只认 HTTP 200业务层的错误码由调用方处理不在这里混在一起。fail 分支把errMsg原样抛出去因为域名未配置和网络断开在真机上表现不同保留原始信息方便定位。重试逻辑单独封装避免每次调用都写循环// utils/request.js —— 追加带退避重试的封装 function sleep(ms) { return new Promise((resolve) setTimeout(resolve, ms)); } async function requestWithRetry(options, retry 2) { for (let attempt 0; ; attempt) { try { return await request(options); } catch (err) { // HTTP 状态码错误不做重试避免用流量刷一个必败的请求 if (attempt retry || err.message.indexOf(HTTP_) 0) { throw err; } await sleep(300 * (attempt 1)); // 第 1 次等 300ms第 2 次等 600ms } } } module.exports { request, requestWithRetry };retry参数表示额外重试次数默认 2意味着最多发 3 次请求。重试只针对超时和断网HTTP 4xx/5xx 直接抛出因为免费接口的限流错误429本身就是在告诉你“别打了”继续重试只会触发更长的封禁。注意免费接口的配额往往按天计可能只有几百次页面里 30 分钟自动刷新一次一天也就几十次请求够了。2.3 request 合法域名开发者工具、真机与云函数的差异原生框架下最典型的一个坑是开发者工具里一切正常手机扫码预览后请求全部失败控制台报request:fail url not in domain list。这是因为开发者工具默认不校验合法域名而真机预览会严格校验。开发阶段的解法是打开工具的“详情 → 本地设置 → 不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”这个开关真机预览扫码后这个开关不生效要临时放行可以用“真机调试”模式它允许在未配置域名的情况下发请求适合接口联调。但体验版和正式版必须在小程序后台配置mp 后台 → 开发管理 → 开发设置 → 服务器域名 → request 合法域名填入https://开头的域名不要带路径。这里有一个容易被忽略的现实约束request 合法域名要求域名完成 ICP 备案。如果一个开源汇率接口域名没有备案提交配置会被拦。常见做法是把请求从页面挪到微信云开发云函数里页面只调wx.cloud.callFunction云函数内部用普通的 HTTP 库请求外部汇率接口再把结果回传。这样绕开了合法域名限制也顺带解决了接口 key 暴露在前端的问题适合有一定后端意识的开发者。3. 汇率换算与列表渲染从汇率矩阵到 WXML 呈现拿到接口数据只是第一步真正的业务逻辑在换算和渲染。原生框架里汇率换算代码要写在工具模块里保持纯函数列表渲染则要处理好 key、过滤和机型适配。3.1 buildConverter 函数怎么写交叉汇率与基准归一化汇率接口返回的 rates 都相对于同一个基准货币。比如 Frankfurter 用?baseUSD时返回CNY: 7.24、JPY: 157.3意思是 1 美元兑 7.24 人民币、157.3 日元。用户要算“100 人民币能兑多少日元”不能直接拿 100 乘 157.3得先把人民币换成美元再用美元换日元100 / 7.24 × 157.3 2172.65 日元这个公式用代码表达就是“金额先除以来源货币汇率再乘目标货币汇率”。为了支持任意基准先给 rates 补上基准货币自身为 1再写闭包// utils/rates.js —— 把接口返回的汇率归一化到任意基准 function buildConverter(rates, base) { // rates 形如 { CNY: 7.24, JPY: 157.3 }这些值都相对 base比如 USD const normalized Object.assign({ [base]: 1 }, rates); return function convert(amount, from, to) { if (from to) return amount; const fromRate normalized[from]; const toRate normalized[to]; if (!fromRate || !toRate) { throw new Error(RATE_NOT_FOUND: from / to); } return amount / fromRate * toRate; }; } module.exports { buildConverter };Object.assign({ [base]: 1 }, rates)这行的作用是当用户选择 USD 作为换算基准时normalized.USD恒等于 1那么convert(100, USD, CNY)就是100 / 1 × 7.24逻辑统一。对于 rates 里缺失的货币直接抛RATE_NOT_FOUND便于在控制台定位而不是让页面渲染出 NaN。如果需要更激进的兜底可以用 CNY 或 USD 做二次中间换算但会引入两次舍入误差展示层必须兜住。3.2 用 wx:for 渲染货币列表wx:key、搜索过滤与顶部导航栏适配列表页是汇率小程序的主体。原生框架用wx:for渲染常见写法如下view classcurrency-list input classsearch placeholder搜索货币代码或名称 bindinputonSearchInput / block wx:for{{visibleCurrencies}} wx:keycode view classcurrency-item {{item.code selected ? active : }} bindtaponSelectCurrency>// pages/index/index.js —— 列表过滤与选中 Page({ data: { allCurrencies: [], visibleCurrencies: [], selected: USD }, onSelectCurrency(e) { const code e.currentTarget.dataset.code; this.setData({ selected: code }); }, onSearchInput(e) { this.applyFilter(e.detail.value); }, applyFilter(keyword) { const kw keyword.trim().toUpperCase(); let list this.data.allCurrencies.filter(item item.code.includes(kw) || item.name.includes(kw) ); this.setData({ visibleCurrencies: list }); } });wx:keycode是关键。列表经过搜索过滤后索引会变化如果省略 wx:key 或写成 index视图层做节点复用时会出现状态错乱典型的症状是滚动位置跳变和 input 焦点丢失。>// 计算自定义导航栏高度适配不同机型的胶囊位置 const menu wx.getMenuButtonBoundingClientRect(); const { statusBarHeight } wx.getSystemInfoSync(); const navHeight (menu.top - statusBarHeight) * 2 menu.height;这段代码的含义是胶囊顶部到屏幕顶部的距离减去状态栏高度得到胶囊在导航栏内的垂直偏移乘 2 再补上胶囊自身高度就得到了自定义导航栏应有的总高度。源码包里如果写死了导航栏高度在全面屏机型上一定会错位替换成这套动态计算即可。3.3 换算金额的精度处理浮点误差、四舍五入与展示位数汇率数据通常带 4 到 6 位小数金额换算出来是一长串浮点数比如 2172.6519337016575。原生框架下直接渲染这个数字既不专业还踩了 JavaScript 浮点精度的坑0.1 0.2不是0.3这是 IEEE 754 双精度表示决定的任何浏览器环境都一样。我提供一个通用处理函数分两步先修正浮点误差做四舍五入再做千分位格式化// utils/format.js —— 金额与汇率展示格式化 function round(value, digits) { if (!isFinite(value)) return NaN; const factor Math.pow(10, digits); // Number.EPSILON 用于修正如 1.005 * 100 100.4999999 的浮点误差 return Math.round((Number(value) Number.EPSILON) * factor) / factor; } function formatMoney(value, digits 2) { const num round(value, digits); if (isNaN(num)) return --; const [int, dec] String(num).split(.); const intPart int.replace(/\B(?(\d{3})(?!\d))/g, ,); return dec ? ${intPart}.${dec.padEnd(digits, 0)} : intPart; } module.exports { round, formatMoney };Number.EPSILON加在乘法之前是为了抵消1.005 * 100这类二进制表示误差。注意toFixed返回的是字符串且对不同引擎的.5舍入行为并不总是四舍五入所以不要用parseFloat(x.toFixed(2))去修正业务数值。展示位数上有一个约定俗成的标准场景保留位数说明汇率值4 位汇率波动小4 位才能体现差异换算金额2 位面向用户的金额展示中间换算过程不提前舍入先算完再展示避免误差累积换算链路里最容易犯的错是在中间步骤提前调用 round比如先算完美元再舍入再乘日元两轮误差叠加后结果可能比真实值差出 0.5 个日元。正确做法是过程全精度只在最后渲染时调一次formatMoney。4. 数据刷新与状态管理原生小程序的缓存、定时器与 setData 性能数据接入和页面渲染跑通之后接下来是体验问题二次进入要不要重新加载汇率要不要自动刷新列表更新怎样不卡顿原生框架下这三个问题分别对应缓存、生命周期和 setData 粒度。4.1 冷启动秒开用 wx.setStorageSync 缓存汇率并设置过期时间很多新人纠结“加载页怎么改”真正该改的是首屏策略。汇率数据不是实时变化的强需求工作日一天变动几次没必要每次冷启动都转圈等接口。常见做法是首次请求成功后把结果连同时间戳写进本地缓存再次进入先渲染缓存、再静默刷新// pages/index/index.js —— 缓存读优先 后台更新 const CACHE_KEY fx_rates_cache; const CACHE_TTL 60 * 60 * 1000; // 1 小时 Page({ onLoad() { const cached wx.getStorageSync(CACHE_KEY); if (cached Date.now() - cached.updatedAt CACHE_TTL) { this.applyRates(cached); // 先用缓存渲染首屏无等待 this.fetchRates(true); // 后台静默刷新 } else { this.fetchRates(false); // 无缓存显示加载态 } }, applyRates(payload) { this.setData({ rates: payload.rates, base: payload.base, updatedText: formatTime(payload.updatedAt) }); }, fetchRates(silent) { if (!silent) this.setData({ loading: true }); requestWithRetry({ url: ratesApi() }) .then(data { const payload { rates: data.rates, base: data.base, updatedAt: Date.now() }; wx.setStorageSync(CACHE_KEY, payload); this.applyRates(payload); }) .finally(() this.setData({ loading: false })); } });缓存条目的结构是{ rates, base, updatedAt }三个字段缺一不可updatedAt用于判断过期base用于切换基准货币时失效处理。用户如果手动切换了基准货币缓存必须清掉或用新 key 重新存否则展示的是上一次基准下的数值。TTL 定为 1 小时是经验值工作日汇率更新频率低周末更是全天不变化1 到 4 小时都合理设置太短反而会频繁打接口浪费免费配额。4.2 下拉刷新与定时刷新onPullDownRefresh 与 setInterval 的配对使用刷新策略分两种用户主动下拉和系统定时自动拉。下拉刷新需要在页面 json 里开启{ enablePullDownRefresh: true, backgroundTextStyle: dark }对应的生命周期处理要和定时器正确配对。定时器如果在 onLoad 里启动页面切到后台再回来就会失控正确配对是 onShow 启动、onHide 停止// pages/index/index.js —— 下拉与定时刷新的生命周期配对 Page({ onShow() { this.startAutoRefresh(); }, onHide() { this.stopAutoRefresh(); }, onUnload() { this.stopAutoRefresh(); }, onPullDownRefresh() { this.fetchRates(true).finally(() wx.stopPullDownRefresh()); }, startAutoRefresh() { this.stopAutoRefresh(); // 防止重复启动 this._timer setInterval(() this.fetchRates(true), 30 * 60 * 1000); }, stopAutoRefresh() { if (this._timer) { clearInterval(this._timer); this._timer null; } } });注意两点onPullDownRefresh回调里必须调用wx.stopPullDownRefresh()否则下拉动画会一直停在顶部fetchRates要返回 Promise.finally才能保证不管成功失败都把动画收掉。定时器方面小程序进入后台后 setInterval 会被系统挂起回到前台由 onShow 重新启动这正好避免了真机上长时间后台跑定时器造成的无效请求和限流。刷新方式触发时机注意事项下拉刷新用户手势必须收尾 stopPullDownRefresh定时刷新每 30 分钟onShow 启、onHide 停勿用 onLoad冷启动缓存读onLoad带 TTL 过期判断展示更新时间4.3 setData 更新粒度用数据路径只更新变化的汇率字段原生框架的逻辑层和视图层分离setData 是唯一的跨层通道传输的是序列化后的数据。整页 setData 一个大对象视图层要做全量 diff列表越长越卡。汇率列表里几十个货币每秒或每几分钟更新几个汇率完全没必要全量替换// 反面全量替换视图层白做大量 diff this.setData({ rates: newRates }); // 正面用数据路径只更新变化的字段 const patch {}; for (const code of changedCodes) { patch[rates. code] newRates[code]; } this.setData(patch);patch[rates. code]这种动态 key 是小程序官方支持的数据路径语法setData 接收一个扁平对象每个 key 是路径值为新数据。视图层只需要对这个路径做局部更新比全量替换快一个量级。列表项的更新也同理只改visibleCurrencies[0].rateText不要重设整个数组。setData 还有两个隐藏约束。一是只能传可 JSON 序列化的数据Date 对象、函数、RegExp 传过去要么丢失要么报错二是 setData 本身是异步渲染它的第二个参数回调会在视图层更新完成后触发不要在回调里做耗时的逻辑。开发者工具里“体验评分”的性能面板会直接标出频繁的大体积 setData排到性能问题第一位的往往就是它。5. 上线前核验与三个实用技巧目录导入、抓包定位与 canvas 走势图最后一个部分讲拿到源码包之后怎么快速验证、真机出问题怎么定位以及不给小程序引图表库的前提下怎么把 7 日走势画出来。5.1 拿到源码包后的第一件事核对 project.config.json 与页面注册这类源码包解压后通常是标准原生结构project/ ├── app.js / app.json / app.wxss ├── pages/ │ ├── index/ # 汇率列表 │ ├── trend/ # 走势图 │ └── settings/ # 基准货币设置 ├── utils/ │ ├── request.js │ └── rates.js ├── project.config.json └── docs/screenshots/ # 效果截图通常放这里导入开发者工具时选目录即可但有三处必须核对。第一project.config.json 里的appid源码作者往往会留自己的测试 appid直接使用可能和你的账号冲突换成测试号或你自己的小程序 appid。第二app.json里的pages数组这是页面注册表新写页面后忘记注册会直接报“找不到页面”。第三对照docs/screenshots里的效果截图看首屏渲染截图能帮你确认设计稿里的导航栏、标签页和列表项样式。不用去下载所谓一键反编译工具源码就在手上反编译只适合研究没有源码的线上包。5.2 真机预览失败怎么定位合法域名错误与微信小程序抓包真机预览最常见的三类失败集中在域名和限流上报错现象原因处理方式request:fail url not in domain list域名未加入 request 合法域名后台配置域名或用真机调试临时放行HTTP 429 或 HTTP 403免费接口限流/被封检查缓存策略和刷新频率换备用接口请求成功但列表为空接口返回字段与解析代码不一致抓包看实际响应体逐字段核对做微信小程序抓包时开发者工具自带的 Network 面板已经能看请求头、请求体和返回体大多数定位场景够用。只有接口地址加密、参数动态生成时才需要上独立的抓包环境。定位顺序建议是先看 Network 面板里请求是否发出再看法响应体是否和解析代码匹配最后看控制台的 errMsg。request:fail url not in domain list属于域名配置问题真机调试模式可以临时跳过校验但提交体验版前一定要回后台配置好。5.3 用 Canvas 2D 画 7 日汇率走势免图表库的轻量方案原生小程序引 echarts 要额外处理 ec-canvas 组件和分包体积7 日折线这类简单图形用 Canvas 2D 手绘就够了。关键是拿到 canvas 节点后按设备像素比缩放否则高分屏上线条是糊的// pages/trend/trend.js —— 用 Canvas 2D 画折线 drawTrend(canvasId, points) { const query wx.createSelectorQuery(); query.select(# canvasId).fields({ node: true, size: true }).exec((res) { if (!res[0]) return; const canvas res[0].node; const ctx canvas.getContext(2d); const dpr wx.getSystemInfoSync().pixelRatio; canvas.width res[0].width * dpr; canvas.height res[0].height * dpr; ctx.scale(dpr, dpr); const W res[0].width; const H res[0].height; const max Math.max(...points); const min Math.min(...points); const range max - min || 1; ctx.strokeStyle #4A90D9; ctx.lineWidth 2; ctx.beginPath(); points.forEach((v, i) { const x i * (W / (points.length - 1)); const y H - ((v - min) / range) * (H - 20) - 10; i 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.stroke(); }); }ctx.scale(dpr, dpr)这步很多人会漏canvas 的物理尺寸按width * dpr设置后坐标系统也要同步缩放否则绘制的图形会按 CSS 像素渲染在 2x、3x 屏上发虚。7 日数据可以从 Frankfurter 的timeseries端点拿参数带上起始日期和symbols一天一个点。绘制多条线或叠加其他图形时每条线绘制前都要重新beginPath()否则上一条线的路径会残留出现首尾相连的脏线这是 canvas 折线图最常见的排查点。本文还有配套的精品资源点击获取