微信小程序MQTT消息本地记录与存储实践
1. 微信小程序端 MQTT 数据记录功能实现原理与工程实践
在嵌入式物联网系统中,微信小程序作为轻量级前端交互界面,承担着设备状态监控、远程指令下发与数据可视化等关键任务。当后端服务(如 EMQX、Mosquitto 或云厂商 MQTT 服务)通过消息代理向小程序推送传感器数据时,如何将这些瞬时到达的 MQTT 消息持久化为可追溯、可回溯、可分析的本地记录,是构建可靠人机交互闭环的核心环节。本节内容聚焦于小程序端数据记录功能的完整实现路径——不依赖服务端数据库,完全在客户端完成消息捕获、结构化解析、本地缓存写入与历史回显,适用于离线场景、调试验证及用户行为留痕等实际工程需求。
1.1 小程序端数据记录的本质约束与设计边界
与 Web 浏览器或桌面应用不同,微信小程序运行在受限沙箱环境中,其存储能力受平台策略严格管控: wx.setStorage 的单次写入上限为 10MB,总容量上限约 10MB(具体值依微信客户端版本而异),且无事务、无索引、无复杂查询能力。这意味着任何“记录”功能都必须遵循三个硬性原则:
- 原子性写入 :每条消息记录必须独立、完整、不可分割地写入存储,避免因中断导致数据损坏;
- 结构扁平化 :不采用嵌套过深的 JSON 结构,优先使用数组+对象组合,便于
JSON.stringify/JSON.parse高效序列化; - 容量可控性 :必须内置容量监控与自动裁剪机制,防止缓存溢出导致
setStorage失败进而阻塞整个业务流程。
因此,“数据记录”在小程序语境下并非传统意义上的数据库日志,而是对 MQTT onMessage 回调中接收到的原始 payload 进行即时快照,并附加时间戳、主题、QoS 等元信息后,以追加方式写入一个全局 messages 数组,再整体序列化存入本地存储。该设计规避了频繁 I/O 开销,同时保证了数据完整性与回溯能力。
1.2 消息接收与结构化解析:从原始 payload 到可记录对象
MQTT 消息在小程序中通过 wx.connectSocket 建立 WebSocket 连接后,由 wx.onSocketMessage 统一接收。原始 payload 通常为字符串(如 {"temp":30,"humid":60,"co2":450} )或 ArrayBuffer(二进制传感器数据)。对于本案例中明确约定为 JSON 字符串的传感器数据流,解析逻辑需严格处理以下三类异常情形:
- 空消息(null/undefined/empty string) :WebSocket 可能因网络抖动推送空帧,直接
JSON.parse将抛出SyntaxError; - 非法 JSON 格式 :设备固件 Bug 或传输错误导致 JSON 结构损坏;
- 字段缺失或类型错误 :如期望
temp字段为数字,但实际为字符串"30"或null。
标准解析函数应封装为带防御性检查的工具方法:
function parseMessagePayload(payload) {
// 步骤1:判空保护
if (!payload || typeof payload !== 'string' || payload.trim() === '') {
console.warn('MQTT message payload is empty or invalid type');
return null;
}
// 步骤2:JSON 解析容错
let parsed;
try {
parsed = JSON.parse(payload);
} catch (e) {
console.error('Failed to parse MQTT payload as JSON:', e, 'Raw payload:', payload);
return null;
}
// 步骤3:字段存在性与类型校验(依据业务协议)
const requiredFields = ['device_id', 'timestamp', 'data'];
for (const field of requiredFields) {
if (!(field in parsed)) {
console.warn(`MQTT payload missing required field: ${field}`);
return null;
}
}
// 步骤4:数值字段安全转换(避免 "30" → NaN)
if (typeof parsed.data.temp === 'string') {
parsed.data.temp = parseFloat(parsed.data.temp);
}
if (typeof parsed.data.humid === 'string') {
parsed.data.humid = parseFloat(parsed.data.humid);
}
return parsed;
}
此函数返回 null 表示消息不可用,上层调用者(即 onSocketMessage 回调)应直接丢弃,不进入记录流程。只有通过全部校验的消息对象,才具备被记录的价值。
1.3 本地存储结构设计: messages 数组的工程化组织
小程序本地存储本质是一个键值对(key-value)字典, wx.setStorage({key: 'messages', data: [...]}) 是唯一写入入口。 messages 数组的设计需兼顾读写效率、内存占用与业务可读性:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id |
string | ✓ | 全局唯一 ID,采用 Date.now() + Math.random().toString(36).substr(2, 9) 生成,确保高并发下不重复 |
topic |
string | ✓ | 消息来源主题,如 sensor/bedroom/temp ,用于后续按主题过滤 |
payload |
object | ✓ | 解析后的 JSON 对象,结构与 parseMessagePayload 输出一致 |
timestamp |
number | ✓ | 消息接收时刻的时间戳(毫秒), Date.now() ,非 payload 内部时间,保证记录时序绝对准确 |
qos |
number | ✗ | MQTT QoS 等级(0/1/2),若连接配置支持获取则记录,用于质量分析 |
该结构摒弃了冗余字段(如重复存储 device_id ),将核心业务数据 payload 作为子对象嵌套,既保持语义清晰,又避免扁平化导致的字段名冲突风险。例如,不同设备可能都有 value 字段,嵌套在 payload 下即可自然隔离。
1.4 记录写入流程:原子化操作与容量控制
写入并非简单 push 后 setStorage ,而是一套包含前置检查、内存更新、持久化落盘与后置清理的完整流程:
async function recordMessage(topic, payload) {
// 步骤1:获取当前所有消息(若不存在则初始化空数组)
let messages = [];
try {
const res = await wx.getStorage({ key: 'messages' });
messages = res.data || [];
} catch (e) {
// key 不存在或读取失败,初始化为空数组
console.log('No existing messages storage, initializing empty array');
}
// 步骤2:构造新记录对象
const newRecord = {
id: `${Date.now()}${Math.random().toString(36).substr(2, 9)}`,
topic,
payload,
timestamp: Date.now()
};
// 步骤3:追加到内存数组(注意:此处为浅拷贝,不影响原引用)
messages.push(newRecord);
// 步骤4:容量检查与自动裁剪(保留最近 500 条)
const MAX_RECORDS = 500;
if (messages.length > MAX_RECORDS) {
// 裁剪最旧的 N 条,保持长度为 MAX_RECORDS
messages = messages.slice(-MAX_RECORDS);
console.log(`Messages array exceeded ${MAX_RECORDS}, trimmed oldest entries`);
}
// 步骤5:序列化并写入存储(关键:必须 await 确保写入完成)
try {
await wx.setStorage({ key: 'messages', data: messages });
console.log(`Successfully recorded message to topic: ${topic}, total count: ${messages.length}`);
} catch (e) {
console.error('Failed to write messages to storage:', e);
// 写入失败时,可选择降级策略:仅保留在内存(易失)或上报错误
throw e;
}
}
此流程的关键点在于:
- await wx.getStorage / await wx.setStorage :强制异步等待,避免因 Storage API 异步特性导致读写竞争;
- slice(-MAX_RECORDS) :高效裁剪,时间复杂度 O(1),优于 splice(0, n) ;
- 错误隔离 : getStorage 失败不中断流程, setStorage 失败抛出异常供上层处理,保障业务逻辑健壮性。
1.5 历史数据回显:从存储加载到页面渲染的全链路
数据记录的最终价值体现在 UI 层的可观察性。小程序页面需在 onLoad 生命周期中主动加载历史记录,并绑定至 WXML 列表组件。该过程需解决两个核心问题: 加载时机 与 渲染性能 。
1.5.1 加载时机:避免白屏与竞态
直接在 onLoad 中 await wx.getStorage 会导致页面初始化阻塞,用户看到空白页。正确做法是:
- onLoad 中立即设置 data.messages = [] 与 data.loading = true ,触发初始渲染(显示加载提示);
- 使用 wx.showLoading 提供视觉反馈;
- 在 try/catch 中执行存储读取,成功后 setData({ messages: loadedData, loading: false }) ,失败则 setData({ loading: false, error: '加载历史失败' }) 。
1.5.2 渲染性能:虚拟列表与分页加载
当 messages 数组达到数百条时,一次性 setData 所有数据会引发严重卡顿。WXML <scroll-view> 的 scroll-y 属性虽支持滚动,但未启用虚拟列表(Virtual List)时,所有 <view wx:for> 节点仍会被创建并挂载到 DOM,内存与渲染开销巨大。
工程实践中,推荐两种方案:
- 方案A(轻量级):前端分页
在 data 中维护 currentPage 和 pageSize , setData 仅传入当前页数据(如 messages.slice((page-1)*size, page*size) ),WXML 中添加“加载更多”按钮,点击后 currentPage++ 并重新计算切片。
- 方案B(生产级): wx:for + wx:key 优化
确保 wx:for 列表项的 wx:key 设置为 record.id (而非默认 index ),使微信框架能精准复用节点,极大提升滚动流畅度。配合 bindscrolltolower 实现滚动到底部自动加载下一页。
WXML 示例(方案B):
<scroll-view scroll-y="true" bindscrolltolower="loadMore" style="height: 50vh;">
<view wx:for="{{messages}}" wx:key="item.id" class="message-item">
<text class="topic">{{item.topic}}</text>
<text class="time">{{formatTime(item.timestamp)}}</text>
<text class="payload">{{JSON.stringify(item.payload)}}</text>
</view>
</scroll-view>
JS 中 formatTime 为格式化工具函数,将毫秒时间戳转为 YYYY-MM-DD HH:mm:ss ,避免在 WXML 中进行复杂计算。
1.6 记录管理功能:清空、导出与筛选的工程实现
仅有记录与回显是不完整的。真实项目中,用户必然需要:
- 清空记录 :释放存储空间,重置调试环境;
- 导出记录 :将本地数据导出为 .json 文件,用于离线分析或提交给技术支持;
- 按主题筛选 :快速定位某类设备(如所有 sensor/+/temp )的历史数据。
1.6.1 安全清空:双重确认与原子化删除
清空操作不可逆,必须加入用户确认步骤。 wx.showModal 是标准交互方式:
async clearAllRecords() {
wx.showModal({
title: '确认清空',
content: '确定要清空所有历史记录吗?此操作无法撤销。',
success: async (res) => {
if (res.confirm) {
try {
await wx.removeStorage({ key: 'messages' });
this.setData({ messages: [], recordCount: 0 });
wx.showToast({ title: '已清空', icon: 'success' });
} catch (e) {
wx.showToast({ title: '清空失败', icon: 'error' });
console.error('Failed to clear messages storage:', e);
}
}
}
});
}
wx.removeStorage 直接删除键,比 setStorage({key: 'messages', data: []}) 更彻底,且避免空数组残留。
1.6.2 一键导出:利用 wx.saveFile 生成可分享文件
小程序不支持直接下载文件,但可通过 wx.saveFile 将 JSON 字符串保存为临时文件,再用 wx.openDocument 打开或 wx.shareFileMessage 分享:
async exportRecords() {
try {
const res = await wx.getStorage({ key: 'messages' });
const jsonStr = JSON.stringify(res.data, null, 2); // 格式化输出,提升可读性
const tempFilePath = wx.env.USER_DATA_PATH + '/mqtt_records_' + Date.now() + '.json';
// 保存为临时文件
await wx.saveFile({ tempFilePath, filePath: tempFilePath });
// 打开文件(iOS/Android 行为略有差异)
wx.openDocument({
filePath: tempFilePath,
success: () => console.log('Export file opened successfully'),
fail: (err) => console.error('Failed to open export file:', err)
});
} catch (e) {
wx.showToast({ title: '导出失败', icon: 'error' });
console.error('Export failed:', e);
}
}
USER_DATA_PATH 是小程序专属目录,文件仅对本小程序可见,安全性有保障。
1.6.3 主题筛选:内存过滤与动态绑定
筛选无需额外存储,纯前端计算即可。在 WXML 中绑定 filteredMessages ,其值由 messages 数组经 filter 生成:
// Page data
data: {
messages: [],
filteredMessages: [],
filterTopic: '' // 当前筛选的主题关键词
},
// 筛选函数
applyFilter() {
const { messages, filterTopic } = this.data;
if (!filterTopic.trim()) {
this.setData({ filteredMessages: messages });
return;
}
const filtered = messages.filter(record =>
record.topic.includes(filterTopic.trim())
);
this.setData({ filteredMessages: filtered });
},
// WXML 中绑定
<view wx:for="{{filteredMessages}}" wx:key="item.id">...</view>
用户在输入框修改 filterTopic 后调用 applyFilter , setData 触发视图更新。此方案零额外开销,响应迅速。
2. MQTT 连接与订阅状态管理:支撑数据记录的底层基石
数据记录功能的有效性,完全依赖于稳定、可控的 MQTT 连接与主题订阅。小程序端的连接管理远非简单调用 connect API,它涉及 WebSocket 生命周期、重连策略、状态同步、UI 反馈等多个维度。一个健壮的状态管理系统,是避免“记录了却收不到消息”或“反复连接断开导致记录丢失”的根本保障。
2.1 连接状态机:从 disconnected 到 connected 的精确建模
小程序中,MQTT 连接本质是 WebSocket 连接。其状态变迁并非线性,而是典型的有限状态机(FSM),必须明确定义各状态及其转移条件:
| 状态 | 触发条件 | 退出动作 | UI 反馈 |
|---|---|---|---|
disconnected |
初始状态; close 被调用;连接失败 |
清理 socket 实例、清除定时器 | “连接”按钮启用,文字为“连接”,背景色为蓝色 |
connecting |
用户点击“连接”按钮 | 创建 wx.connectSocket 实例;启动连接超时定时器(如 10s) |
“连接”按钮禁用,文字为“连接中…”,背景色为灰色 |
connected |
wx.onSocketOpen 触发 |
启动心跳包(Ping)发送;注册 onSocketMessage ;执行自动订阅 |
“连接”按钮变为“断开”,文字为“断开”,背景色为灰色;显示“已连接”状态标签 |
reconnecting |
wx.onSocketClose 或 wx.onSocketError 触发(非用户主动关闭) |
启动指数退避重连定时器(1s, 2s, 4s…) | 页面顶部显示 Toast “正在重连…”,“断开”按钮禁用 |
disconnecting |
用户点击“断开”按钮 | 调用 wx.closeSocket ;清除所有定时器 |
“断开”按钮禁用,文字为“断开中…”,背景色为灰色 |
此状态机强制要求所有连接相关操作( connect , subscribe , publish , close )必须检查当前状态,禁止非法转移(如 connected 状态下调用 connect )。状态变量 connectionStatus 应作为 Page.data 的一部分,通过 setData 驱动 UI 更新。
2.2 连接参数持久化:避免重复输入的用户体验优化
连接参数(服务器地址、端口、用户名、密码)不应每次启动小程序都手动输入。利用 wx.setStorage / wx.getStorage 实现参数记忆,是基础但关键的体验优化。
2.2.1 参数存储结构
为避免键名污染全局存储,采用统一前缀 mqtt_config_ :
const CONFIG_KEYS = {
server: 'mqtt_config_server',
port: 'mqtt_config_port',
username: 'mqtt_config_username',
password: 'mqtt_config_password'
};
// 保存配置
async saveConnectionConfig(config) {
await Promise.all(
Object.entries(config).map(([key, value]) =>
wx.setStorage({ key: CONFIG_KEYS[key], data: value })
)
);
}
// 加载配置
async loadConnectionConfig() {
const keys = Object.values(CONFIG_KEYS);
const res = await wx.multiGetStorage({ keys });
const config = {};
res.data.forEach(({ key, data }, index) => {
const realKey = Object.keys(CONFIG_KEYS).find(k => CONFIG_KEYS[k] === key);
if (realKey) config[realKey] = data || '';
});
return config;
}
multiGetStorage 批量读取,减少 I/O 次数,提升加载速度。
2.2.2 输入框绑定与防呆设计
WXML 输入框需正确绑定 value 并监听 bindinput :
<input
placeholder="服务器地址 (wss://...)"
value="{{connectionConfig.server}}"
bindinput="onServerInput"
/>
<input
placeholder="端口"
type="number"
value="{{connectionConfig.port}}"
bindinput="onPortInput"
/>
<!-- 用户名、密码同理 -->
JS 中 onServerInput 等事件处理器,应实时更新 this.data.connectionConfig 并 setData ,确保输入即时反映。密码框需设置 password 类型,隐藏明文。
2.3 主题订阅管理:动态增删与状态同步
订阅(SUBSCRIBE)是数据流入的阀门。小程序需支持:
- 动态添加订阅 :用户输入主题,点击“添加订阅”;
- 动态取消订阅 :用户从已订阅列表中选择并移除;
- 自动重订阅 :连接恢复后,自动重新订阅所有已保存主题。
2.3.1 订阅列表的本地化存储
与消息记录类似,订阅列表 subscriptions 也需持久化,结构为数组,每个元素包含 topic 和 qos :
// 存储键
const SUBSCRIPTIONS_KEY = 'mqtt_subscriptions';
// 添加订阅
async addSubscription(topic, qos = 0) {
let subs = [];
try {
const res = await wx.getStorage({ key: SUBSCRIPTIONS_KEY });
subs = res.data || [];
} catch (e) {}
// 防重:避免重复添加同一主题
if (!subs.some(s => s.topic === topic)) {
subs.push({ topic, qos });
await wx.setStorage({ key: SUBSCRIPTIONS_KEY, data: subs });
}
}
// 移除订阅
async removeSubscription(topic) {
try {
const res = await wx.getStorage({ key: SUBSCRIPTIONS_KEY });
const subs = res.data || [];
const filtered = subs.filter(s => s.topic !== topic);
await wx.setStorage({ key: SUBSCRIPTIONS_KEY, data: filtered });
} catch (e) {}
}
2.3.2 连接成功后的自动重订阅
wx.onSocketOpen 回调中,必须读取本地 subscriptions 并逐个发送 SUBSCRIBE 包。这是保证“断线重连后数据不丢失”的核心逻辑:
wx.onSocketOpen(() => {
console.log('WebSocket connected');
// 更新状态机
this.updateConnectionStatus('connected');
// 自动重订阅
this.autoResubscribe();
// 启动心跳
this.startHeartbeat();
});
async autoResubscribe() {
try {
const res = await wx.getStorage({ key: SUBSCRIPTIONS_KEY });
const subscriptions = res.data || [];
for (const sub of subscriptions) {
// 调用 MQTT 库的 subscribe 方法,或手动构造 SUBSCRIBE 包
await this.mqttClient.subscribe(sub.topic, sub.qos);
console.log(`Auto-subscribed to ${sub.topic} with QoS ${sub.qos}`);
}
} catch (e) {
console.error('Auto-resubscribe failed:', e);
}
}
2.4 发布(Publish)功能:从 UI 操作到 MQTT 协议包的转化
发布功能是双向通信的出口。用户在 UI 上输入主题与消息内容,点击“发布”后,小程序需:
- 校验主题与消息非空;
- 根据用户选择的 QoS 等级(0/1)构造 MQTT PUBLISH 包;
- 调用 wx.sendSocketMessage 发送。
2.4.1 发布表单设计与校验
WXML 表单需包含主题输入框、消息内容输入框( textarea )、QoS 选择器( picker )及发布按钮:
<input
placeholder="发布主题"
value="{{publishTopic}}"
bindinput="onPublishTopicInput"
/>
<textarea
placeholder="消息内容 (JSON 格式)"
value="{{publishMessage}}"
bindinput="onPublishMessageInput"
auto-height
/>
<picker
range="{{qosOptions}}"
value="{{publishQosIndex}}"
bindchange="onQosChange"
>
<view class="picker">QoS {{qosOptions[publishQosIndex]}}</view>
</picker>
<button bindtap="handlePublish">发布</button>
JS 中 handlePublish 执行强校验:
handlePublish() {
const { publishTopic, publishMessage, publishQosIndex } = this.data;
const qos = this.data.qosOptions[publishQosIndex];
if (!publishTopic.trim()) {
wx.showToast({ title: '请输入主题', icon: 'none' });
return;
}
if (!publishMessage.trim()) {
wx.showToast({ title: '请输入消息内容', icon: 'none' });
return;
}
// 尝试解析为 JSON,确保格式正确(可选,取决于业务)
try {
JSON.parse(publishMessage);
} catch (e) {
wx.showToast({ title: '消息内容不是有效 JSON', icon: 'none' });
return;
}
// 执行发布
this.doPublish(publishTopic, publishMessage, qos);
},
2.4.2 MQTT PUBLISH 包构造要点
小程序端通常使用封装好的 MQTT 库(如 mqtt.js 小程序版),但理解底层包结构至关重要:
- Fixed Header :包含 Packet Type (0x30)、Remaining Length(变长编码);
- Variable Header :包含 Topic Name(UTF-8 编码)、Packet Identifier(QoS>0 时必需);
- Payload :原始消息内容( publishMessage 字符串)。
库会自动处理编码,开发者只需确保:
- 主题名符合 MQTT 规范(无空格、不以 $ 开头除非是系统主题);
- 消息内容为字符串,二进制数据需 Base64 编码;
- QoS=1 时,库会自动处理 PUBACK 流程,无需手动干预。
3. 工程实践中的典型问题与规避策略
在将上述理论应用于真实小程序项目时,开发者常遭遇一系列“看似简单、实则棘手”的问题。这些问题往往源于对小程序运行机制、MQTT 协议细节或微信平台限制的理解偏差。以下是几个高频、高影响的实战陷阱及其根治方案。
3.1 连接失败的静默黑洞: wx.connectSocket 的错误捕获盲区
现象:用户点击“连接”,UI 显示“连接中…”,但数秒后无任何反馈,既不成功也不失败,仿佛石沉大海。
根源: wx.connectSocket 的 fail 回调仅在 DNS 解析失败、URL 格式错误、网络完全不可达 等极端情况下触发。而绝大多数连接失败(如 TLS 握手失败、服务器拒绝连接、认证失败)均不会进入 fail ,而是直接触发 wx.onSocketError ,且该回调 不携带任何错误详情 ,仅为一个空对象 {} 。
规避策略:必须同时监听 wx.onSocketError 和 wx.onSocketClose (带 code 参数),并结合连接超时机制综合判断:
let connectTimeout;
function startConnection() {
// 清除可能存在的旧定时器
if (connectTimeout) clearTimeout(connectTimeout);
// 启动 10 秒超时
connectTimeout = setTimeout(() => {
console.error('MQTT connection timeout after 10s');
wx.showToast({ title: '连接超时,请检查服务器地址和网络', icon: 'none' });
this.updateConnectionStatus('disconnected');
}, 10000);
wx.connectSocket({
url: `wss://${server}:${port}/mqtt`,
success: () => {
console.log('WebSocket connection initiated');
// 成功仅表示连接请求已发出,不保证建立
},
fail: (err) => {
// 此处只处理 URL 级别错误
console.error('connectSocket failed at URL level:', err);
wx.showToast({ title: '连接地址错误', icon: 'none' });
this.updateConnectionStatus('disconnected');
}
});
// 监听底层错误(TLS、认证等)
wx.onSocketError((err) => {
console.error('WebSocket socket error (TLS/auth):', err);
// 此处 err 通常为空,但日志可辅助排查
if (connectTimeout) clearTimeout(connectTimeout);
wx.showToast({ title: '连接失败,请检查账号密码', icon: 'none' });
this.updateConnectionStatus('disconnected');
});
// 监听关闭,code 为 1006 表示异常关闭,常伴随错误
wx.onSocketClose((res) => {
if (connectTimeout) clearTimeout(connectTimeout);
if (res.code === 1006) {
console.error('WebSocket closed abnormally, likely auth failure');
wx.showToast({ title: '连接被服务器拒绝', icon: 'none' });
}
this.updateConnectionStatus('disconnected');
});
}
3.2 消息乱序与重复:WebSocket 与 MQTT 协议的双重挑战
现象:UI 上数据显示顺序混乱,或同一消息被重复渲染多次。
根源:双重因素叠加:
- WebSocket 层 : wx.onSocketMessage 回调是异步事件,多个消息可能在 JS 主线程中“堆积”,若处理逻辑耗时(如复杂 JSON 解析、大量 setData ),后到达的消息可能先完成渲染;
- MQTT 层 :QoS=0 消息不保证送达,但 QoS=1 消息在 PUBACK 丢失时,服务器会重发,导致客户端收到重复包。
规避策略:引入内存消息队列与去重机制。
- 顺序保证 :在
onSocketMessage中,不直接处理,而是将payload推入一个messageQueue数组,然后setTimeout(() => processQueue(), 0)将处理逻辑放入微任务队列,确保所有消息按接收顺序串行处理。 - 重复过滤 :为每条消息生成
payloadHash(如MD5(payload + topic + timestamp)),维护一个seenHashesSet,处理前检查是否已存在,存在则return。
const messageQueue = [];
const seenHashes = new Set();
wx.onSocketMessage((res) => {
const payload = res.data;
const hash = md5(`${payload}${currentTopic}${Date.now()}`);
if (seenHashes.has(hash)) return; // 丢弃重复
seenHashes.add(hash);
messageQueue.push({ payload, topic: currentTopic });
// 确保只有一个处理循环在运行
if (!isProcessing) {
isProcessing = true;
processQueue();
}
});
async function processQueue() {
while (messageQueue.length > 0) {
const msg = messageQueue.shift();
const parsed = parseMessagePayload(msg.payload);
if (parsed) {
await recordMessage(msg.topic, parsed);
// 更新 UI...
}
}
isProcessing = false;
}
3.3 存储容量耗尽: wx.setStorage 失败的优雅降级
现象:当 messages 数组增长至临界点, wx.setStorage 抛出 QUOTA_EXCEEDED_ERR ,整个记录功能瘫痪。
根源:小程序对 wx.setStorage 的总容量有硬性限制,且该限制是 所有 key 共享 的。若应用其他模块(如用户配置、缓存图片)也大量使用 setStorage , messages 的可用空间会急剧萎缩。
规避策略:实施三级降级:
- 一级(预防) :在 recordMessage 写入前,调用 wx.getStorageInfo 获取剩余空间,若 < 1MB ,触发自动裁剪(如保留最近 100 条);
- 二级(容错) : setStorage 失败时,不抛出异常,而是将新消息暂存于内存 memoryOnlyMessages 数组,并在 UI 显著位置提示“本地存储已满,新消息仅暂存于内存,重启后丢失”;
- 三级(兜底) :提供“导出并清空”快捷按钮,引导用户主动管理。
async recordMessage(topic, payload) {
// ... [省略解析与构造]
// 一级:检查空间
try {
const info = await wx.getStorageInfo();
if (info.currentSize > info.limitSize - 1024) { // 预留 1MB
console.warn('Storage almost full, trimming messages');
messages = messages.slice(-100);
}
} catch (e) {}
try {
await wx.setStorage({ key: 'messages', data: messages });
} catch (e) {
if (e.errMsg.includes('QUOTA_EXCEEDED')) {
// 二级:降级到内存
memoryOnlyMessages.push(newRecord);
wx.showToast({
title: '存储已满',
icon: 'warning',
duration: 3000
});
console.warn('Falling back to memory-only storage');
} else {
throw e;
}
}
}
3.4 真实项目经验:我在部署环境踩过的坑
在为一家智能农业大棚客户部署小程序监控端时,我遇到了一个极具迷惑性的问题:设备上报的温湿度数据,在小程序上显示正常,但导出的 JSON 文件里,所有 timestamp 字段的值都相同,均为导出那一刻的时间戳,而非消息接收时间。
排查过程耗时两天。起初怀疑是 recordMessage 函数中 Date.now() 调用位置错误,但代码逻辑清晰无误。最终发现罪魁祸首是 JSON.stringify 的一个鲜为人知的特性:当对象中包含 Date 实例时, stringify 会将其序列化为 ISO 字符串,但若对象是通过 Object.assign 或展开运算符 ... 浅拷贝而来,且源对象的 timestamp 是 Date 实例,则拷贝后 timestamp 属性的 toString 方法被劫持,导致 stringify 时调用的是被篡改的方法。
根本原因在于,我们为了“格式化时间”,在 recordMessage 中错误地写了:
// 错误示范!
const newRecord = {
...parsed, // parsed 中的 timestamp 是 Date 实例
timestamp: new Date() // 覆盖为当前时间
};
但 parsed 的结构是 { timestamp: DateObj, data: {...} } , ...parsed 会将 DateObj 的 toString 方法一并拷贝,而小程序环境对此有特殊处理。
解决方案极其简单: 永远不要在记录对象中混用 Date 实例与普通数字时间戳 。强制统一为毫秒数字:
const newRecord = {
id: ...,
topic: ...,
payload: parsed,
timestamp: Date.now() // 确保是 number 类型
};
这个坑提醒我:在小程序这种多端兼容、运行时环境复杂的平台上,最“朴素”的写法(如直接用 Date.now() )往往是最可靠的。任何看似炫技的语法糖(如 ... 拷贝、 new Date().toISOString() )都可能在某个微信版本中埋下隐患。
更多推荐


所有评论(0)