我怎么做消息模块的
Site Owner
发布于 2026-03-15
# 我怎么做消息模块的 最近在做一个即时通讯功能。一开始觉得就是个聊天页,后来发现坑比想象的多。
我怎么做消息模块的
最近在做一个即时通讯功能。一开始觉得就是个聊天页,后来发现坑比想象的多。
一开始的想法
我最初的设计很简单:会话列表 + 聊天详情,两个页面搞定。
技术选型也没多想:Zustand 管状态,Axios 发请求,WebSocket 做实时推送。这些都是熟悉的东西。
然后就开始写了。
第一个坑:数据结构不匹配
后端返回的数据长这样:
interface ConversationDTO {
id: number;
otherUserId: number;
otherUserName: string;
lastMessage: string;
unreadCount: number;
// ...
}
我前端需要的是:
interface Conversation {
id: string; // 注意这里是 string
name: string;
avatar: string;
lastMessage: string;
time: string; // 要显示「刚刚」「5分钟前」这种
// ...
}
一开始我直接在组件里转换,代码很乱。后来统一在 Store 里做转换,组件只管渲染。
function convertConversation(dto: ConversationDTO): Conversation {
return {
id: String(dto.id),
name: dto.otherUserName,
time: formatRelativeTime(dto.lastMessageAt),
// ...
};
}
这个转换函数写了好几版。比如 formatRelativeTime 一开始用的第三方库,后来发现太重了,自己写了个简单的。
第二个坑:新会话怎么处理
用户从商品详情页点「联系卖家」,跳到聊天页。这时候会话还不存在。
我一开始的做法是:先调 API 创建会话,拿到会话 ID,再跳转。
问题是:创建会话需要时间,用户点了按钮要等一两秒才能跳转。体验很差。
后来改成:直接跳转到 /messages/chat/new,把对方用户信息带过去。发第一条消息的时候,后端自动创建会话。
// 从商品页跳转
navigate('/messages/chat/new', {
state: {
otherUserId: 123,
otherUserName: '卖家小王',
otherUserAvatar: 'https://...',
}
});
发送消息成功后,刷新会话列表,找到新创建的会话,再跳转过去。
const handleSend = async (content: string) => {
const sentMessage = await sendMessage({
receiverId: newChatReceiverId,
content,
messageType: 'TEXT',
});
if (isNewChat && sentMessage) {
await loadConversations();
const newConv = conversations.find(c => c.otherUserId === receiverId);
if (newConv) {
navigate(`/messages/chat/${newConv.id}`, { replace: true });
}
}
};
这个流程有点绕,但用户体验好多了。
第三个坑:WebSocket 重连
WebSocket 断了怎么办?
我用的是 STOMP 协议,库本身支持自动重连。但重连之后,之前的订阅会丢失。
一开始没注意这个问题,测试的时候发现:断网再连上,收不到新消息了。
后来在 WebSocket 服务里加了订阅恢复逻辑:
// 保存所有订阅
private subscriptions = new Map<string, Function>();
// 重连后恢复订阅
private onConnect = () => {
this.subscriptions.forEach((callback, destination) => {
this.client.subscribe(destination, callback);
});
};
这样断线重连后,订阅会自动恢复。
第四个坑:消息去重
WebSocket 收到消息,要加到消息列表里。但有时候会收到重复的消息。
比如网络抖动的时候,同一条消息可能收到两次。
一开始没处理,列表里会出现重复消息。后来加了去重:
handleWebSocketMessage: (data) => {
const message = convertMessage(data, currentUserId);
// 检查是否已存在
const exists = currentMessages.some(m => m.apiId === message.apiId);
if (exists) return;
set(state => ({
currentMessages: [...state.currentMessages, message]
}));
}
用后端返回的 apiId 做去重,比用前端生成的 ID 靠谱。
图片消息的处理
发图片消息的流程:
- 用户选图片
- 上传到 OSS
- 拿到 URL
- 发送消息,content 就是图片 URL
const handleSendImage = async (file: File) => {
const result = await uploadToOSS(file, 'temp');
await sendMessage({
receiverId,
content: result.url,
messageType: 'IMAGE',
});
};
这里有个问题:上传需要时间,用户选完图片要等几秒才能发出去。
我没有像 AI 对话那样做「先显示再上传」的优化。因为这个场景用户可以接受等一下,而且实现起来简单很多。
后面如果用户反馈体验差,再优化。
Mock 数据兜底
API 挂了怎么办?
我加了 Mock 数据兜底。API 失败的时候,显示示例数据,至少页面不会白屏。
try {
await loadConversations();
} catch {
if (conversations.length === 0) {
useMessageStore.getState().setConversations(MOCK_CONVERSATIONS);
}
toast.error('加载会话失败,显示示例数据');
}
这个在开发阶段很有用。后端还没好的时候,前端可以先跑起来。
目前的状态
跑了一周,基本功能都通了。
还有些没做的:
- 语音消息(后端接口还没好)
- 消息撤回
- 离线消息同步
- 消息搜索
这些等后面有需求再加。
回头看,最大的教训是:不要一开始就想太多。先把核心流程跑通,遇到问题再解决。我在数据结构上纠结了太久,其实边做边改也没什么问题。