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) ),维护一个 seenHashes Set,处理前检查是否已存在,存在则 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() )都可能在某个微信版本中埋下隐患。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐