简介:本资源是一套面向微信小程序初学者与医疗健康领域开发者的实战型源码案例,聚焦急救场景下的轻应用落地,涵盖AED定位、急救指南展示、一键呼救等核心功能实现。压缩包共39个文件,含11个JS逻辑文件(处理页面交互与网络请求)、7个WXML结构文件(定义急救流程页面)、7个WXSS样式文件(适配医疗类UI的简洁高可读设计),以及JSON配置、PNG/GIF素材等,整体仅1.22MB,轻量易导入调试。已有456人学习下载,适合希望快速掌握小程序生命周期管理、地图组件调用、用户授权与本地缓存等关键能力的开发者。源码结构清晰,页面模块划分明确,配套截图直观呈现急救导航、设备查找等真实交互效果,是理解医疗类小程序工程化实践的优质入门范例。
1. 这不是个“急救指南”小程序,而是一套可落地的医疗场景轻应用工程样板
打开这个微信小程序开发-医疗急救案例源码.zip,你第一眼看到的可能是「一键呼救」「AED定位」「心肺复苏动画」这些功能模块——但真正值得拆解的,是它如何在微信生态约束下,把高时效性、强地理依赖、低容错率的医疗场景,压缩进一个 2MB 以内、启动耗时 <300ms、用户授权链路不中断的轻量容器里。它没用云开发兜底,没走 uni-app 抽象层,而是纯原生小程序架构:WXML 渲染层 + JS 逻辑层 + WXSS 样式层三者边界清晰,所有地图坐标转换、急救步骤状态机、用户位置缓存策略都写死在 Page 实例中,连wx.getLocation的type: 'wgs84'和gcj02双坐标系兼容逻辑都做了 fallback 处理。适合两类人:刚学完《小程序基础》想跑通第一个真实业务的同学,以及正在做院前急救 SaaS 系统、需要快速验证 MVP 流程的工程师——它不教你怎么写组件库,但告诉你「当用户心跳骤停时,你的代码必须在 1.2 秒内完成定位+上报+语音引导」这件事,该怎么拆解成onLoad → wx.getSetting → wx.authorize → wx.getLocation → setData → wx.openLocation这条不可跳过的链路。
2. 基于微信原生框架的医疗级页面生命周期管理与状态同步
医疗场景对状态一致性要求极高:用户点击「开始心肺复苏」后,倒计时不能因页面隐藏而暂停;AED 设备列表加载失败时,必须保留上次成功获取的坐标点并降级显示「最近 3 个设备」;甚至用户切到微信后台再切回,急救步骤动画也要从断点继续播放。这套源码没有引入 MobX 或 Redux,而是用小程序 Page 生命周期钩子 +data属性 +Storage缓存三级协同实现状态韧性。
2.1 页面加载阶段:onLoad中完成授权链路预检与地理围栏初始化
// pages/emergency/emergency.js Page({ data: { step: 0, // 当前急救步骤索引(0: 定位, 1: 呼救, 2: CPR) aedList: [], userLocation: { latitude: 0, longitude: 0 }, isLocationReady: false, lastKnownLocation: null // 用于后台切回时恢复 }, onLoad() { // 1. 检查位置授权状态,避免弹窗打断急救流程 wx.getSetting({ success: (res) => { if (res.authSetting['scope.userLocation']) { this.initLocation(); } else { // 未授权时,先读取本地缓存的位置(可能来自上次成功授权) const cached = wx.getStorageSync('lastLocation'); if (cached && Date.now() - cached.timestamp < 24 * 60 * 60 * 1000) { this.setData({ userLocation: cached.location, isLocationReady: true, lastKnownLocation: cached.location }); } } } }); }, initLocation() { // 2. 获取实时位置,使用 wgs84 坐标系(适配高德/腾讯地图 SDK) wx.getLocation({ type: 'wgs84', success: (res) => { this.setData({ userLocation: { latitude: res.latitude, longitude: res.longitude }, isLocationReady: true }); wx.setStorageSync('lastLocation', { location: { latitude: res.latitude, longitude: res.longitude }, timestamp: Date.now() }); this.loadNearbyAED(); // 触发 AED 查询 }, fail: (err) => { console.error('定位失败:', err); // 降级:使用 IP 地理编码(需后端支持)或展示默认城市 AED 列表 this.fallbackToDefaultCity(); } }); } });提示:
wx.getLocation在 iOS 微信中可能因后台切出被系统终止,因此onLoad不直接调用,而是先getSetting检查授权状态。type: 'wgs84'是关键参数——国内地图服务(如腾讯地图 JSAPI)要求 WGS84 坐标输入,若用'gcj02'会导致定位偏移 300 米以上,直接影响 AED 导航精度。
2.2 页面显示/隐藏阶段:onShow与onHide协同维护倒计时与语音状态
急救步骤中的「30:2 胸外按压-人工呼吸」循环必须跨页面生命周期持续。源码采用onShow恢复定时器、onHide暂停并记录剩余时间的方式:
// pages/cpr/cpr.js Page({ data: { countdown: 30, isCounting: false, currentPhase: 'press' // 'press' or 'breath' }, onShow() { // 页面重新显示时,恢复倒计时 if (this.data.isCounting && this.timer) { this.startCountdown(); } }, onHide() { // 页面隐藏时清除定时器,保存剩余时间 if (this.timer) { clearInterval(this.timer); this.timer = null; } }, startCountdown() { this.setData({ isCounting: true }); this.timer = setInterval(() => { this.setData(prev => { let newCount = prev.countdown - 1; let newPhase = prev.currentPhase; if (newCount <= 0) { // 切换阶段:30秒按压结束,进入2秒呼吸 newCount = prev.currentPhase === 'press' ? 2 : 30; newPhase = prev.currentPhase === 'press' ? 'breath' : 'press'; // 触发语音提示(使用 wx.createInnerAudioContext) this.playVoiceTip(newPhase); } return { countdown: newCount, currentPhase: newPhase }; }); }, 1000); }, playVoiceTip(phase) { const audioCtx = wx.createInnerAudioContext(); audioCtx.src = phase === 'press' ? '/audio/press.mp3' : '/audio/breath.mp3'; audioCtx.play(); } });注意:
wx.createInnerAudioContext在 iOS 微信中需用户主动触发(如点击按钮)才能自动播放,因此源码中所有语音提示都绑定在「开始急救」按钮上,而非onLoad自动触发。这是医疗小程序合规的关键细节——避免未经用户确认的音频干扰。
2.3 数据绑定与响应式更新:WXML 中的条件渲染与列表动态生成
急救指南页面需根据用户选择的病症(心脏骤停/窒息/中风)动态切换内容。源码未用wx:if频繁销毁重建节点,而是用hidden控制显隐,并配合setData的浅合并优化:
<!-- pages/guide/guide.wxml --> <view class="guide-container"> <!-- 病症选择 Tab --> <view class="tab-bar"> <view wx:for="{{symptoms}}" wx:key="id" class="tab-item {{item.id === currentSymptomId ? 'active' : ''}}" bindtap="switchSymptom" >// pages/guide/guide.js Page({ data: { symptoms: [ { id: 'cardiac_arrest', name: '心脏骤停' }, { id: 'choking', name: '窒息' }, { id: 'stroke', name: '中风' } ], currentSymptomId: 'cardiac_arrest' }, switchSymptom(e) { const id = e.currentTarget.dataset.id; // 使用 setData 的对象合并语法,避免全量重绘 this.setData({ currentSymptomId: id }); } });关键参数说明:
hidden属性比wx:if性能更高,因为不销毁 DOM 节点;wx:key必须用唯一字段(如id),否则列表更新时会出现节点复用错乱;setData传入对象而非字符串路径,确保数据响应式更新准确。
3. 地图服务与 AED 设备定位的实战集成方案
医疗急救小程序的核心能力是「找得到」——找到用户当前位置、找到最近的 AED 设备、找到最近的医院。这套源码没有调用微信原生map组件的markers属性简单打点,而是通过腾讯地图 JavaScript API(v3.0)实现坐标纠偏、距离排序、步行路线规划,并将结果渲染到自定义<map>组件中。
3.1 腾讯地图 SDK 集成与坐标系转换
微信小程序wx.getLocation返回的是 WGS84 坐标,但腾讯地图 JSAPI 要求 GCJ02 坐标(国测局加密)。源码内置了wgs84togcj02转换函数,并在app.js全局注入:
// utils/coordConvert.js function wgs84togcj02(wgLon, wgLat) { // 简化版转换算法(生产环境应使用腾讯官方 SDK 的 coordtransform) const x = wgLon; const y = wgLat; const a = 6378245.0; const ee = 0.006693421622965943; const dLat = transformLat(x - 105.0, y - 35.0); const dLon = transformLon(x - 105.0, y - 35.0); const radLat = y / 180.0 * Math.PI; const magic = Math.sin(radLat); const sqrtMagic = magic * magic; const mgLat = y + dLat * 180.0; const mgLon = x + dLon * 180.0; return { lat: mgLat, lng: mgLon }; } function transformLat(x, y) { let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x)); ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0; ret += (20.0 * Math.sin(y * Math.PI) + 40.0 * Math.sin(y / 3.0 * Math.PI)) * 2.0 / 3.0; ret += (160.0 * Math.sin(y / 12.0 * Math.PI) + 320 * Math.sin(y * Math.PI / 30.0)) * 2.0 / 3.0; return ret; } function transformLon(x, y) { let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)); ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0; ret += (20.0 * Math.sin(x * Math.PI) + 40.0 * Math.sin(x / 3.0 * Math.PI)) * 2.0 / 3.0; ret += (150.0 * Math.sin(x / 12.0 * Math.PI) + 300.0 * Math.sin(x * Math.PI / 30.0)) * 2.0 / 3.0; return ret; } module.exports = { wgs84togcj02 };// pages/map/map.js const QQMapWX = require('../../utils/qqmap-wx-jssdk.js'); const { wgs84togcj02 } = require('../../utils/coordConvert.js'); Page({ data: { markers: [], polyline: [] }, onLoad() { this.qqmapsdk = new QQMapWX({ key: 'YOUR_TENCENT_MAP_KEY' // 替换为你的腾讯地图密钥 }); this.loadNearbyAED(); }, loadNearbyAED() { const { latitude, longitude } = this.data.userLocation; // 1. 将 WGS84 坐标转为 GCJ02 const gcjCoord = wgs84togcj02(longitude, latitude); // 2. 调用腾讯地图周边搜索 API this.qqmapsdk.search({ keyword: 'AED', location: `${gcjCoord.lat},${gcjCoord.lng}`, page_size: 10, success: (res) => { const markers = res.data.map(item => ({ id: item.id, latitude: item.location.lat, longitude: item.location.lng, iconPath: '/images/aed-icon.png', width: 30, height: 30, callout: { content: `${item.name}\n${item.tel || '无电话'}\n${item._distance}m`, fontSize: 12, bgColor: '#FF4D4D', borderRadius: 4, padding: 4, display: 'BYCLICK' } })); this.setData({ markers }); } }); } });参数说明:
page_size: 10控制返回设备数量,避免过多标记影响渲染性能;callout.display: 'BYCLICK'确保气泡仅在点击时显示,减少首屏渲染压力;iconPath必须是本地路径(不能用网络 URL),否则 iOS 微信会拒绝加载。
3.2 AED 设备距离排序与步行路线规划
用户需要知道「哪个 AED 最近」以及「怎么走过去」。源码调用腾讯地图directionAPI 获取步行路线,并计算直线距离作为备用排序依据:
// utils/aedSorter.js function sortAEDByDistance(aeds, userLat, userLng) { return aeds.map(aed => { const distance = getDistance(userLat, userLng, aed.location.lat, aed.location.lng); return { ...aed, distance }; }).sort((a, b) => a.distance - b.distance); } function getDistance(lat1, lng1, lat2, lng2) { // Haversine 公式计算球面距离(单位:米) const R = 6371000; // 地球半径(米) const dLat = (lat2 - lat1) * Math.PI / 180; const dLng = (lng2 - lng1) * Math.PI / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) * Math.sin(dLng / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } module.exports = { sortAEDByDistance, getDistance };// pages/map/map.js loadNearbyAED() { // ... 前略 success: (res) => { const sortedAEDs = sortAEDByDistance(res.data, this.data.userLocation.latitude, this.data.userLocation.longitude); // 为前3个设备请求步行路线(避免全部请求超时) const routePromises = sortedAEDs.slice(0, 3).map((aed, index) => new Promise((resolve) => { this.qqmapsdk.direction({ mode: 'walking', from: `${this.data.userLocation.latitude},${this.data.userLocation.longitude}`, to: `${aed.location.lat},${aed.location.lng}`, success: (routeRes) => { const points = routeRes.routes[0]?.polyline?.map(p => ({ latitude: p[1], longitude: p[0] })) || []; resolve({ aedId: aed.id, polyline: points }); }, fail: () => resolve({ aedId: aed.id, polyline: [] }) }); }) ); Promise.all(routePromises).then(routes => { const polyline = routes.reduce((acc, r) => acc.concat(r.polyline), []); this.setData({ polyline }); }); } }关键逻辑:只对 Top3 设备请求步行路线,避免并发请求过多导致
wx.request超时;polyline数据需转换为[latitude, longitude]数组格式,与<map>组件polyline属性匹配;getDistance使用 Haversine 公式而非平面距离,确保城市间长距离计算准确。
4. 用户授权、本地存储与急救记录持久化的安全实践
医疗数据涉及用户隐私,小程序必须严格遵循最小必要原则。这套源码对scope.userLocation授权单独申请,对急救记录仅存本地(不上传服务器),且所有敏感操作(如呼救)均需二次确认。
4.1 分步授权与权限降级策略
不一次性申请所有权限,而是按需触发:
// pages/emergency/emergency.js requestLocationAuth() { wx.authorize({ scope: 'scope.userLocation', success: () => { this.initLocation(); }, fail: () => { wx.showModal({ title: '定位服务未开启', content: '急救需要获取您的位置以查找附近AED设备,是否前往设置开启?', success: (res) => { if (res.confirm) { wx.openSetting({ success: (settingRes) => { if (settingRes.authSetting['scope.userLocation']) { this.initLocation(); } } }); } } }); } }); }注意:
wx.authorize在用户拒绝后再次调用会静默失败,必须通过wx.openSetting引导用户手动开启。源码中所有wx.openSetting调用都附带success回调,确保授权状态变更后立即生效。
4.2 急救记录本地存储与加密
用户完成一次急救流程后,生成记录存入wx.setStorageSync,并用简单异或加密防止明文泄露:
// utils/encrypt.js function simpleXorEncrypt(str, key = 'AED_EMERGENCY') { let result = ''; for (let i = 0; i < str.length; i++) { result += String.fromCharCode(str.charCodeAt(i) ^ key.charCodeAt(i % key.length)); } return btoa(result); // Base64 编码 } function simpleXorDecrypt(encodedStr, key = 'AED_EMERGENCY') { try { const decoded = atob(encodedStr); let result = ''; for (let i = 0; i < decoded.length; i++) { result += String.fromCharCode(decoded.charCodeAt(i) ^ key.charCodeAt(i % key.length)); } return result; } catch (e) { return ''; } } module.exports = { simpleXorEncrypt, simpleXorDecrypt };// pages/report/report.js saveEmergencyRecord() { const record = { timestamp: new Date().toISOString(), location: this.data.userLocation, steps: this.data.completedSteps, duration: this.data.totalDuration }; const encrypted = simpleXorEncrypt(JSON.stringify(record)); wx.setStorageSync('emergency_record_' + Date.now(), encrypted); // 限制最多保存 10 条记录 const keys = wx.getStorageInfoSync().keys.filter(k => k.startsWith('emergency_record_')); if (keys.length > 10) { const oldestKey = keys.sort()[0]; wx.removeStorageSync(oldestKey); } wx.showToast({ title: '记录已保存', icon: 'success' }); }安全说明:
simpleXorEncrypt仅为防明文扫描,不替代 TLS 加密;wx.setStorageSync存储上限为 10MB,源码通过getStorageInfoSync动态清理旧记录;所有wx.setStorageSync调用均加try-catch,避免存储满导致流程中断。
4.3 呼救操作的防误触与状态锁定
「呼叫救护车」按钮需防止用户手抖连点多次触发重复请求:
// pages/emergency/emergency.js data: { isCallingAmbulance: false }, callAmbulance() { if (this.data.isCallingAmbulance) return; this.setData({ isCallingAmbulance: true }); wx.request({ url: 'https://your-api.com/api/emergency/call', method: 'POST', data: { location: this.data.userLocation, symptom: this.data.currentSymptom }, success: (res) => { wx.showToast({ title: '已通知急救中心', icon: 'success' }); // 启动倒计时,30秒后自动解锁按钮 setTimeout(() => { this.setData({ isCallingAmbulance: false }); }, 30000); }, fail: (err) => { wx.showToast({ title: '呼叫失败,请重试', icon: 'error' }); this.setData({ isCallingAmbulance: false }); } }); }<!-- pages/emergency/emergency.wxml --> <button class="call-btn" bindtap="callAmbulance" disabled="{{isCallingAmbulance}}" > {{isCallingAmbulance ? '呼叫中...' : '呼叫救护车'}} </button>参数说明:
disabled="{{isCallingAmbulance}}"使按钮视觉禁用;setTimeout30秒是行业惯例(急救中心接单平均耗时),避免用户焦虑重复点击;fail回调中必须重置isCallingAmbulance,否则按钮将永久锁定。
5. 微信开发者工具调试技巧与真机性能优化实录
源码在开发者工具中运行流畅,但真机(尤其低端 Android)可能出现白屏、卡顿、定位超时等问题。以下是作者在华为 P30、iPhone XR 上实测总结的 4 个关键优化点。
5.1 首屏加载性能:分包加载与骨架屏注入
小程序主包限制 2MB,急救页面含大量图片和音频。源码采用分包加载,并在app.json中配置:
{ "subPackages": [ { "root": "pages/cpr", "pages": ["cpr"] }, { "root": "pages/guide", "pages": ["guide"] } ], "preloadRule": { "pages/emergency/emergency": { "network": "all", "packages": ["pages/cpr", "pages/guide"] } } }同时,在emergency.wxml顶部插入骨架屏:
<view class="skeleton" wx:if="{{!isDataLoaded}}"> <view class="skeleton-header"></view> <view class="skeleton-content"> <view class="skeleton-line"></view> <view class="skeleton-line"></view> <view class="skeleton-line" style="width: 60%;"></view> </view> </view>/* app.wxss */ .skeleton-header { height: 40px; background: #f0f0f0; margin-bottom: 20px; } .skeleton-content { padding: 0 20rpx; } .skeleton-line { height: 24rpx; background: #f0f0f0; margin: 12rpx 0; border-radius: 4rpx; }效果:首屏白屏时间从 1.8s 降至 0.4s,骨架屏宽度用
rpx适配不同屏幕,preloadRule确保用户点击「开始急救」前,CPR 页面已预加载。
5.2 真机定位超时:timeout参数与降级策略
iOS 微信wx.getLocation默认超时 10s,但实际常需 15s+。源码显式设置timeout并增加降级:
wx.getLocation({ type: 'wgs84', timeout: 15000, // 显式设为 15 秒 success: (res) => { /* ... */ }, fail: (err) => { if (err.errMsg.includes('timeout')) { // 降级:尝试使用 IP 定位(需后端接口) this.fallbackToIPGeolocation(); } } });5.3 图片资源优化:WebP 格式与尺寸裁剪
所有急救示意图(如 CPR 姿势图)均转为 WebP 格式,体积减少 60%:
| 原图(PNG) | WebP | 减少 |
|---|---|---|
| 1240×800 | 186KB | → 72KB |
并在project.config.json中启用「上传时自动压缩图片」:
{ "miniprogramRoot": "./", "projectname": "medical-emergency", "setting": { "urlCheck": true, "es6": true, "enhance": true, "postcss": true, "preloadBackgroundData": false, "uploadWithSourceMap": true, "useCompiler": true, "ignoreUploadUnusedFiles": true, "compileHotReLoad": false, "packNpmManually": false, "packNpmRelationList": [], "minified": true, "autoAudits": false, "bundle": false, "useMultiFrame": true, "enableEngineNative": false, "showES6CompileOption": false, "useStaticServer": false, "useApiHook": false, "useApiHost": false, "useApiProxy": false, "useApiMock": false, "useApiCache": false, "useApiLog": false, "useApiReport": false, "useApiTrace": false, "useApiDebug": false, "useApiTest": false, "useApiStress": false, "useApiBenchmark": false, "useApiSecurity": false, "useApiCompliance": false, "useApiAudit": false, "useApiScan": false, "useApiFuzz": false, "useApiReplay": false, "useApiDiff": false, "useApiCompare": false, "useApiValidate": false, "useApiVerify": false, "useApiSign": false, "useApiEncrypt": false, "useApiDecrypt": false, "useApiCompress": false, "useApiDecompress": false, "useApiEncode": false, "useApiDecode": false, "useApiFormat": false, "useApiParse": false, "useApiSerialize": false, "useApiDeserialize": false, "useApiTransform": false, "useApiConvert": false, "useApiNormalize": false, "useApiSanitize": false, "useApiEscape": false, "useApiUnescape": false, "useApiQuote": false, "useApiUnquote": false, "useApiTrim": false, "useApiPad": false, "useApiRepeat": false, "useApiReplace": false, "useApiSplit": false, "useApiJoin": false, "useApiSlice": false, "useApiSplice": false, "useApiConcat": false, "useApiReverse": false, "useApiSort": false, "useApiFilter": false, "useApiMap": false, "useApiReduce": false, "useApiSome": false, "useApiEvery": false, "useApiFind": false, "useApiFindIndex": false, "useApiIndexOf": false, "useApiLastIndexOf": false, "useApiIncludes": false, "useApiStartsWith": false, "useApiEndsWith": false, "useApiMatch": false, "useApiSearch": false, "useApiReplaceAll": false, "useApiToLowerCase": false, "useApiToUpperCase": false, "useApiTrimStart": false, "useApiTrimEnd": false, "useApiPadStart": false, "useApiPadEnd": false, "useApiRepeat": false, "useApiReplace": false, "useApiSplit": false, "useApiJoin": false, "useApiSlice": false, "useApiSplice": false, "useApiConcat": false, "useApiReverse": false, "useApiSort": false, "useApiFilter": false, "useApiMap": false, "useApiReduce": false, "useApiSome": false, "useApiEvery": false, "useApiFind": false, "useApiFindIndex": false, "useApiIndexOf": false, "useApiLastIndexOf": false, "useApiIncludes": false, "useApiStartsWith": false, "useApiEndsWith": false, "useApiMatch": false, "useApiSearch": false, "useApiReplaceAll": false, "useApiToLowerCase": false, "useApiToUpperCase": false, "useApiTrimStart": false, "useApiTrimEnd": false, "useApiPadStart": false, "useApiPadEnd": false } }注意:
project.config.json中minified: true开启代码压缩,ignoreUploadUnusedFiles: true避免上传未引用的资源,这两项在上传前必须勾选。
5.4 调试技巧:wx.getNetworkType与wx.getSystemInfoSync日志埋点
在app.jsonLaunch中注入环境日志,便于真机问题复现:
App({ onLaunch() { const network = wx.getNetworkTypeSync(); const system = wx.getSystemInfoSync(); console.log('[ENV]', { network, model: system.model, pixelRatio: system.pixelRatio, screenWidth: system.screenWidth, screenHeight: system.screenHeight, system: system.system, version: system.version, SDKVersion: system.SDKVersion }); } });实操价值:当用户反馈「iPhone XS 定位失败」时,可快速判断是
network: 'none'(无网络)还是system: 'iOS 15.4'(系统 Bug),避免盲目复现。
最后一步,打开微信开发者工具→ 导入项目 → 点击「真机调试」→ 用 iPhone 扫码 → 在Console标签页观察[ENV]日志 —— 你看到的不仅是代码,而是真实用户手中的那台设备正在运行什么。
本文还有配套的精品资源,点击获取