返回列表
技术
19 分钟

我怎么做消息模块的

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 靠谱。

图片消息的处理

发图片消息的流程:

  1. 用户选图片
  2. 上传到 OSS
  3. 拿到 URL
  4. 发送消息,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('加载会话失败,显示示例数据');
}

这个在开发阶段很有用。后端还没好的时候,前端可以先跑起来。

目前的状态

跑了一周,基本功能都通了。

还有些没做的:

  • 语音消息(后端接口还没好)
  • 消息撤回
  • 离线消息同步
  • 消息搜索

这些等后面有需求再加。

回头看,最大的教训是:不要一开始就想太多。先把核心流程跑通,遇到问题再解决。我在数据结构上纠结了太久,其实边做边改也没什么问题。

#项目实战