如何在 Next.js 中快速集成 @microsoft/signalr

如何在 Next.js 中快速集成 @microsoft/signalr

虽然只有少数项目需要集成 WebSockets 来在界面发生变化时实时响应而不重新获取数据,但这些项目仍然数量庞大。

这是一项必不可少的工作,我们不会讨论它们或者比较提供更好开发体验的第三方库。

我的目标是展示如何快速集成 @microsoft/signalr 到 Next.js 中,以及在开发过程中遇到的问题如何解决。

首先,我希望每个人都已经在本地安装和部署了 Next.js 项目。在我的案例中,版本是 13.2.4。让我们添加一些重要的库:swr(版本 2.1.5)用于数据获取以及与本地缓存进一步工作,以及 @microsoft/signalr(版本 7.0.5) - 用于 WebSockets 的 API。

npm install --save @microsoft/signalr swr

让我们从创建一个简单的 fetcher 函数和一个名为 useChatData 的新 hook 开始,以从我们的 REST API 获取初始数据。它返回了聊天消息列表、检测错误和加载状态的字段,以及允许更改缓存数据的 mutate 方法。

// hooks/useChatData.ts
import useSWR from 'swr';

type Message = {
    content: string;
    createdAt: Date;
    id: string;
};

async function fetcher<TResponse>(url: string, config: RequestInit): Promise<TResponse> {
    const response = await fetch(url, config);
    if (!response.ok) {
        throw response;
    }
    return await response.json();
}

export const useChatData = () => {
    const { data, error, isLoading, mutate } = useSWR<Message[]>('OUR_API_URL', fetcher);
    return {
        data: data || [],
        isLoading,
        isError: error,
        mutate,
    };
};

为了测试它是否按预期工作,让我们更新我们的页面组件。在顶部导入我们的 hook,并像下面的代码片段中那样从中提取数据。如果它工作正常,你将看到渲染的数据。如你所见,这很简单。

// pages/chat.ts
import { useChatData } from 'hooks/useChatData';

const Chat: NextPage = () => {
    const { data } = useChatData();

    return (
        <div>
            {data.map(item => (
                <div key={item.id}>{item.content}</div>
            ))}
        </div>
    );
};

下一步需要连接我们未来的页面到 WebSockets,捕获 NewMessage 事件,并使用新消息更新缓存。我建议从一个单独的文件中构建 socket 服务。

根据 SignalR 文档中的示例,我们需要创建一个连接实例以后续监听事件。我还添加了一个 connections 对象,以防止重复连接,以及两个用于启动/停止连接的帮助函数。

// api/socket.ts
import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr';

let connections = {} as { [key: string]: { type: string; connection: HubConnection; started: boolean } };

function createConnection(messageType: string) {
    const connectionObj = connections[messageType];
    if (!connectionObj) {
        console.log('SOCKET: Registering on server events ', messageType);
        const connection = new HubConnectionBuilder()
            .withUrl('API_URL', {
                logger: LogLevel.Information,
                withCredentials: false,
            })
            .withAutomaticReconnect()
            .build();

        connections[messageType] = {
            type: messageType,
            connection: connection,
            started: false,
        };
        return connection;
    } else {
        return connections[messageType].connection;
    }
}

function startConnection(messageType: string) {
    const connectionObj = connections[messageType];
    if (!connectionObj.started) {
        connectionObj.connection.start().catch(err => console.error('SOCKET: ', err.toString()));
        connectionObj.started = true;
    }
}

function stopConnection(messageType: string) {
    const connectionObj = connections[messageType];
    if (connectionObj) {
        console.log('SOCKET: Stoping connection ', messageType);
        connectionObj.connection.stop();
        connectionObj.started = false;
    }
}

function registerOnServerEvents(
    messageType: string,
    callback: (payload: Message) => void,
) {
    try {
        const connection = createConnection(messageType);
        connection.on('NewIncomingMessage', (payload: Message) => {
            callback(payload);
        });
        connection.onclose(() => stopConnection(messageType));
        startConnection(messageType);
    } catch (error) {
        console.error('SOCKET: ', error);
    }
}

export const socketService = {
    registerOnServerEvents,
    stopConnection,
};

因此,现在我们的页面可能如下所示。我们获取并提取了包含消息列表的 data,并进行了渲染。此外,上面的 useEffect 注册了 NewMessage 事件,创建了连接,并监听后端。

当事件触发时,hook 中的 mutate 方法会使用新对象更新现有列表。

// pages/chat.ts
import { useChatData } from 'hooks/useChatData';
import { socketService } from 'api/socket';

const Chat: NextPage = () => {
    const { data } = useChatData();

    useEffect(() => {
        socketService.registerOnServerEvents(
            'NewMessage',
            (payload: Message) => {
                mutate(() => [...data, payload], { revalidate: false });
            }
        );
    }, [data]);

    useEffect(() => {
        return () => {
            socketService.stopConnection('NewMessage');
        };
    }, []);

    return (
        <div>
            {data.map(item => (
                <div key={item.id}>{item.content}</div>
            ))}
        </div>
    );
};

看起来不错,它可以工作,我们可以看到新消息如何出现在消息列表中。我选择了一个聊天的基本示例,因为它非常清晰且易于理解

。当然,你可以根据自己的逻辑应用它。

小额奖励

使用其中一个版本的 @microsoft/signalr,我们遇到了重复的问题。这与 useEffect 中的依赖数组有关。每次依赖项更改时,connection.on(event, callback); 都会缓存回调并一次又一次地触发它。

useEffect(() => {
    // data 默认为空数组(registerOnServerEvents 第1次运行),
    // 但在初始数据获取后它会变化(registerOnServerEvents 第2次运行)
    // 每个事件都会更改数据并触发 registerOnServerEvents 的运行
    socketService.registerOnServerEvents(
        'NewMessage',
        // 回调函数被缓存
        (payload: Message) => {
            // 每次数据更改都会多次调用 mutate
            mutate(() => [...data, payload], { revalidate: false });
        }
    );
}, [data]);
// 在收到3条消息事件后,我们竟然渲染了4条消息,哈哈

我们找到的最快最可靠的解决方案是在 React ref 中保留数据的副本,并在 useEffect 中使用它进行未来的更新。

// pages/chat.ts
import { useChatData } from 'hooks/useChatData';
import { socketService } from 'api/socket';

const Chat: NextPage = () => {
    const { data } = useChatData();
    const messagesRef = useRef<Message[]>([]);

    useEffect(() => {
        messagesRef.current = chatData;
    }, [chatData]);

    useEffect(() => {
        socketService.registerOnServerEvents(
            'NewMessage',
            (payload: Message) => {
                const messagesCopy = messagesRef.current.slice();
                mutate(() => [...messagesCopy, payload], { revalidate: false });
            }
        );
    }, [data]);

    useEffect(() => {
        return () => {
            socketService.stopConnection('NewMessage');
        };
    }, []);

    return (
        <div>
            {data.map(item => (
                <div key={item.id}>{item.content}</div>
            ))}
        </div>
    );
};

目前,我们使用了 @microsoft/signalr 的新版本,它似乎已经修复了必要的问题。但无论如何,如果有人发现这个解决方案有用,并使用了这个解决办法,我会很高兴。总之,我想说,我对 SignalR 的经验非常积极,安装不需要任何特定的依赖项或设置,它运行良好且满足我们的需求。


请注意,在文档中有一些 `OUR_API_URL`、`API_URL` 等需要替换为实际的 API 地址的占位符。此外,如果需要更多的翻译或有任何其他问题,请随时提出。