公司專案需求需要有一個類似 Line 聊天室功能,包含關鍵字查詢、公告、回覆訊息、@ 人員、群組等核心功能,其他需求就相較普遍的 CRUD 功能,這篇就不另外敘述。
- 開發工具:React、React Router 7
- 訊息處理工具:@microsoft/signalr、linkifyjs、react-mentions-ts
- 資料處理:@tanstack/react-query、react-hook-form、zustand、zod
當提到聊天室功能,第一個想到的就是使用 WebSocket 與後端介接;但如果從零實作,會遇到斷線、重連等不少細節。因此我選擇使用 .NET SignalR,並透過用戶端的 withAutomaticReconnect 設定重連策略。
SignalR 優先使用 WebSocket 進行連線,若瀏覽器不支援,則會退回使用 SSE 或 Long Polling。這讓我能將時間放在聊天室的事件設計與使用者體驗,而不是自行處理所有連線底層機制。
訊息處理
前端使用 SignalR 監聽後端事件時,方法名稱必須一致。因此前後端需要先制定一套事件名稱規範,讓發送、接收與錯誤處理有一致的對應關係。

/**
* SignalR Hub 事件名稱
*/
export default {
MESSAGE_SENT: 'SendMessage',
MESSAGE_EDITED: 'MessageEdited',
MESSAGE_DELETED: 'MessageDeleted',
MESSAGE_SENT_ERROR: 'SendMessageError',
MESSAGE_EDITED_ERROR: 'MessageEditedError',
MESSAGE_DELETED_ERROR: 'MessageDeletedError',
};下方先在前端寫好對應發送、監聽事件的方法,可以看到 invoke 其實也是去呼叫後端對應方法。
import { useEffect, useRef } from 'react';
import {
HubConnectionBuilder,
LogLevel,
} from '@microsoft/signalr';
import HUB_EVENTS from './signalREvents';
export default function useSignalR() {
const connectionRef = useRef(null);
/**
* 發送訊息
*/
const sendMessage = async (message) => {
await connectionRef.current.invoke(HUB_EVENTS.MESSAGE_SENT, message);
};
useEffect(() => {
const connection = new HubConnectionBuilder()
.withUrl(`${import.meta.env.VITE_API_ROUTE}/chatHub`, {
accessTokenFactory: () => 'token:xxxxx',
})
.withAutomaticReconnect([0, 2000, 5000, 10000])
.configureLogging(LogLevel.Warning)
.build();
connectionRef.current = connection;
// 接收新訊息
connection.on(HUB_EVENTS.MESSAGE_SENT_ERROR, (message) => {
console.log('來自新訊息', message);
});
let isDisposed = false;
let retryTimer;
/**
* 建立 SignalR 初始連線,失敗時在元件仍掛載期間重試。
*
* @returns 完成一次連線嘗試後結束。
*/
async function startConnection() {
try {
await connection.start();
} catch (error) {
console.error('SignalR 連線失敗', error);
if (!isDisposed) {
retryTimer = window.setTimeout(startConnection, 5000);
}
}
}
void startConnection();
// 卸載停止連線
return () => {
isDisposed = true;
window.clearTimeout(retryTimer);
void connection.stop();
};
}, []);
return { sendMessage };
}可以看到下方後端也定義了對應的方法名稱,待前端確定連線後就可以開始 invoke 此方法進行訊息傳輸。
/// <summary>
/// 發送訊息
/// </summary>
/// <param name="messageData"></param>
/// <returns></returns>
public async Task SendMessage(MessageModel messageData)
{
var isSuccess = false;
if (isSuccess)
{
// 寫入資料庫
}
else
{
await Clients.Caller.SendAsync("SendMessageError", "發送失敗");
}
}接著可以從瀏覽器 Network 面板查看連線內容所發送的訊息。

@ 人員功能
本來有看到一位作者的 signavio/react-mentions 專案,就是在做這類功能,但缺乏長期維護以及對 TypeScript 型別衍生支援問題,因此該套件已停止更新。不過多虧開源社群,另一位開發者 Fork 該專案後,針對型別以及現代化工具進行重構,最終使用 react-mentions-ts。
使用起來很簡單,只需定義好觸發條件以及 @ 文字的規則就能開始使用。
import { MentionsInput, Mention } from 'react-mentions-ts';
const data = [
{ id: 1, userName: 'testAd01' },
{ id: 2, userName: 'testAd02' },
{ id: 3, userName: '上祐' },
];
function MentionsTextArea({ onChange }) {
return (
<MentionsInput
onMentionsChange={({ value, mentions }) => onChange(value, mentions)}
>
<Mention
trigger="@"
markup="@[__display__](user:__id__)"
data={data}
appendSpaceOnAdd
className="rounded bg-blue-100 px-0.5"
/>
</MentionsInput>
);
}
解析訊息關鍵字
開發時發現某些訊息的特定欄位需要特別處理,譬如網址、Gmail 等關鍵字。但 react-mentions-ts 提供的 parseMentionsMarkup 還不足以達到需求,因為這個 parser 只涵蓋到 @ 人員的關鍵字。
因此我使用 LinkifyJS,專門將網址、Gmail 等文字轉成 <a> 標籤。

群組聊天
看似麻煩,實際流程是後端在建立連線時,先把使用者加入到指定群組後,再用相同方式發送訊息給前端。
/**
* 發送群組訊息
*/
const sendMessage = async (groupId, message) => {
await connection.invoke(HUB_EVENTS.MESSAGE_SENT, groupId, message);
};/// <summary>
/// 初始連線
/// </summary>
/// <returns></returns>
public override async Task OnConnectedAsync()
{
try
{
var userGroups = await _groupService.getUserGroupList(userId);
}
catch (Exception error)
{
// 錯誤處理
}
}
/// <summary>
/// 發送群組訊息
/// </summary>
/// <param name="groupId"></param>
/// <param name="messageData"></param>
/// <returns></returns>
public async Task SendMessage(string groupId, MessageModel messageData)
{
var isSuccess = false;
if (isSuccess)
{
// 寫入資料庫
}
else
{
await Clients.Group(groupId).SendAsync("SendMessageError", "發送失敗");
}
}注意事項
-
前端的
connection.on監聽必須要在呼叫start()連線前就先定義好。useSignalR.js const connection = new HubConnectionBuilder() .withUrl(`${import.meta.env.VITE_API_ROUTE}/chatHub`, { accessTokenFactory: () => 'token:xxxxx', }) .withAutomaticReconnect([0, 2000, 5000, 10000]) .configureLogging(LogLevel.Warning) .build(); // 接收新訊息,需要在 start 前先準備好 connection.on(HUB_EVENTS.MESSAGE_SENT_ERROR, (message) => { console.log('來自新訊息', message); }); connection.start(); -
前端
invoke方法送入的欄位,也要與後端方法的參數順序對應。useSignalR.js connection.invoke(HUB_EVENTS.MESSAGE_SENT, groupId, message);SignalRCore.cs public async Task SendMessage(string groupId, MessageModel messageData)