构建现代化 IM 聊天系统:React 实战技巧分享

199 阅读12分钟

前言

最近项目迭代了一个新需求,是需要在系统中集成一个IM 客服聊天界面,帮助客服人员快捷回复多账号下的私信内容,这里分享下写这个需求用到的哪些小技巧,希望能帮助到做类似需求的小伙伴们。

界面概览

大部分 IM 基本都是左边为会话列表,右侧为点开会话后的聊天记录与输入框。整体界面可以直接截图个草图让 Cursor 来写大体框架和界面 UI (左右布局会话列表固定宽度,聊天主体界面撑满剩余宽度),咱们专注实现逻辑。

image.png

实时消息推送方案的选择

需要推送消息我们一般常用的是 websocket 来做到实时传输数据,全双工的方式前后端通信,但由于我的需求没有群聊场景,只是一对一的聊天,因此选用了 SSE 这种半双工的方案,Chatgpt 类型的应用一般都是 SSE,只需要后端往前端推送新消息就好。SSE 可以比 WebSocket 更加节省服务器资源,特别是在不需要双向通信的应用场景中 MDN SSE 介绍

写的话可以原生方式来写,也可以使用第三方库,我这里直接使用的原生方式。

www.npmjs.com/package/@mi…

然后监听两个自定义的事件 chatMessage(聊天记录新消息)conversation (会话列表新增消息)。 我们还需要把数据存储在全局状态里,方便其他子组件便捷的获取到,不用一层层的 props 传递,这里使用了 jotai

// 设置 jotai 状态的函数
const setMessageData = useSetAtom(sseMessageDataAtom);
const setConversationData = useSetAtom(sseConversationDataAtom);
const setStatus = useSetAtom(sseStatusAtom);
const setError = useSetAtom(sseErrorAtom);

// 状态管理
const [status, setLocalStatus] = useState<'CONNECTING' | 'OPEN' | 'CLOSED'>('CLOSED');
const [error, setLocalError] = useState<Error | null>(null);
const [currentEvent, setCurrentEvent] = useState<string | null>(null);

 const createEventSource = () => {
    try {
      const url = `url`; // 要连接的 sse 地址
      const es = new EventSource(url);
      eventSourceRef.current = es;
      setLocalStatus('CONNECTING');

      es.onopen = () => {
        setLocalStatus('OPEN');
        setLocalError(null);
      };

      es.onerror = () => {
        const error = new Error('SSE 连接错误');
        setLocalError(error);
        setLocalStatus('CLOSED');
        message.error('由于网络问题,部分用户接入失败,请点击刷新');
      };

      es.addEventListener('chatMessage', (event) => {
        setCurrentEvent('chatMessage');
        setMessageData(event.data);
      });

      es.addEventListener('conversation', (event) => {
        setCurrentEvent('conversation');
        setConversationData(event.data);
      });
    } catch (err) {
      setLocalError(err as Error);
      setLocalStatus('CLOSED');
    }
  };
  
  // 关闭 EventSource 连接
  const closeEventSource = () => {
    if (eventSourceRef.current) {
      eventSourceRef.current.close();
      eventSourceRef.current = null;
      setLocalStatus('CLOSED');
    }
  };
  
  useEffect(() => {
    createEventSource()
    return () => {
      closeEventSource();
    };
  }, []);

假如你使用的是 umi,在本地开发的时候可能会发现控制台请求成功了,但是没收到推送数据,需要在 proxy.ts文件添加一下这段代码。其他 webpack vite 应该也是大同小异,可以网上搜下。

// SSE 本地开发环境需要处理跨域
onProxyRes: (proxyRes: any, req: any, res: any) => {
    if (req.headers.accept === 'text/event-stream') {
      res.writeHead(res.statusCode, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-transform',
        Connection: 'keep-alive',
        'X-Accel-Buffering': 'no',
        'Access-Control-Allow-Origin': '*',
      });
    }
},

会话列表

请求数据与滚动加载

左侧的会话列表的实现,首先要想好怎么请求数据,聊天会话数量日积月累会越来越多,所以一定是要分页的数据接口请求来,而不是一下子批量拉取。分页的接口形式,前端来滚动加载。

在网上了解到,会话列表一般采用游标分页方式。这种方法比传统分页的优势在于游标分页利用索引快速定位数据,避免了从头扫描的需求。由于我们的场景不涉及表格形式的跳转到特定页码,也没有页码显示,因此不需要使用传统的页码分页。

基于 ID 的游标分页

使用唯一的记录标识符(如数据库中的主键ID)作为游标。每次请求时,通过上一个记录的ID来获取下一批数据。 例如:SELECT * FROM users WHERE id > last_id LIMIT page_size;

基于时间戳的游标分页

使用时间戳字段来排序和分页,适用于按时间顺序插入的数据。 例如:SELECT * FROM posts WHERE created_at > last_timestamp ORDER BY created_at LIMIT page_size;

最后约定传最后一个数据的时间戳作为游标,接口 TS 类型简单展示。

export type ConversationPageParams = {
  /**
   * 分页游标
   */
  cursor?: number;
  /**
   * 分页大小
   */
  limit: number;
};

export type IMConversation = {
  /**
   * 分页游标
   */
  cursor?: number;
  /**
   * 数据列表
   */
  data?: Data[];
  /**
   * 是否还有更多数据
   */
  hasMore?: boolean;
};

export function GetConversationPage(params: ConversationPageParams) {
  return request<ApiResult<IMConversation>>('urlxxx/page', {
    method: 'GET',
    params,
  });
}

然后一般遇到滚动加载分页数据的场景我们可以使用 Tanstack-Query (原名 react-query)# useInfiniteQuery,我项目里是 v4 版本,代码示例如下,v5 版本有一些小差异但不影响使用。

官网使用useInfiniteQuery示例

  const {
    data: conversationList,
    isLoading,
    isFetchingNextPage,
    hasNextPage,
    fetchNextPage,
  } = useInfiniteQuery({
    queryKey: ['conversations', projectId, customerType, retentionStatus], // 查询参数
    queryFn: async ({ pageParam }) => {
      if (!projectId) {
        return Promise.resolve({
          cursor: 0,
          data: [],
          hasMore: false,
        });
      }
      const params: ConversationPageParams = {
        projectId,
        limit: 50,
        cursor: pageParam,
        customerType,
        retentionStatus,
      };
      const response = await GetConversationPage(params);
      return response.data;
    },
    getNextPageParam: (lastPage) => (lastPage?.hasMore ? lastPage?.cursor : undefined),
    initialData: {
      pages: [],
      pageParams: [],
    },
  });
  
  useEffect(() => {
    if (inViewport && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inViewport, hasNextPage, isFetchingNextPage, fetchNextPage]);

我们再使用 Map 来存储一下 会话 ID : 数据 的 Map,方便后续做查重操作。这里我使用了 ahook 的useMap

const [conversationMap, { set: setConversationMap, get: getConversation }] = useMap<
    string,
    Conversation
  >();

// 添加一个 useEffect 来处理 Map 的更新 放在 select 里面 setMap 会死循环
  useEffect(() => {
    conversationList?.pages.forEach((page) => {
      page?.data?.forEach((conversation: DouyinImManualConversation) => {
        if (conversation.conversationId) {
          setConversationMap(conversation.conversationId, conversation);
        }
      });
    });
  }, [conversationList?.pages, setConversationMap]);

DOM 呈现部分,删除部分业务相关代码,简单展示。

 <div className="overflow-y-auto flex-1 no-scrollbar">
    {!projectId ? (
      <div className="flex justify-center items-center h-full text-gray-400 -translate-y-16">
        请选择项目
      </div>
    ) : isLoading ? (
      <div className="flex justify-center py-4">加载中...</div>
    ) : (
      <>
        {conversationList?.pages?.map((page, i) => (
          <React.Fragment key={i}>
            {page?.data?.map((conversation: Conversation) => (
              <div
                key={conversation.conversationId}
                className={cn(
                  'flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50',
                  selectedConversation?.conversationId === conversation.conversationId &&
                    'bg-blue-50',
                )}
                onClick={() => handleSelectConversation(conversation)}
              >
                <Badge count={conversation.unReadCount} size="small">
                  <Avatar
                    src={conversation.customerInfo?.avatar || <img src={defaultAvatar} />}
                    size={40}
                  />
                </Badge>
                <div className="flex flex-col flex-1 gap-1">
                  <div className="flex justify-between items-center">
                    <div className="flex overflow-hidden gap-1 items-center">
                      <div className="max-w-[115px] truncate font-medium">
                        {conversation.customerInfo?.nickname}
                      </div>
                    </div>
                  </div>
                  <div className="flex justify-between items-center">
                    <div className="max-w-[115px] truncate text-xs text-gray-400">
                      {renderMessageInUserList(conversation.lastMessageOverview)}
                    </div>
                    <div className="text-xs text-gray-400">
                      {formatMessageTime(conversation.lastMessageOverview?.messageCreateTime)}
                    </div>
                  </div>
                </div>
              </div>
            ))}
          </React.Fragment>
        ))}
        <div ref={loadMoreRef} className="h-4" />
        {isFetchingNextPage && <div className="flex justify-center py-4">加载更多...</div>}
      </>
    )}
  </div>

接受新会话实时消息推送

接受 SSE 推送过来的新的会话消息,已经滚动加载拉取到的会话就直接更新并插入到最前面,当前加载的会话数据中不存在的话就插入到会话最前面。我们还有个星标指定的功能,所以我这里是插入到星标置顶后的第一位。

使用 react-query的好处在于我们可以直接使用queryClient.setQueryData来更新指定queryKey 的数据,其实就是等同于请求回来的接口数据放在 state 里面了,使用setQueryData来 更新接口数据同时更新视图。

其中快速查找就使用到了我们之前存储的 Map 数据,直接 getConversation 一下快速查询到当前会话列表是否已存在,无需重头遍历。如果存在并且当前正在打开的会话,不需要新增未读消息的小红点数量。

// 收到推送的消息更新会话列表
  const updateConversation = (data: string) => {
    const conversationSSEData: Conversation = JSON.parse(data);

    const { conversationId } = conversationSSEData;
    if (!conversationId) return;

    queryClient.setQueryData(
      ['conversations', projectId, customerType, retentionStatus],
      (oldData: any) => {
        if (!oldData?.pages?.length) return oldData;

        const existingConversation = getConversation(conversationId);
        const updatedPages = [...oldData.pages];

        if (existingConversation) {
          // 如果是当前打开的会话,不增加未读数
          const updatedConversation = {
            ...existingConversation,
            ...conversationSSEData,
            // 如果是当前选中的会话,保持未读数为0
            unReadCount:
              selectedConversation?.conversationId === conversationId
                ? 0
                : conversationSSEData.unReadCount || 0,
          };

          setConversationMap(conversationId, updatedConversation);

          updatedPages.forEach((page) => {
            const index = page.data.findIndex(
              (item: DouyinImManualConversation) =>
                item.conversationId === conversationSSEData.conversationId,
            );
            if (index !== -1) {
              page.data.splice(index, 1);
            }
          });

          // 找到第一个非星标会话的位置
          const firstNonStarredIndex = updatedPages[0].data.findIndex(
            (item: DouyinImManualConversation) => item.starStatus !== 1,
          );

          // 如果找到非星标会话,将会话插入到该位置,否则插入到列表最前面
          if (firstNonStarredIndex !== -1) {
            updatedPages[0].data.splice(firstNonStarredIndex, 0, updatedConversation);
          } else {
            updatedPages[0].data.unshift(updatedConversation);
          }
        } else {
          const newConversation = {
            conversationId: conversationSSEData.conversationId,
            ...conversationSSEData,
            // 如果是当前选中的会话,保持未读数为0
            unReadCount:
              selectedConversation?.conversationId === conversationId
                ? 0
                : conversationSSEData.unReadCount || 0,
          };

          setConversationMap(conversationId, newConversation);

          // 找到第一个非星标会话的位置
          const firstNonStarredIndex = updatedPages[0].data.findIndex(
            (item: DouyinImManualConversation) => item.starStatus !== 1,
          );

          // 如果找到非星标会话,将会话插入到该位置,否则插入到列表最前面
          if (firstNonStarredIndex !== -1) {
            updatedPages[0].data.splice(firstNonStarredIndex, 0, newConversation);
          } else {
            updatedPages[0].data.unshift(newConversation);
          }
        }

        return {
          ...oldData,
          pages: updatedPages,
        };
      },
    );
  };

// 处理 SSE 消息更新
useEffect(() => {
    if (!conversationData) return;
    try {
      updateConversation(conversationData);
    } catch (error) {
      console.error('处理 SSE 消息失败:', error);
    }
}, [conversationData]);

会话列表搜索联系人功能

观察常见的 IM 软件例如 飞书、微信,我们会发现他们的搜索功能都是单独弹出来的 Popover 或者 Modal。

image.png image.png

原因自然是避免搜的过程中消息会话列表数据根据搜索关键词来回变动,而且也能解决要搜索的联系人不在当前滚动加载拉取到的范围内的问题。因为在单独的一个弹窗中,我们可以查询不分页的接口,毕竟是精确搜索,数据量不会很庞大。

IM 软件查询到某个用户或者消息后,点击确认,发现都是直接插入到列表第一个的位置,我们模仿实现即可。

搜索后确认插入逻辑。

const handleSearchSelect = (conversation: DouyinImManualConversation) => {
    setSelectedConversation(conversation);
    setVisible(false);

    // 将选中的会话移动到列表最前面
    if (conversation.conversationId && projectId) {
      queryClient.setQueryData(
        ['conversations', projectId, customerType, retentionStatus],
        (oldData: any) => {
          if (!oldData?.pages?.length) return oldData;

          const updatedPages = [...oldData.pages];

          // 在所有页面中查找并移除该会话
          let foundConversation: DouyinImManualConversation | undefined;
          updatedPages.forEach((page) => {
            const index = page.data.findIndex(
              (item: DouyinImManualConversation) =>
                item.conversationId === conversation.conversationId,
            );
            if (index !== -1) {
              foundConversation = page.data.splice(index, 1)[0];
            }
          });

          // 如果在现有列表中找到了会话,使用它,否则使用传入的会话
          const conversationToAdd = foundConversation || conversation;

          // 找到第一个非星标会话的位置
          const firstNonStarredIndex = updatedPages[0].data.findIndex(
            (item: DouyinImManualConversation) => item.starStatus !== 1,
          );

          // 如果找到非星标会话,将会话插入到该位置,否则插入到列表最前面
          if (firstNonStarredIndex !== -1) {
            updatedPages[0].data.splice(firstNonStarredIndex, 0, conversationToAdd);
          } else {
            updatedPages[0].data.unshift(conversationToAdd);
          }

          // 更新会话映射
          if (conversation.conversationId) {
            setConversationMap(conversation.conversationId, conversationToAdd);
          }

          return {
            ...oldData,
            pages: updatedPages,
          };
        },
      );

      // 请求接口标记消息为已读
    }
  };

查询插入成功后要考虑到一个问题,我要是插入的这个会话,是我后面几页还没滚动加载到的数据怎么办,不应该同时出现两个相同的会话,所以我们要在滚动加载分页的接口拿回来数据做去重处理。

我们在 useInfiniteQueryselect功能内对获取到的数据做处理,此选项可用于转换或选择查询函数返回的数据的一部分。它会影响返回的数据值,但不会影响查询缓存中存储的内容。

 const {
    // 其他代码
  } = useInfiniteQuery({
    // 其他代码
    select: (data) => {
      const updatedPages = data.pages.map((page) => {
        const updatedData =
          page?.data?.map((conversation: DouyinImManualConversation) => {
            if (!conversation.conversationId) return conversation;

            const existingConversation = getConversation(conversation.conversationId);

            // 如果已存在会话,合并现有数据和新数据,优先使用接口返回的未读数量
            if (existingConversation) {
              const mergedConversation = {
                ...existingConversation,
                ...conversation,
              };
              return mergedConversation;
            }

            return conversation;
          }) || [];

        return {
          ...page,
          data: updatedData,
        };
      });

      return {
        pages: updatedPages,
        pageParams: data.pageParams,
      };
    },
  });

还有handleSelectConversation就是点击会话列表选中某个会话这里就不贴代码了,setSelectedConversation更新一下会话信息,然后再记得queryClient.setQueryData本地清除一下小红点。

这样子我们就在不用频繁拉取接口的情况下完成了本地数据的变更,左侧会话列表需要注意的小细节大致就是这些了。接下来我们来看聊天记录界面的实现。

聊天记录及输入框

image.png

聊天记录的数据也需要滚动加载,但这里使用的是 css 小技巧来实现向上滚动等同于向下滚动的操作。接口数据是最新的在数组最靠前。

使用 flex-direction: column-reverse 将聊天历史记录翻转,最新的对话就靠在最下面,然后向上滑动就等同于平时的向下滑动了,同样是再使用useInfiniteQuery即可。

其中 <div className="block flex-grow flex-shrink" />用来在聊天记录只有一两条的时候将底部空白区域撑起来,不然效果就是看到顶部区域一大片的空白。

image.png

messagesEndRef这个 Ref 的作用是在发送消息后将消息视口滚动到最底部messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })

<div className="flex overflow-y-auto flex-col-reverse flex-1 p-4 no-scrollbar">
        {/* 用于自动滚动到底部的参考元素 */}
        <div ref={messagesEndRef} />

        {/* 消息列表 */}
        <div className="flex flex-col-reverse flex-1 gap-4">
          {/* 空白占位元素,用于消息少时将消息撑到底部 */}
          <div className="block flex-grow flex-shrink" />

          {messages.map((message) => (
            <div key={`${message.serverMessageId}-${nanoid()}`} className="group">
              {message.direction === 1 ? (
                <Bubble
                    content={renderMessageInChat(message)}
                    placement="start"
                    avatar={
                      <Avatar
                        src={selectedUser.customerInfo?.avatar || <img src={defaultAvatar} />}
                      />
                    }
                  />
              ) : (
                <Bubble
                  content={renderMessageInChat(message)}
                  placement="end"
                  avatar={<Avatar src={<img src={defaultAvatar} />} />}
                />
              )}
            </div>
          ))}
        </div>

        {/* 添加没有更多消息的提示 */}
        {!hasNextPage && messages.length > 0 && (
          <div className="flex justify-center py-2 text-sm text-gray-400">没有更多消息了</div>
        )}

        {/* 加载状态 */}
        {isFetchingNextPage && (
          <div className="flex justify-center py-2">
            <Spin size="small" />
          </div>
        )}

        {/* 加载更多的参考点 */}
        <div ref={loadMoreRef} className="w-full h-1" />

        {/* 无消息提示 */}
        {!isLoading && messages.length === 0 && (
          <div className="flex justify-center items-center h-full text-gray-400">暂无聊天记录</div>
        )}
      </div>

接受新聊天内容实时消息推送

我本地发出数据是立即上屏的,不会等接口响应成功再更新 UI,那样子太慢了。所以我有立即上屏的逻辑,收到推送回来的自己的消息暂时就先丢掉,反正下一次进入查看记录界面是拉取的接口获取。

const handleSendMessage = async ({
    content,
    messageType,
    sendType,
    extraParams = {},
  }: SendMessageParams) => {
    if (!projectId || !selectedUser?.conversationId) return;

    // 先创建本地消息对象
    const localMessage: ChatMessageType = {
      serverMessageId: `local-${Date.now()}`,
      conversationId: selectedUser.conversationId,
      content,
      messageType,
      messageEvent: 0,
      direction: 2,
      messageCreateTime: new Date().toISOString(),
    };

    // 立即更新本地消息列表
    setRealtimeMessages((prev) => [localMessage, ...prev]);

    // 确保在状态更新后执行滚动
    Promise.resolve().then(() => {
      chatMessagesRef.current?.scrollToBottom();
    });

    try {
      // 发送消息到服务器代码
    } catch (error) {
      message.error('发送失败');
      // 从消息列表中移除失败的本地消息
      setRealtimeMessages((prev) =>
        prev.filter((msg) => msg.serverMessageId !== localMessage.serverMessageId),
      );
    }
  };

由于上面的立即上屏逻辑,因此收到自定义 SSE 事件为chatMessage(聊天框里的新数据)的时候,这里我要丢弃掉本地使用这个聊天界面的人发出去的消息,只对用户向我发送的消息做处理。

 const updateMessage = (data: string) => {
      try {
        const messageSSEData = JSON.parse(data) as ChatMessage;
        if (
          selectedUser?.conversationId === messageSSEData.conversationId &&
          messageSSEData.serverMessageId !== lastSendMessageId
        ) {
          // 创建新消息对象
          const newMessage: ChatMessageType = {
            ...messageSSEData,
            serverMessageId: messageSSEData.serverMessageId || `sse-${Date.now()}`,
            direction: messageSSEData.messageEvent === 0 ? 2 : 1,
          };

          // 添加到实时消息列表
          setRealtimeMessages((prev: ChatMessageType[]) => [newMessage, ...prev]);

          // 滚动到底部
          setTimeout(() => {
            messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
          }, 100);

          // 通知后端消息已读
          
        }
      } catch (error) {
        console.error('处理会话消息失败:', error, '原始数据:', data);
      }
    };

输入框正确处理输入法合成事件

这个场景是指,用户在使用输入法的时候直接按下 Enter 回车键会直接触发我们定义的回车相关逻辑,但用户可能只是想把输入的内容打到输入框内,并不想发送,浏览器贴心的为我们提供了CompositionEvent事件的监听。

developer.mozilla.org/zh-CN/docs/…

然后 shift+Enter 是默认换行行为,但 Alt+Enter 没换行成功我就只能用代码插入换行符了。

const textareaRef = useRef<HTMLTextAreaElement>(null);
// 添加输入法组合状态 中文输入法按下 enter 会触发发送
const [isComposing, setIsComposing] = useState(false);

 const handleKeyDown = (
    e: React.KeyboardEvent<HTMLTextAreaElement>,
    sendStatus?: boolean,
    messageInfo?: string,
  ) => {
    // 如果正在输入法组合输入中,不处理任何按键事件
    if (isComposing) {
      return;
    }

    if (e.key === 'Enter') {
      // Shift + Enter 或 Alt + Enter 换行
      if (e.altKey || e.shiftKey) {
        e.preventDefault(); // 阻止默认行为
        const textarea = e.currentTarget;
        const start = textarea.selectionStart;
        const end = textarea.selectionEnd;

        // 在光标位置插入换行符
        setText((prevText) => prevText.substring(0, start) + '\n' + prevText.substring(end));

        // 使用 requestAnimationFrame 确保在下一帧设置光标位置
        requestAnimationFrame(() => {
          textarea.selectionStart = textarea.selectionEnd = start + 1;
        });
        return;
      }
      // 普通 Enter 发送消息
      e.preventDefault();
      if (sendStatus) {
        handleSend();
      } else {
        message.error('发送失败: ' + messageInfo);
      }
    }
  };

<textarea
    className={cn(
      'pt-1 pb-3 w-full rounded-lg border-none resize-none focus:outline-none',
    )}
    rows={7}
    placeholder='回复内容,按Enter发送,按Shift+Enter或Alt+Enter换行'
    value={text}
    onChange={handleTextChange}
    onKeyDown={(e) => handleKeyDown(e, validData?.sendStatus, validData?.message)}
    onCompositionStart={() => setIsComposing(true)}
    onCompositionEnd={() => setIsComposing(false)}
    autoFocus
    ref={textareaRef}
 />

结语

要分享的主要就是这些内容,希望对大家有帮助,剩下的就是一些输入框上的工具栏之类的业务相关的代码了,没有太多值得分享的价值,就先略过了。

还有一些我在做的过程中发现的 IM 开源聊天仓库也很不错。