做微信小程序的时候,“一键已读”这种需求真的是高频中的高频。不管是聊天会话列表、通知中心还是订单消息页面,用户看到一堆红点都习惯先找个“全部已读”按钮点一下。功能听起来简单,不就是把所有未读状态清零嘛,但真正动手做的时候,你会发现一堆细节问题:已读状态应该存哪里?服务端返回的数据怎么跟本地状态合并?tabBar角标怎么同步清掉?连点两下会不会重复请求?这些问题不处理干净,上线后就会出现“明明点了全部已读,红点还在”、“换个页面回来未读又冒出来”这种让人抓狂的bug。
这篇文章我会从需求场景、数据模型、接口设计、代码实现到性能优化,把一键已读功能从头到尾说清楚,附带我在实际项目中踩过的坑和排查经验。适合刚入门的小程序开发者,也适合那些功能会做了但想优化得更稳的老手。代码以原生微信小程序为主,uniapp/Taro的差异点我也会提一嘴。
1. 先想清楚:一键已读到底要改什么状态
1.1 两个典型场景:会话列表与消息通知中心
一键已读在不同业务里,对象是完全不一样的。我经手最多的是两类场景。
第一类是会话列表,类似微信的聊天列表。每个会话有自己的未读数,一键已读的语义是把所有会话的未读数清零,会话本身还保留在列表里,只是那个数字和红点消失了。
第二类是消息通知中心,类似订单通知、系统公告、互动提醒。每条消息记录有一个已读/未读字段,一键已读是把所有未读记录标记为已读。注意这里的差异:会话列表重置的是一个聚合数字(unread_count),通知中心修改的是每条记录的布尔状态(is_read / read_at)。操作对象不一样,接口和落到数据库的逻辑也就不一样。
还有更细的变体,比如有的业务点击“一键已读”后会把未读消息“收起来”,只展示已读列表;有的业务反而会把未读消息保留在未读Tab里,只把角标清了。这些属于产品层面的选择,但作为开发者,你一开始就得问清楚:已读之后数据往哪走,是原地变成已读状态,还是从当前列表里移除。方向错了,后面代码全白写。
1.2 方案选型:纯前端标记还是后端配合
做一键已读有两种大路线,纯前端实现和服务端配合实现。我先说结论:如果是真实业务、数据要跨设备同步,必须走后端标记;如果只是个内部demo、演示工具,或者数据完全是本地产生的,纯前端也能凑合,但限制很大。
纯前端方案的实现逻辑是:把未读数据维护在页面data或者本地storage里,点击一键已读就遍历数组把unread_count全部置0,再写一下storage。这个方案最大的问题在于:换台手机登录,未读状态恢复原样;后端如果存着未读标记,下次下拉刷新或者重新拉取列表时,未读数据又会从服务端“冒”回来。所以纯前端只适合没有服务端的小场景,或者服务端本身就不存未读状态的场景。
真正稳妥的是后端配合方案。前端调用后端“全部已读”接口,由服务端把会话或消息的未读状态统一更新,同时返回最新的角标值或最新列表。前端拿到结果后做两件事:更新本地展示数据,清理tabBar角标。服务端负责“数据权威”,前端负责“体验流畅”,两边配合才是正经做法。
而且后端接口最好做得“幂等”。也就是说,用户连续点两次一键已读,第二次的数据状态和第一次一样,不会产生任何副作用。这样你前端就不用费劲去防止重复点击了——当然实际开发里我依然会做防重复处理,因为幂等防的是数据问题,前端防的是重复的请求流量和loading抖动。
2. 数据模型设计:未读状态不该只活在页面上
2.1 消息与会话的数据结构
数据模型设计这块,很多新手容易忽略。我先给出一个在真实项目里能直接用的大致结构,大家可以根据业务裁剪。
如果是会话列表,后端返回的数据一般长这样:
{ "code": 0, "data": { "list": [ { "conversation_id": "1001", "peer_name": "张三", "peer_avatar": "https://xxx.com/a.png", "last_message": "好的,明天见", "last_message_time": 1720000000, "unread_count": 5 }, { "conversation_id": "1002", "peer_name": "客服小助手", "peer_avatar": "https://xxx.com/b.png", "last_message": "您的问题已受理", "last_message_time": 1719990000, "unread_count": 0 } ], "total_unread": 5 } }如果是通知中心,结构则更偏向单条消息:
{ "code": 0, "data": { "list": [ { "id": "10001", "title": "订单发货提醒", "content": "您的订单已发货", "create_time": 1720000000, "is_read": false, "read_at": null } ], "total_unread": 1 } }注意观察:会话场景关注的是“会话级未读数”,通知场景关注的是“单条记录的已读标记”,两者在页面上的操作按钮逻辑虽然一样,但列表项的字段职责是完全不同的。设计数据结构时,尽量让后端把total_unread直接给出来,不要在端上通过遍历列表自己算,后端算好了前端直接用,能省掉不少数据处理代码。
2.2 已读状态存哪里:服务端、本地缓存与页面data的分工
数据存放的三个层级各有用处,不要混淆。
服务端存的是数据的权威状态,比如数据库里的unread_count、is_read字段。前端的一切展示最终都要以服务端的数据为准。
本地缓存(wx.setStorageSync)存的是“前端自己产生的补充状态”,比如我加粗提示一下:如果用户执行了一键已读,但接口返回更新的列表之前,本地可以先记录一个“我已执行过全部已读”的时间戳,用于本地优先渲染。这种兜底策略在实际项目里非常有用,后面我会展开讲。
页面data(this.setData)存的只是当前渲染快照。它不承担持久化职责,每次页面onShow或者下拉刷新都应该从服务端重新拉取最新数据。
有一个很典型的错误:把未读状态只放在页面的data里,然后用wx.setStorageSync来做持久化,完全绕过服务端。我接手过一个项目就是这个实现,本地测试怎么点都对,一上真机、一换设备就露馅。用户换个手机登录,历史未读状态又全部回来了。所以各位记住:凡是跟用户账号相关的数据,尽量上服务端;本地缓存永远只是“展示层的辅助手段”,不是“数据存储仓库”。
2.3 角标与总未读数的聚合逻辑
tabBar上的数字角标(badge)和红点,是跟一键已读强相关的UI元素。这里面的关键在于:角标的总未读数从哪来。
最简单的做法是列表接口直接返回total_unread字段,前端在onShow时调用一个轻量的未读总数接口或列表接口,拿到总数后调用wx.setTabBarBadge设置角标。一键已读成功后,角标直接移除。
但如果你用了本地兜底的时间戳方案,角标的计算逻辑就会复杂一点点:前端展示的未读数应该是服务端返回的未读数经过本地已读时间戳校准后的结果。比如服务端返回总未读12,但本地记录了一个“2分钟前执行过一键已读”,那你需要判断这12条未读的消息时间是否早于该时间戳。如果都早于,说明服务端还没同步过来,前端就展示为0;如果其中有晚于时间戳的新消息,那这些才是真正需要展示的未读数。
这个逻辑不建议在页面上重复写,建议抽到一个公共模块里统一管理。我项目中通常叫unread-store.js,里面维护当前未读总数、本地已读时间戳、角标更新函数,页面只管调用,处理细节全部收敛在一个模块里,调试和维护都省心。
3. 核心实现:从按钮到数据落地的完整流程
3.1 页面骨架与交互按钮
我们先搭一个最简单的会话列表页面,重点看一键已读的完成链路。WXML部分大概长这样:
<view class="container"> <view class="header"> <text class="title">消息</text> <view wx:if="{{totalUnread > 0}}" class="read-all-btn" bindtap="handleMarkAllRead"> <text>全部已读</text> </view> </view> <view class="list"> <view wx:for="{{conversationList}}" wx:key="conversation_id" class="conversation-item" > <view class="avatar">{{item.peer_name}}</view> <view class="content"> <text class="name">{{item.peer_name}}</text> <text class="last-msg">{{item.last_message}}</text> </view> <view wx:if="{{item.unread_count > 0}}" class="badge"> <text>{{item.unread_count > 99 ? '99+' : item.unread_count}}</text> </view> </view> </view> <view wx:if="{{conversationList.length === 0}}" class="empty">暂无消息</view> </view>这里有个小细节:按钮的渲染我用了wx:if="{{totalUnread > 0}}"。没有未读的时候按钮直接不渲染,而不是渲染出来再置灰。这样视觉更干净,也不会让用户产生“点了没反应”的困惑。计算属性totalUnread是页面从列表数据里算出来的,每次setData列表之后同步更新。
3.2 一键已读的接口调用与本地状态更新
核心逻辑在js层。先看常规的实现代码,再解释关键点:
// pages/message/index.js const app = getApp(); Page({ data: { conversationList: [], totalUnread: 0, loading: false }, onShow() { this.fetchList(); }, // 拉取会话列表 async fetchList() { const res = await app.request({ url: '/v1/conversations', method: 'GET' }); if (res.code === 0) { this.setData({ conversationList: res.data.list, totalUnread: res.data.total_unread }); this.updateTabBarBadge(res.data.total_unread); } }, // 一键已读 async handleMarkAllRead() { // 防止重复点击 if (this.data.loading) return; // 没有未读直接返回 if (this.data.totalUnread === 0) return; this.setData({ loading: true }); // 记录本地已读时间戳,用于兜底展示 const nowTimestamp = Math.floor(Date.now() / 1000); // 乐观更新:先把前端UI置为已读 this.markAllLocalRead(); try { await app.request({ url: '/v1/conversations/read-all', method: 'POST' }); // 请求成功后,用服务端数据校准(也可以直接沿用乐观更新) wx.removeTabBarBadge({ index: 1 }); wx.hideTabBarRedDot({ index: 1 }); wx.showToast({ title: '已全部读', icon: 'success' }); } catch (err) { // 请求失败,回滚本地状态,重新拉列表 await this.fetchList(); wx.showToast({ title: '操作失败,请重试', icon: 'none' }); } finally { this.setData({ loading: false }); } // 无论成败,都记录本次已读时间,仅供参考(失败的场景由fetchList重置) wx.setStorageSync('readAllTimestamp', nowTimestamp); }, // 本地将列表中的未读清零 markAllLocalRead() { const list = this.data.conversationList.map((item) => { if (item.unread_count > 0) { return { ...item, unread_count: 0 }; } return item; }); this.setData({ conversationList: list, totalUnread: 0 }); }, // 更新tabBar角标 updateTabBarBadge(count) { if (count > 0) { wx.setTabBarBadge({ index: 1, text: count > 99 ? '99+' : String(count) }); } else { wx.removeTabBarBadge({ index: 1 }); wx.hideTabBarRedDot({ index: 1 }); } } });这段代码的关键点有三个:
第一,乐观更新。先不管请求成功还是失败,先把页面上的数字清零、角标清掉,用户点击的瞬间就看到“生效了”。请求成功万事大吉,请求失败则回滚重新拉列表。这种交互比“转圈等接口”舒服很多,尤其是在弱网环境下,用户不会觉得点了跟没点一样。
第二,loading拦截。一键已读虽然内部幂等,但连续点击会产生两个并发请求,容易造成状态错乱。我在函数开头判断loading,处理中直接return,确保同一时间只有一个已读请求在处理。
第三,index参数要写对。wx.removeTabBarBadge里的index是tabBar列表中消息tab的索引,从0开始。写错了会移除别的tab的角标,这类bug超级隐蔽。我建议在代码里用常量维护tab索引,不要裸写数字,比如const MSG_TAB_INDEX = 1。
3.3 同步清理 tabBar 角标与红点
tabBar角标有两种形态,数字角标和红点。数字角标用wx.setTabBarBadge,红点用wx.showRedDot。一键已读之后两种都要清掉,否则可能出现数字没了但红点还在的尴尬局面。
清理代码如下:
// 一键已读成功后 wx.removeTabBarBadge({ index: MSG_TAB_INDEX }); wx.hideRedDot({ index: MSG_TAB_INDEX });注意tabBar API只能在tabBar页面调用,如果你的小程序页面层级里当前不在tabBar页,直接调会报“API not found”或者无效。一个比较稳妥的实践是:把角标更新函数封装成公共方法,内部try-catch包裹,因为有些低版本基础库可能对API支持不完整。我一般这样兜一层:
function safeRemoveBadge(index) { try { wx.removeTabBarBadge({ index }); wx.hideTabBarRedDot({ index }); } catch (e) { console.warn('remove badge failed', e); } }3.4 用本地时间戳做弱网兜底
我再分享一个自己用得非常多的技巧:本地已读时间戳方案。
先解释一下问题场景。在某些弱网环境里,用户点了一键已读,前端乐观更新已经把所有未读清零,但后端接口因为网络原因迟迟没返回。恰好这个时候有个下拉刷新或者onShow触发了列表重新拉取,服务端数据库里未读数还没更新(因为请求还没到),这时新拉回来的列表就会重新带着一堆未读数“闪回来”,用户体验非常糟糕。
解决方案就是我在前面提到的本地时间戳。具体做法:
// 一键已读时写入 wx.setStorageSync('readAllTimestamp', Math.floor(Date.now() / 1000)); // 拉取列表后,渲染前做一次校准 formatConversationList(list) { const readAllTs = wx.getStorageSync('readAllTimestamp') || 0; return list.map((item) => { // 如果会话的最后一条消息时间早于已读时间戳,说明这条会话在已读之后没有新消息 if (item.last_message_time && item.last_message_time < readAllTs) { return { ...item, unread_count: 0 }; } return item; }); }这个方案的意义在于:本地展示时,对那些消息时间早于“已读时间点”的会话,一律按已读渲染。即使服务端暂时没同步过来,前端这里也能保持“已读”的视觉一致性。等下一次真正拉取到最新数据、服务端已更新时,数据自然恢复正常。
当然这个方案也有个前提:会话必须带last_message_time字段,而且后端接口拿到的时间戳要与本地一致。如果服务端返回的是时间字符串而不是时间戳,记得先parse一下,不然比较逻辑会出问题。时间戳方案不是银弹,但作为展示层的“缓冲带”,在实际项目里能省掉一堆抱怨和投诉。
4. 大批量消息场景的性能与体验优化
4.1 setData 大数据量的处理方式
前面说的都是常规列表,但如果用户的通知列表有几百条甚至上千条未读,一键已读时的setData就要小心了。微信小程序里setData的性能跟数据量和调用频率强相关,频繁setData大数组会造成页面卡顿甚至掉帧。
一个直接的办法是改用路径更新,只更新需要变的字段。比如:
// 优化前:整个数组重建 this.setData({ conversationList: newList }); // 优化后:按路径更新每个未读会话的unread_count this.data.conversationList.forEach((item, index) => { if (item.unread_count > 0) { this.setData({ [`conversationList[${index}].unread_count`]: 0 }); } });但这里有个陷阱:如果列表有几百项,循环里反复setData会触发几十上百次渲染,性能更差。更推荐的做法是,把需要更新的元素索引先收集起来,用一条setData同时更新多个路径:
const patch = {}; this.data.conversationList.forEach((item, index) => { if (item.unread_count > 0) { patch[`conversationList[${index}].unread_count`] = 0; } }); if (Object.keys(patch).length > 0) { this.setData({ ...patch, totalUnread: 0 }); }如果数据量特别大,比如上千条未读通知,建议直接用空壳刷新:把整个列表替换为相同结构但已读的数组。这里再提醒一句:不要修改原数组对象,要map生成新数组,否则小程序的数据响应可能失效。setData接到的应该是新引用,不是一个被修改的旧对象。
更深层的优化可以用“虚拟列表”或者“分页加载”,但那个就是另一个话题了。如果你们的一键已读功能已经卡到需要虚拟列表,那大概率问题不在已读逻辑,而是页面本身就该做列表优化了。
4.2 防重复点击与并发竞态处理
一键已读按钮的重复点击问题,我前面用loading拦截了。但这里还有一类并发问题:用户点了一键已读,同时在另一个页面收到新消息推送,导致列表页被重新刷新,未读数重新出现。这种情况下,前一个已读请求和后一个列表刷新请求谁先返回都会导致数据不一致。
我的处理方式是:在请求层维护一个简单的“请求序号”或者“请求版本号”。每次发起列表刷新,自增一个版本号;每个请求返回时带上发起时的版本号,如果返回时的版本号已经过期,就丢弃这次结果,不更新页面。这个思路类似于前端防抖竞态处理,代码如下:
let listRequestVersion = 0; async fetchList() { const currentVersion = ++listRequestVersion; const res = await app.request({ url: '/v1/conversations' }); if (currentVersion < listRequestVersion) { // 说明已经有更新的请求,丢弃本次数据 return; } this.setData({ conversationList: res.data.list, totalUnread: res.data.total_unread }); }竞态处理是很容易被忽略的细节,但不加这个,多环境和多来源的数据交叉刷新时,页面很容易闪回旧数据。类似的思路也可以用于下拉刷新、分页加载和已读接口并发时的数据一致性处理。
4.3 已读后列表空态与按钮消失的节奏感
一键已读还有一个关于体验的细节:按钮消失的时机。有些产品会希望按钮在点击后马上消失,有些则希望等接口成功后再消失。我推荐“等接口回来再消失”的方式。因为按钮的消失对用户而言意味着“操作完成”的强反馈,如果网络慢导致乐观更新后按钮消失,但接口最终失败,又要“惊喜”地把按钮变回来,交互上特别混乱。
代码上的控制其实很简单,不要用totalUnread === 0直接控制按钮,而是用一个独立的hasPendingReadALl状态,或者等fetchList返回后再计算totalUnread。比如:
async handleMarkAllRead() { // ... await app.request({ url: '/v1/conversations/read-all' }); // 重新拉取或本地更新,以最终数据为准 await this.fetchList(); // fetchList内部会根据返回更新totalUnread和按钮 }按钮的消失跟随真实的列表刷新结果,而不是乐观更新的瞬间。如果网络快,视觉上是秒消失;如果网络慢,loading状态让用户知道操作在进行中。这个节奏要自然得多。
另外,空态文案也要同步处理。列表没有任何消息时,不该显示“全部已读”按钮。我在WXML里用wx:if="{{conversationList.length === 0}}"控制了“暂无消息”的空态,按钮的显隐判断里也加了totalUnread > 0的条件,这两个条件配合,基本不会有“空列表还带个按钮”的尴尬。
5. 常见问题与排查实录
5.1 列表数据明明改了却不刷新
这是小程序开发里高频出现的问题,症状是:setData后页面没变化,打开调试器发现数据其实已经变了。
八成原因是修改了原数组引用。比如:
// 错误写法:直接修改数组项 this.data.conversationList[index].unread_count = 0; this.setData({ conversationList: this.data.conversationList });list数组本身引用没变,setData对比后发现引用一致就“跳过”更新了,UI自然不动。正确写法是生成新数组或者用扩展运算符创建新对象:
const newItem = { ...this.data.conversationList[index], unread_count: 0 }; const newList = [...this.data.conversationList]; newList[index] = newItem; this.setData({ conversationList: newList });还有一种情况是setData的key写错,路径对不上,比如嵌套层级多了个data前缀。这个排查起来也不难,在setData回调里打日志,看实际渲染值。
5.2 角标清理不彻底
一键已读后数字角标消失了但红点还在,或者tabBar角标消失了但页面列表还是旧的。
角标这块最常见的原因就是:数字角标和红点API是两套,只调了removeTabBarBadge没调hideRedDot;或者就根本没调,只更新了页面内部数据。另外,如果当前页面不是tabBar页面,位置索引又写错,API静默失败(很多API失败就进fail回调,但很多人不写fail回调,导致没反应)。所以封装safeRemoveBadge时,记得都包上fail回调并打印日志:
wx.removeTabBarBadge({ index: 1, fail: (e) => console.error('remove badge fail', e) });5.3 一键已读后重新进入页面又出现未读
这个就是服务端同步问题。最典型的是:用户在一键已读后的瞬间,服务端其实还没真正更新数据库,此时如果页面onShow重新拉列表,服务端返回的仍是旧数据。
排查步骤:
- 确认接口是否幂等,重复调用read-all不会产生副作用。
- 确认前端在onShow是否重新拉取列表,且列表接口返回的total_unread是否来自服务端表字段,而不是缓存里算出来的。
- 确认服务端是否有缓存雪崩的问题,比如用了Redis存未读数,但批量已读接口没有同步更新Redis。
如果服务端更新有延迟,可以用我前面说的本地时间戳方案做展示层兜底,确保前端不闪回旧数据。这个临时方案能让体验平滑,但根子上还是要推动后端把已读接口的数据库更新做及时、做正确。
5.4 一键已读接口超时后的状态不一致处理
接口超时是一键已读最讨厌的问题。前端已经乐观更新成“全部已读”,但服务端迟迟没返回。此时页面如果允许继续操作(比如用户又点了一次),两次请求可能都是超时的,就会出现“前端看着是全已读,后端压根没改”的严重不一致。
我的建议是:
- 一键已读请求加上较短的timeout,比如5秒。
- 超时后不要只是toast报错,要主动重新拉一次列表数据,以服务端数据为准完成回滚或者确认。
- 如果连续两次已读请求都超时,把“已读失败”事件上报到日志平台,通过监控尽早发现接口问题。
优化后的超时处理逻辑大致如下:
async handleMarkAllRead() { // ... try { const res = await app.request({ url: '/v1/conversations/read-all', timeout: 5000 }); if (res.code === 0) { this.updateTabBarBadge(0); } } catch (err) { await this.fetchList(); // 回滚或校准 wx.showToast({ title: '网络异常,请重试', icon: 'none' }); } finally { this.setData({ loading: false }); } }5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查与解决方案 |
|---|---|---|
| 点了全部已读,列表没变化 | setData修改了原数组引用 | 改用新数组/新对象,保证setData收到新引用 |
| 数字角标清了但红点还在 | 只调用removeTabBarBadge,没调用hideRedDot | 补调用wx.hideTabBarRedDot |
| 刷新后未读又冒出来 | 服务端未真正更新,或本地缓存干扰 | 确认接口幂等,引入本地时间戳兜底展示 |
| 连续点击产生多个请求 | 缺少loading/禁用态控制 | 在函数入口加loading判断 |
| 弱网下状态闪回 | 乐观更新与接口失败导致回滚 | 用版本号或fetchList做统一校准 |
| tabBar角标更新无效 | tab索引写错或API在非tabBar页面调用 | 检查index,确认在onShow时才调用tabBar相关API |
| 列表超长卡顿 | setData大数组整体替换 | 用路径更新或按批次更新 |
6. 跨端实现差异:uniapp 与 Taro 的注意事项
现在很多人用uniapp或者Taro来开发小程序,一键已读的核心逻辑是一样的,但API有差异,我简单提一下。
uniapp里设置tabBar角标用的是uni.setTabBarBadge,清理是uni.removeTabBarBadge和uni.hideTabBarRedDot,参数和原生wx基本一致。但要注意,uniapp的vue3版本里,选项式API和组合式API的写法会影响setData的使用方式。组合式里没有this.setData,你要么用响应式变量赋值,要么还是要在vue2的写法下走this.setData。原则还是一样:触发响应式更新的数据必须是新引用,直接改obj.xxx = xxx是不触发视图更新的。
Taro的话,因为底层是React语法,修改状态用setState。Taro的setState在小程序端通常会做合并优化,但深层嵌套对象更新时,同样需要展开新对象。Taro 3对tabBar角标的封装还是走wx API,直接用Taro.setTabBarBadge就行。
跨端的另一个大坑是本地时间戳方案的取用。不同端的storage API不同,原生用wx.getStorageSync,uniapp用uni.getStorageSync,Taro用Taro.getStorageSync。封装成一个common.getStorage(key)公共方法,避免业务代码散落一堆平台判断。
7. 最后一层:一键已读与通知体系的联动
一键已读功能上线后,还有个容易忽视的联动点,就是通知体系。很多小程序接了订阅消息、客服消息或站内信推送,用户会在微信服务通知里收到“你有1条新消息”的推送。这种情况下,小程序内部的一键已读状态和微信服务通知的角标是脱节的——微信服务通知的角标归微信管,小程序自己的badge归自己管,两边没法直接同步。
所以产品层面要有预期管理:小程序内的一键已读只能清除小程序内部的未读状态,不能保证微信服务通知的红点也消失。如果确实想让服务通知的角标也消失,只能引导用户去微信设置里关闭该小程序的订阅消息推送,或者在小程序内提供“关闭通知”的选项。这个功能不属于一键已读的核心范围,但如果不提前跟产品沟通清楚,很容易被当成bug投诉。
另外,如果你的小程序同时接了IM能力和业务通知,注意别一键已读把IM会话的未读也顺手清了。我在项目里就遇到过这种事故:订单通知的“全部已读”按钮不小心把客服会话的未读数也归零了,客户那边炸了锅。正确做法是后端read-all接口要区分场景,传scope参数,比如scope=notification或scope=conversation,前端请求时明确场景,服务端按范围更新。
8. 一些真心建议
功能本身不复杂,但能不能做得稳,全在细节里。做了一天微信小程序一键已读,说几点我的个人体会。
第一个体会是:接口设计比前端代码更重要。一个幂等的read-all接口,底下配合清晰的数据库更新逻辑,前端再烂也乱不到哪去;接口设计得乱七八糟,前端再怎么补救都是徒劳。就算时序边界、超时重试做得很全,核心数据源不稳,页面终究会闪。
第二个体会是:小程序里“数据快照”的展示策略很关键。一键已读这种高频轻操作,配合乐观更新和空态兜底,体验会非常顺滑。做之前先把几种网络状态(正常、超时、弱网重试)都列出来,把每种状态下的UI表现先定义清楚,再去写代码,能少走很多弯路。
第三个建议是:每个一键已读操作都要埋点。点击量、成功率、失败原因、平均耗时,这些数据能帮你尽早发现接口和服务端问题。没有数据支撑的优化都是瞎猜,有一次线上角标不消失,查到最后是后端事务没提交,没有埋点日志的话,排查时间至少要翻一倍。
最后说个实用小技巧。如果你的消息列表分布在多个tab页面里,一键已读后最好用一个全局事件或者一个共享的store通知其他页面刷新。不然你在这个页面清完了角标,切到另一个页面,它的未读数还是旧的。微信原生小程序可以用getApp().globalData加一个onUnreadChange的监听函数列表,或者用wx.eventCenter这种自研的轻量事件通知机制。这个坑我踩过,补上之后整个通知模块才真正统一了。
我做微信小程序的时间不算短了,这种“简单功能深坑多”的项目不止一次碰到。每个功能都能写半天,但大多是看着容易、做着磨人。希望这篇文章能让你少折腾几个通宵。