使用Supabase和React构建实时协作白板
2024 年 5 月 18 日

你们有没有尝试过使用 Figma 或 Miro?如果你用过的话,我相信你一定见过这样一个功能,突然之间你会在你的 Figma 文件或 Miro 白板上看到多个光标。如果你还没有见过,这里是一个快速视频展示我在说什么。

Figma 的实时体验

Miro 的实时体验

使用 Supabase 和 React 构建实时协作白板

在这篇文章中,我们将探讨如何使用 Supabase 和 React 构建一个实时协作白板。这个项目将使多个用户能够同时绘制、写字和编辑白板上的内容,实现类似 Figma 和 Miro 的实时协作体验。让我们开始吧! Miro实时体验

看起来多么时尚。我的意思是,看看它,看起来很酷。你能想象他们是如何构建这个功能的吗?为了提供如此出色的动态用户体验,他们付出了什么代价?再见屏幕共享,只需跟随那个人的光标,听听会议中的音频,你就可以开始了。

我真的很喜欢这个功能。所以我着手研究这个功能,想弄清楚构建这样一个功能需要什么。老实说,要理解这一点真的很痛苦,但后来事情变得容易得多。让我们深入了解和实施这个项目。

是什么? #

我们正在尝试构建一个便利贴板应用程序,我们能够看到多个光标。 这个项目使用实时技术和便签来显示其他用户在同一网站上正在做什么。在这个项目中,每个用户都可以获取其他用户的信息,比如他们的光标移动到哪里,他们在输入什么,他们正在拖动哪个便签等。

为了让大家了解,这里是展示应用程序运行情况的视频:

实时应用程序运行情况

先决条件 #

为了充分利用本博文,我强烈建议大家熟悉以下主题:

为什么要这样做? #

我知道,这个问题可能会出现在你们脑海中,为什么我们要这样做。我们之所以这样做是因为:

  • 为了了解创新UX是如何运作的。
  • 了解一些关键的UX决策

一些背景故事 在我们深入实施之前,我想分享一些关于这个项目的事情(我的个人经历)。在这个项目中,我学到了很多关于webRTC的知识。我从零开始学习了webRTC (opens new window),比如什么是webRTC?连接是如何形成的?数据传输是如何进行的? #

我选择webRTC是因为我认为每个客户端都需要了解网络/房间中连接的其他每个客户端的信息。所以这不就和视频会议应用程序如Microsoft Teams,Zoom,Google Meet等一样吗?在这些应用程序中,每个客户端都知道其他每个客户端,并且他们可以直接通过webRTC共享数据流(这是我当时的想法)。 但事实证明,情况并不那么简单,这将创建一种网状网络,并且在网络上需要大量资源。因此,有更好的协议/模式来处理这种情况,比如SFU(选择性转发单元) (opens new window)

我可能有点跳跃了,但你们明白我的意思吧。所以我认为这只是一个简单的项目。与其让每个客户端之间共享多个视频流,我只需要用其他每个客户端的光标位置替换它。这有多难呢?实际上非常困难。

我试图首先实现WebRTC完全网格模式,以了解每个客户端如何与其他客户端共享其流。但是当超过2个客户端加入时,管理连接即在webrtc中进行报价谈判 (opens new window)变得非常棘手,因此我放弃了这个想法。

因此,我转而转向了Supabase的实时数据库的功能。我实际上使用了它。 在这个项目中。是的,这就是背景故事,但让我们继续。

这是我们理解整个项目实现过程的部分。我使用了以下技术栈:

  • 前端 - React.js 和 Vite
  • 后端 - Supabase

在进入前端之前,让我们了解一下后端是什么,以及它如何帮助我们实现我们的结果。

💡 **注意:**这篇博客文章不会是一个一步一步的指南,而是一个对代码库的遍览,以及对概念/架构的详细解释。

进入 Supabase #

Supabase (opens new window) 是一个后端即服务的可视化平台,允许您使用最少的代码创建 postgres 数据库。他们的文档非常不错,让人感觉像家一样,您可以在很短的时间内将项目上线。

这个很酷的低代码平台提供了一个实时功能 (opens new window),让您可以做一堆事情: 听取有关数据库插入、更新和其他更改的更新

在这三者中,我们将使用Presence API。

需求 #

让我们看看构建我们的便利贴板应用程序所需的内容:

  • 我们需要一种实时光标感觉,即我的屏幕还应该显示其他人移动光标的位置
  • 我们还需要一种让我的屏幕知道其他人在做什么的方式,比如,添加便条和移动便条
  • 如果有人关闭他们的浏览器窗口,那么我的屏幕不应包含离开的人的更改。

所有这些需求可以通过Supabase的Presence API (opens new window)来实现。这是实现我们目标的完美配方。

在我们开始深入了解这个API之前,我们 首先需要了解什么是频道。

  • 频道:
    • 这是实时功能的基本构建块。
    • 频道是所有客户端连接的地方。可以想象频道就像一个房间,所有人都可以加入进来。
    • 每当一个新的客户端进入时,我们需要确保它连接到这个房间/频道。
    • 您可以在此处找到有关此内容的详细信息。

现在让我们了解一下存在 API。这是一个可以帮助您与所有其他客户端共享当前客户端状态的 API。它通过 track 函数来实现。因此,典型的 track 函数调用如下所示:

const channel = client.channel('room1'); // 创建一个名为 'room1' 的频道

channel.track({
    clientId: '123',
    color: 'red'
})

每个频道都有一个共同的状态池供所有客户端使用。现在如果一个用户突然关闭了他的选项卡。 e. 如果客户端从频道中移除,则此状态也会被更新。我们将在本博文的后面部分详细介绍 presence API 的其他事件,如 joinleavesync

现在我们了解了 presence API,让我们开始了解项目架构。

项目设置 #

在这里稍作偏离话题,我想谈谈前端工具/项目环境。从前端的角度来看,我正在使用 Vite 来创建项目脚手架。我使用了 Vite 的 typescript 模板,因为这个项目是用 typescript 构建的。

yarn create vite --template react-ts

使用上述命令创建一个带有基于 react 的 typescript 模板的 Vite (opens new window) 项目。

从后端的角度来看,我正在使用 supabase 的本地设置。我不会在本博文中涵盖这部分,因为 supabase 的指南已经做得很好了。 感谢您很好地解释了这个链接处 (opens new window)

其他使用的库如下:

项目架构 #

我们已经从项目设置的角度做好了所有准备。现在让我们了解应用程序的流程:

  • 每当在浏览器上打开我们项目的新标签时,这意味着我们需要创建supabase的新客户端实例。因此,我们首先创建一个客户端。
  • 接下来,我们创建一个通道,以便客户端可以添加到该通道。
  • 我们监听在syncleave事件的出现API。
  • 当用户活动更新时,我们需要将这些数据传递给所有其他客户端。
  • 如果浏览器窗口关闭,我们需要做的是... 为了确保所有客户端都不包含与已删除客户端相关的数据。

项目架构

获得了应用程序流程的知识后,我们就可以了解实现细节。正如我之前提到的,这不会是一篇逐步代码漫谈,而是一篇深入理解的博文。

现在让我们熟悉一下存储库结构:

有3个文件我们需要仔细查看:

  • App.tsx - 整个逻辑驻留的驱动程序
  • component/Cursor.tsx - 一个SVG光标 组件以显示其他客户端的指针
  • component/StickyNote.tsx - 一个可编辑的便利贴组件。

我们将逐个查看每个文件,以了解应用程序的流程:

**注意:**强烈建议您在阅读每个文件结构的详细信息时打开每个文件。这样您就不会迷失方向。

App.tsx #

在阅读下面的部分时,请参考这个组件 (opens new window)

  • 当浏览器标签打开我们项目的URL时,这个文件将立即加载。因此,根据流程,我们应该实例化一个 supabase 客户端,方法如下:

    const clientA = createClient(SUPABASE_URL, SUPABASE_KEY);
    
    
  • 一旦客户端被加载 在实例化后,我们期望它应该创建一个如下的通道:

const channel = clientA.channel("room-1");
  • 对于一个新的客户端,它会检查room-1通道是否存在。如果存在,它将加入该通道,否则将创建一个新的通道。
  • 在我们主要应用的挂载过程中,我们期望客户端订阅到存在API的syncleave事件。为此,我们首先创建事件处理程序来侦听这些事件,如下所示:
useEffect(() => {
    channel.on("presence", { event: "sync" }, () => {
        const newState = channel.presenceState<Clients>();

        // 用于管理一旦其他客户端更新后的状态
    }, []); 
useEffect(() => {
    channel.on<{ clientId: string }>(
        "presence",
        { event: "leave" },
        ({ leftPresences }) => {
            // 用于管理
``` 在添加这些事件处理程序后,我们可以按照以下方式进行订阅:

useEffect(() => { if (isFirstRender.current) { subsChannel.current = channel.subscribe(); isFirstRender.current = false; } }, []);


这个效果将在组件挂载时执行。我们还需要确保不会意外地多次调用`subscribe`方法,否则supabase将会抛出错误。这个问题不会在生产环境中发生,但在严格模式下,即开发模式下,这个效果会运行两次。这是React确保事情可预测的方式。

因此,为了避免这种情况,我添加了一个引用`isFirstRender`。当效果在严格模式下第二次运行时,它会被设置为false。

*  有一件事需要注意 这是我们在所有客户端之间同步的数据结构。它如下所示:

export enum EventTypes {
    MOVE_MOUSE = "move-mouse",
    MOVE_NOTE = "move-note",
    ADD_NOTE = "add-note",
    ADD_NOTE_TEXT = "add-note-text",
}

export type Note = {
    x: number;
    y: number;
    content: string;
};

type Payload = {
    eventType: EventTypes;
    x: number;
    y: number;
    color: string;
    notes: Array<Note>;
};

export type Clients = Record<string, Payload>;

这些是TypeScript类型,但它们转换为以下内容:

{ "W3lf6J4shQUfE-DTkILBb": { "color": "rgb(15%, 30%, 40%)", "eventType": "move-mouse", "x": 829, "y": 235, "notes": [ { "content": "客户端W3lf的笔记", "x": 202, "y": 350 } ] } } ```json }, "JRlR_3qHr7B2bad2HVvWE": { "color": "rgb(94%, 30%, 40%)", "eventType": "move-mouse", "x": 16, "y": 65, "notes": [ { "content": "This note from client JRIR", "x": 198, "y": 729 } ] } }


这是每个客户端将保留的典型状态。我们还告诉每个客户端保留所有其他客户端的状态,这发生在`newClients`状态变量中。

现在让我们了解每个客户端如何与所有其他客户端同步。每个客户端有3个更新自身状态的点:
    
* 当鼠标移动时,
* 当注释被添加时
* 当便签被移动时。
* 当便条被编辑时

无论发生这些情况中的哪一种,我们都会调用它们各自的事件处理程序。所以正如您所期望的那样,
    
* 对于鼠标移动,我们将使用 `onMouseMove` ,
    *   当添加注释时,我们使用屏幕上的` button` 的 `onClick`,
    *   对于便利贴的编辑,使用便利贴组件的 `onChange` 事件。
    *   最后,当备注被移动时,我们使用 StickyNote 组件的 `onMouseMove`。

    对于所有这些情况,它们通过 `channel.track` 函数更新其状态。这是一个存在 API 函数,将更新发送给连接到渠道的所有其他客户端。您可以在[这里](https://supabase.com/docs/guides/realtime/presence#sync-and-track-state)阅读有关此功能的更多信息。
    
现在,为了使我们的情景工作,我们使用了以下事件处理程序:

const handleMouseMove = (event: React.MouseEvent) => { throttledChannelTrack({ [CURRENT_CLIENT_ID]: { ...newClients[CURRENT_CLIENT_ID], eventType: EventTypes.MOVE_MOUSE, color: randomColor,

                    ...currentClient,
                    eventType: EventTypes.UPDATE_NOTE,
                    notes: [...notes],
                },
            });
        };

        const handleNoteDeletion = (noteIndex: number) => {
            const currentClient = newClients[CURRENT_CLIENT_ID];

            const notes = currentClient.notes;
            notes.splice(noteIndex, 1);

            throttledChannelTrack({
                [CURRENT_CLIENT_ID]: {
                    ...currentClient,
                    eventType: EventTypes.DELETE_NOTE,
                    notes: [...notes],
                },
            });
        };

        const handleClientChangeColor = (color: string) => {
            const currentClient = newClients[CURRENT_CLIENT_ID];

            throttledChannelTrack({
                [CURRENT_CLIENT_ID]: {
                    ...currentClient,
                    eventType: EventTypes.CHANGE_COLOR,
                    color,
                },
            });
        };

        const handleClientChangeName = (name: string) => {
            const currentClient = newClients[CURRENT_CLIENT_ID];

            throttledChannelTrack({
                [CURRENT_CLIENT_ID]: {
                    ...currentClient,
                    eventType: EventTypes.CHANGE_NAME,
                    name,
                },
            });
        };

        const handleClientChangeNoteColor = (noteColor: string) => {
            const currentClient = newClients[CCURRENT_CLIENT_ID];

            throttledChannelTrack({
                [CURRENT_CLIENT_ID]: {
                    ...currentClient,
                    eventType: EventTypes.CHANGE_NOTE_COLOR,
                    noteColor,
                },
            });
        };

        return {
            handleMouseClick,
            handleNoteAddition,
            handleNoteMouseMove,
            handleNoteDeletion,
            handleClientChangeColor,
            handleClientChangeName,
            handleClientChangeNoteColor,
        };
    }, [newClients, throttledChannelTrack, subsChannel]);

    return {
        track,
        ...callbacks,
    };
}; ```javascript
ID]: {
                    ...currentClient,
                    eventType: EventTypes.MOVE_NOTE,
                    notes,
                },
            });
        };

请注意throttledChannelTrack函数。它是一个节流函数,仅用于频繁发生的事件,例如鼠标移动和便利贴移动。对于像添加便条这样的情况,我们直接使用channel.track函数。

CURRENT_CLIENT_ID是由nanoid (opens new window)生成的随机唯一字符串ID,每当客户端在浏览器标签上打开时都会生成。

很好地观察到,每次调用track函数时,我们都会更新当前客户端的状态并保留其现有状态。

  • 一旦调用track函数,立即执行出席API的sync事件处理程序。这是API的一般工作方式,每当发生track调用时,就会执行sync事件处理程序。因此,如果现在C 如果当前客户端执行了上述任何情况,它将使用上述负载调用跟踪函数并执行sync事件处理程序。

在我们的情况下,当此事件处理程序执行时,我们需要确保执行以下操作:

  • 从通道中捕获presenceState。这是频道维护的一个状态,其中包含所有其他客户端进行的最新更新(存在事件)。
  • 遍历所有存在事件并使用所有这些存在事件更新newClients状态。
  • 我们在这里做的是,所有存在事件实际上就是来自所有其他客户端的每个客户端的状态。我们只是将所有其他客户端状态捕获到当前客户端中,并将其更新到一个名为newClients的状态变量中。
  • 通过这种方式,我们跟踪其他客户端:
useEffect(() => {
            channel.on("presence", { event: "sync" }, () => {
                const newState = c ```
hannel.presenceState<Clients>();

const presenceValues: Clients = {};

Object.keys(newState).forEach((stateId) => {
    const presenceValue = newState[stateId][0];
    const clientId = Object.keys(presenceValue)[0];

    presenceValues[clientId] = presenceValue[clientId];
});

setNewClients((preValue) => {
    const updatedClients = Object.keys(presenceValues).reduce<Clients>(
        (acc, curr) => {
            acc[curr] = {
                ...preValue[curr],
                ...presenceValues[curr],
            };
            return acc;
        },
        {}
    );

    return updatedClients;
});

在 在这里我想讨论的最后一件事是在客户端被移除时更新UI。我们不希望UI因为已经离开频道的客户的便签而变得混乱。观看这个视频以更清晰地理解我的意思:

  • 这是非常容易实现的。我们只需要为存在API的“离开”事件添加事件处理程序,并更新newClients状态变量。 这就是整个应用逻辑。让我们现在简要看一下 UI 组件。UI 组件非常简单直观。它们会接收一堆属性并以良好的方式显示出来。

Cursor.tsx #

这个组件会接收 xy 坐标以及客户端的名字,然后以良好的方式显示出来。 nelly welly way. Have a look at the below cursor image:

Custom Cursor

  • A quick thing to note about this component is that it will render all cursors except for the current client/browser window. This UI decision was made to avoid displaying another pointer apart from the actual one that is visible.

StickyNote.tsx

Refer to this component while reading this component. 这个组件会渲染存储在newClients状态变量中的所有便签。它接收当前便签的xy坐标以及其中的内容。

  • 这个便签的一个重要特点是,即使对于当前客户,我们也显示它的便签。这是有道理的,因为我们想知道并看到我们添加了一个便签,它应该出现在屏幕上,因此做出了这个决定。
  • 这个组件为整个流程增添了一些亮点。它的作用是,会将其他客户的便签在您的屏幕上突出显示。您当前的便签不会被突出显示。这样,我们清楚地区分出每个便签属于谁。看一下这个小视频/gif: 粘性笔记组件
  • 您甚至可以看到,光标的颜色与粘性笔记的高亮颜色匹配,适用于当前客户之外的客户。

总结

这个项目给了我很多启示。以下是其中一些:

  • 使用回调函数与设置器一起更新状态变量,有效地避免了由将状态更新放在由依赖项更改触发的useEffect钩子中而导致的无限渲染。

  • 避免使用props初始化状态变量,以防止在后续属性更改时未反映。相反,当props更改时更新状态以确保准确的数据表示。

总的来说,在这篇博客中,我们看到了:

  • 为什么我们要做这个项目
  • 了解背景故事
  • 项目架构
  • Presence API
  • UI组件:光标和便利贴 隐藏光标并突出粘性笔记,以提供更好的用户体验。

该项目的完整代码库可以在这里 (opens new window)找到。

感谢阅读!

请在 twitter (opens new window)github (opens new window),以及 linkedIn (opens new window) 上关注我。