为了账号安全,请及时绑定邮箱和手机立即绑定

如何用Next.js和Liveblocks构建一个协作编辑器

协作应用现在已成为现代软件中的必备元素,允许多人可以同时在同一文档、设计或代码库上进行协作。想想 Google 文档、Figma 或多人编程平台这样的工具,它们之所以强大,在于提供了实时协作功能,确保每个人的改动都能即时同步给其他参与者。

在这篇指南里,我教你如何使用 Next.jsLiveblocks (一个简单易用的实时协作库)构建一个协同文本编辑器。到了最后,你将拥有一个让多个用户可以编辑同一份文档、实时看到对方的修改,并还能看到谁在线以及他们正在做什么的编辑器。

你将从本指南中得到什么?

第一部分:了解协作编辑器及其在Liveblocks中的作用

在第一部分,我们将看看什么是协作编辑器,它们的工作原理,以及它们背后的技术。我们将特别关注WebSockets在实时更新中的应用,并讨论Liveblocks如何让构建这些功能变得更容易。

第二部分:一步一步搭建协同编辑器

在第二部分,我们将从头开始构建编辑器。我们将设置项目环境,集成Liveblocks,并添加实时编辑功能、用户在线状态显示和管理文档状态的功能。到最后,你将拥有一款可以展示的实时共享编辑器。

准备好了吗?首先,让我们来了解一下协作编辑器的基础,以及它们背后的技术。为什么Liveblocks非常适合构建这样的编辑器,接下来就来探讨。

什么是协作编辑工具?

想象你正在和朋友一起编辑一份文档,就像这样。你们都能实时看到对方的改动,就像你们在同一台电脑上一起工作一样。这就是协作编辑器的神奇之处。

一种协作编辑工具是一款软件,允许多用户同时编辑同一文档或文件。每位用户都可以进行修改,所有人都能即时看到这些修改。例如,Google Docs、Notion 和 Figma。

协同编辑器是怎么工作的?

协作编辑器让用户和共享服务器之间进行实时通信。让我们简单了解一下它是如何工作的。

当用户编辑文档时,更改会发送到服务器。

服务器处理这些改动,并把它们发给所有在线的用户。

每个人都能实时看到变化,显示更新立即。

在多个用户同时进行更改的场景中,编辑器必须有效处理这些更新以确保一致。这里就需要能实现实时同步的技术发挥作用了,这种技术就是WebSocket

实时协同背后的技术:WebSockets

大多数传统网站使用HTTP请求来与服务器通信。这对静态页面非常有效,但对于实时应用,比如协作编辑器来说,速度就显得太慢了。

WebSockets 通过允许客户端和服务器之间持续的双向通信解决了此问题。客户端不再为每个更新单独发送请求,而是保持 WebSocket 连接 开启。这样,数据可以即时发送和接收,无论何时有变化。

下面是一个关于WebSocket通信是如何工作的简单解释

websocket-flow
WebSocket通信流程图 (WebSocket communication flowchart)

用户1做了个修改。

  1. 服务器收到编辑,然后把编辑发给用户1和用户2。

  2. 用户2又进行了编辑,然后服务器就把这个编辑内容再次发送给两位用户。

这样,每个人都能实时看到同一个文档版本。

Liveblocks: 简化实时协作

从零开始搭建一个协作编辑器是可能的,但处理实时同步、冲突解决和用户在线状态可能会有些棘手。这就是 Liveblocks 能发挥作用的地方。

Liveblocks 是一个强大的库,使构建协作应用更加简单。无需自己手动编写 WebSocket 和状态管理代码,而是可以使用 Liveblocks 内置的功能,

  • 在线状态:查看谁在线以及他们在做什么。

  • 存储:分享和同步文档、图片或任何类型的内容。

  • 房间管理:管理多个用户的权限设置和连接。

使用 Liveblocks 可以帮你节省不少时间,原因如下:

  1. 实时查看谁正在编辑,他们的光标位置,以及他们在输入的内容,全部实时更新。

  2. 冲突处理:轻松处理多个用户同时更改时的冲突。

  3. 简单的集成:它与流行的前端框架如Next.js无缝整合,使其更容易构建和扩展。

通过使用Liveblocks,你可以专注于构建编辑器的核心功能,而不必担心这些底层通信细节。

项目启动:

让我们从新建一个Next.js项目开始。

npx create-next-app@latest collaborative-editor --typescript

点击这里进入全屏模式。点击这里退出全屏。

在创建项目之后,安装名为 Liveblocks 的依赖项。

在终端中运行以下npm命令来安装这些依赖项:
npm install @liveblocks/client @liveblocks/node @liveblocks/react @liveblocks/yjs @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor @tiptap/pm @tiptap/react @tiptap/starter-kit yjs

全屏,退出全屏

接下来,创建一个 Liveblocks 配置文件,用于设置用户信息的类型,并在编辑器中显示光标。

// liveblocks.config.ts

declare global {
  interface Liveblocks {
    /** 存在状态 */
    Presence: { cursor: { x: number; y: number } | null };
    /** 用户元数据 */
    UserMeta: {
      /** 用户ID */
      id: string;
      /** 用户信息 */
      info: {
        /** 用户名 */
        name: string;
        /** 用户颜色 */
        color: string;
        /** 用户头像 */
        picture: string;
      };
    };
  }
}

export {};

进入或退出全屏模式

访问 https://liveblocks.io/ 并注册一个免费账号。注册后,您会看到仪表板,在那里您会看到为您创建的两个项目。选择第一个项目,然后在左侧菜单中找到“API 密钥”,然后获取秘密密钥。

liveblocks-dashboard1
这是实时协作的仪表盘图片。(Zhè shì shíshí zōuzuó de yànbǎn píngmú.)

liveblocks-dashboard2 实时看板界面

创建一个名为 .env 的文件,并添加环境变量 LIVEBLOCKS_SECRET_KEY,并将您从 API 密钥仪表板中复制的密钥赋值给该环境变量。

LIVEBLOCKS_SECRET_KEY=sk_dev_xxxxxxxxxx_xxxxxxx_xxx_xxxxx_x # 生产密钥, 用于LIVEBLOCKS

进入全屏;退出全屏

好了,项目设置已经完成。接下来,我们将为编辑器创建一些组件。

为编辑器创建组件

我不会讨论样式,因为这篇文章讲的是如何用Liveblocks和Next.js来构建一个协作编辑器。点击这里可以查看CSS和图标文件的GitHub链接。

这里包含了所有组件、CSS文件和图标的链接

让我们先创建一个工具栏组件,并添加如下代码。我们将使用Tiptap来构建编辑器,因为它是我的最爱之一,用于创建富文本编辑器。

    // components/Toolbar.tsx

    import { Editor } from "@tiptap/react";
    import styles from "./Toolbar.module.css";

    type Props = {
      editor: Editor | null;
    };

    export function Toolbar({ editor }: Props) {
      if (!editor) {
        return null;
      }

      return (
        <div className={styles.toolbar}>
          <button
            className={styles.button}
            onClick={() => editor.chain().focus().toggleBold().run()}
            data-active={editor.isActive("bold") ? "is-active" : undefined}
            aria-label="加粗"
          >
            <BoldIcon />
          </button>
          <button
            className={styles.button}
            onClick={() => editor.chain().focus().toggleItalic().run()}
            data-active={editor.isActive("italic") ? "is-active" : undefined}
            aria-label="斜体"
          >
            <ItalicIcon />
          </button>
          <button
            className={styles.button}
            onClick={() => editor.chain().focus().toggleStrike().run()}
            data-active={editor.isActive("strike") ? "is-active" : undefined}
            aria-label="删除线"
          >
            <StrikethroughIcon />
          </button>

          <button
            className={styles.button}
            onClick={() => editor.chain().focus().toggleBlockquote().run()}
            data-active={editor.isActive("blockquote") ? "is-active" : undefined}
            aria-label="引用"
          >
            <BlockQuoteIcon />
          </button>

          <button
            className={styles.button}
            onClick={() => editor.chain().focus().setHorizontalRule().run()}
            data-active={undefined}
            aria-label="水平线"
          >
            <HorizontalLineIcon />
          </button>

          <button
            className={styles.button}
            onClick={() => editor.chain().focus().toggleBulletList().run()}
            data-active={editor.isActive("bulletList") ? "is-active" : undefined}
            aria-label="无序列表"
          >
            <BulletListIcon />
          </button>

          <button
            className={styles.button}
            onClick={() => editor.chain().focus().toggleOrderedList().run()}
            data-active={editor.isActive("orderedList") ? "is-active" : undefined}
            aria-label="有序列表"
          >
            <OrderedListIcon />
          </button>
        </div>
      );
    }

全屏 退出全屏

我们现在正在使用Tiptap编辑器工具及其格式选项,比如斜体、粗体和列表。

接下来,我们创建一个Avatars组件来显示房间内所有在线的用户。

    // components/Avatars.tsx

    import { useOthers, useSelf } from "@liveblocks/react/suspense";
    import styles from "./Avatars.module.css";

    export function Avatars() {
      const users = useOthers();
      const currentUser = useSelf();

      return (
        <div className={styles.avatars}>
          {users.map(({ connectionId, info }) => {
            return (
              <Avatar key={connectionId} picture={info.picture} name={info.name} />
            );
          })}

          {currentUser && (
            <div className="relative ml-8 first:ml-0">
              <Avatar
                picture={currentUser.info.picture}
                name={currentUser.info.name}
              />
            </div>
          )}
        </div>
      );
    }

    export function Avatar({ picture, name }: { picture: string; name: string }) {
      return (
        <div className={styles.avatar} data-tooltip={name}>
          <img
            src={picture}
            className={styles.avatar_picture}
            data-tooltip={name}
          />
        </div>
      );
    }

切换到全屏模式,退出全屏

在这里我们使用了两个钩子函数。

  • useOthers:获取和我同在一个房间的其他用户列表。

  • useSelf:获取我的用户信息

现在我们来创建一个 ErrorListener 组件,用来捕获 Liveblocks 提供者内部发生的任何错误。特别是当用户试图连接他们无权访问的房间时出现的 4001 错误。

    // components/ErrorListener.tsx

    "use client";

    import { useErrorListener } from "@liveblocks/react/suspense";

    import React from "react";

    import styles from "./ErrorListener.module.css";
    import { Loading } from "./Loading";

    const ErrorListener = () => {
      const [error, setError] = React.useState<string | undefined>();

      useErrorListener((error) => {
        switch (error.code) {
          case -1:
            setError("无法连接到Liveblocks,可能是因为网络问题");

            break;

          case 4001:
            setError("您没有访问此房间的权限");

            break;

          default:
            setError("发生意外错误");

            break;
        }
      });

      return error ? (
        <div className={styles.container}>
          <div className={styles.error}>{error}</div>
        </div>
      ) : (
        <Loading />
      );
    };

    export default ErrorListener;

按ESC键进入或退出全屏模式

要感谢 Liveblocks 提供了方便的 useErrorListener 钩子,这个钩子处理了所有的错误捕获逻辑在我们的协作应用程序里。

接下来是 ConnectToRoom 组件,它显示初始界面,用户在此输入他们想要加入的房间名。然后,使用输入的名称作为 roomId,跳转到房间页面。

    // components/ConnectToRoom.tsx

    "use client";

    import React from "react";
    import styles from "./ConnectToRoom.module.css";

    import { useRouter } from "next/navigation";

    const ConnectToRoom = () => {
      const router = useRouter();

      const inputRef = React.useRef<HTMLInputElement>(null);

      const connectToRoom = async () => {
        const roomId = inputRef.current?.value;
        if (roomId && roomId.length > 0) {
          await (async () => router.push(`/room?roomId=${roomId}`))();
        }
      };

      return (
        <div className={styles.container}>
          <h1>连接到一个房间</h1>
          <p>连接到一个房间开始与其他人的协作。</p>
          <input
            ref={inputRef}
            type="text"
            placeholder="房间ID:"
            className={styles.input}
          />
          <button className={styles.button} onClick={connectToRoom}>
            进入
          </button>
        </div>
      );
    };

    export default ConnectToRoom;

点击这里切换到或退出全屏模式

因为我们从 URL 里拿到 roomId,创建一个自定义钩子组件。

    // hooks/useRoomId.ts

    import { useSearchParams } from "next/navigation";
    import { useEffect, useState } from "react";

    export const useRoomId = () => {
      const searchParams = useSearchParams();

      const [roomId, setRoomId] = useState<string | null>(
        searchParams.get("roomId")
      );

      useEffect(() => {
        setRoomId(searchParams.get("roomId"));
      }, [searchParams]);

      return roomId;
    };

进入全屏模式,或退出全屏

最后,编辑器组件协同 Tiptap 和 Liveblocks 工作,共同创造一些神奇的效果。

    // components/Editor.tsx

    "use client";

    import { useEditor, EditorContent } from "@tiptap/react";
    import StarterKit from "@tiptap/starter-kit";
    import Collaboration from "@tiptap/extension-collaboration";
    import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
    import * as Y from "yjs";
    import { LiveblocksYjsProvider } from "@liveblocks/yjs";
    import { useRoom, useSelf } from "@liveblocks/react/suspense";
    import { useEffect, useState } from "react";
    import { Toolbar } from "./Toolbar";
    import styles from "./Editor.module.css";
    import { Avatars } from "@/components/Avatars";

    export function Editor() {
      const room = useRoom();
      const [doc, setDoc] = useState<Y.Doc>();
      const [provider, setProvider] = useState<any>();

      useEffect(() => {
        const yDoc = new Y.Doc();
        const yProvider = new LiveblocksYjsProvider(room, yDoc);
        setDoc(yDoc);
        setProvider(yProvider);

        return () => {
          yDoc?.destroy();
          yProvider?.destroy();
        };
      }, [room]);

      if (!doc || !provider) {
        return null;
      }

      return <TiptapEditor doc={doc} provider={provider} />;
    }

    type EditorProps = {
      doc: Y.Doc;
      provider: any;
    };

    function TiptapEditor({ doc, provider }: EditorProps) {
      const userInfo = useSelf((me) => me.info);

      const editor = useEditor({
        editorProps: {
          attributes: {
            class: styles.editor,
          },
        },
        extensions: [
          StarterKit.configure({
            history: false,
          }),
          Collaboration.configure({
            document: doc,
          }),
          CollaborationCursor.configure({
            provider: provider,
            user: userInfo,
          }),
        ],
      });

      return (
        <div className={styles.container}>
          <div className={styles.editorHeader}>
            <Toolbar editor={editor} />
            <Avatars />
          </div>
          <EditorContent editor={editor} className={styles.editorContainer} />
        </div>
      );
    }

点击此处进入全屏,点击此处退出全屏

liveblocks-with-tiptap-editor
这是一张Liveblocks与Tiptap编辑器的截图。

Liveblocks 提供了一个 YJS 提供者,帮助定义和存储编辑器中的内容。此外,Tiptap 支持协作扩展功能,使得设置协作变得简单易行。

太好了,我们的编辑功能已经准备就绪。但为了协作,我们需要设置Liveblocks和RoomProvider。这将允许用户验证身份并创建一个会话以便他们加入所选的房间。

在下一节中,我们将讨论房间功能以及Liveblocks如何支持实时协作。

设置 Liveblocks Provider 和房间

在编写代码之前,让我们先看看图解,理解一下 Liveblocks 如何工作。

liveblock-flow-diagram
点击图片查看流程图

  • 当用户打开应用时,它会发送一个请求来确定用户想加入哪个房间。房间ID可以通过useRoomId钩子获取。

    拿到 roomId 后,就初始化 LiveblocksProvider 吧。

  • 身份验证请求发送到 /api/liveblocks-auth(我们很快会设置好这个接口),附上房间 ID 用于检查用户是否有访问权限。

  • 如果认证成功,房间设置就开始了。这将包括创建一个新的协作文档实例(Y.Doc)并设置一个提供程序(LiveblocksYjsProvider)以实现与Liveblocks后端的同步。

  • LiveblocksYjsProvider插件连接到Liveblocks的WebSocket,加入指定房间。

  • 初始状态(比如鼠标位置和用户的活动情况)会与其他参与者共享。

  • 用户状态:当用户加入时,状态更新并分享给大家。

  • 光标追踪:他们的光标位置通过CollaborationCursor扩展跟踪,并即时共享。

  • 文档同步:用户所做的任何更改都会被发送到Liveblocks服务器,服务器会更新共享文档,使所有用户都能看到最新版本。

  • 如果用户A编辑了文档,这些修改会同步到Liveblocks。

  • Liveblocks 将这些更改发送给所有用户,确保文档保持同步。

  • 当用户B加入时,他们的光标和位置与所有人共享。

    当用户离开后,Y.Doc 实例和提供方会被移除,同时 WebSocket 连接也将会被关闭。

    这将开始一个清理过程,以清除用户在房间中的痕迹和更新。

我希望上面的解释能帮助你更好地理解Liveblocks是如何运作的。

正如之前提到的,Liveblocks 需要一个端点来验证用户并为他们想加入的房间启动会话。因此,我们来创建一个名为 liveblocks-auth 的 API 端点。

    // app/api/liveblocks-auth/route.ts

    import { Liveblocks } from "@liveblocks/node";
    import { NextRequest } from "next/server";

    const liveblocks = new Liveblocks({
      secret: process.env.LIVEBLOCKS_SECRET_KEY!,
    });

    export async function POST(request: NextRequest) {
      const userId = Math.floor(Math.random() * 10) % USER_INFO.length;

      const roomId = request.nextUrl.searchParams.get("roomId");

      const session = liveblocks.prepareSession(`session-${userId}`, {
        userInfo: USER_INFO[userId],
      });

      session.allow(roomId!, session.FULL_ACCESS);

      const { body, status } = await session.authorize();
      return new Response(body, { status });
    }

    const USER_INFO = [
      {
        name: "Sachin Chaurasiya",
        color: "#D583F0",
        picture: "https://github.com/Sachin-chaurasiya.png",
      },
      {
        name: "Mislav Abha",
        color: "#F08385",
        picture: "https://liveblocks.io/avatars/avatar-2.png",
      },
      {
        name: "Tatum Paolo",
        color: "#F0D885",
        picture: "https://liveblocks.io/avatars/avatar-3.png",
      },
      {
        name: "Anjali Wanda",
        color: "#85EED6",
        picture: "https://liveblocks.io/avatars/avatar-4.png",
      },
      {
        name: "Jody Hekla",
        color: "#85BBF0",
        picture: "https://liveblocks.io/avatars/avatar-5.png",
      },
      {
        name: "Emil Joyce",
        color: "#8594F0",
        picture: "https://liveblocks.io/avatars/avatar-6.png",
      },
      {
        name: "Jory Quispe",
        color: "#85DBF0",
        picture: "https://liveblocks.io/avatars/avatar-7.png",
      },
      {
        name: "Quinn Elton",
        color: "#87EE85",
        picture: "https://liveblocks.io/avatars/avatar-8.png",
      },
    ];

点击全屏,退出全屏

这里我们用的是假用户数据,而在真实情况下,你需要从数据库获取用户数据并创建会话。

这一行代码让用户以完全的权限访问房间,包括 room:readroom:write。这只是演示用的,但在实际情况下,访问权限则取决于用户的角色。

// 允许会话具有完全访问权限
session.allow(roomId!, session.FULL_ACCESS);

进入全屏 退出全屏

接下来,咱们来创建一个Provider组件,然后在RootLayout中使用它。

    // app/Providers.tsx

    "use client";

    import { useRoomId } from "@/hooks/useRoomId";
    import { LiveblocksProvider } from "@liveblocks/react";
    import { type PropsWithChildren } from "react";

    export function Providers({ children }: PropsWithChildren) {
      const roomId = useRoomId();

      return (
        <LiveblocksProvider
          key={roomId}
          authEndpoint={`/api/liveblocks-auth?roomId=${roomId}`}
        >
          {children}
        </LiveblocksProvider>
      );
    }

点击全屏观看 点击退出全屏

更新布局,并用提供组件包裹子元素。

// app/layout.tsx
import { Providers } from "./Providers";
...
return (
    ...
    <body>
      <Providers>{children}</Providers>
    </body>
    ...
)

全屏 退出全屏

好了,提供者已经准备好了。现在,我们来用RoomProvider创建一个Room部分。

    // app/Room.tsx

    "use client";

    import { ReactNode } from "react";
    import { RoomProvider } from "@liveblocks/react/suspense";
    import { ClientSideSuspense } from "@liveblocks/react";
    import ErrorListener from "@/components/ErrorListener";
    import { useRoomId } from "@/hooks/useRoomId";

    export function 房间({ children }: { children: ReactNode }) {
      const roomId = useRoomId();

      return (
        <RoomProvider
          id={roomId ?? ""}
          initialPresence={{
            cursor: null,
          }}
          key={roomId}
        >
          <ClientSideSuspense fallback={<ErrorListener />}>
            {children}
          </ClientSideSuspense>
        </RoomProvider>
      );
    }

全屏模式 退出全屏.

在这里,RoomProvider 使用两个 props:roomIdinitialPresence,用于用户的光标,即光标的 x 和 y 坐标。

我们使用ClientSideSuspense,并将ErrorListener组件用作回退。如果出现错误,它会显示错误信息;否则,它将显示一个加载指示器,这意味着提供者还在加载中。

接下来,创建一个Room页面并添加以下代码。很简单:用Room组件包住Editor,这样编辑器就能获取该房间的所有连接。

// 这是一个客户端组件,用于显示一个房间页面,其中包含一个编辑器。
// This is a client component used to display a room page, which includes an editor.
"use client";

import { Room } from "@/app/Room";
import { Editor } from "@/components/Editor";

export default function RoomPage() {
  return (
    <main>
      <Room>
        <Editor />
      </Room>
    </main>
  );
}

切换到全屏模式,退出全屏模式

最后,更新首页,即 app/page.tsx 文件。

    import ConnectToRoom from "@/components/ConnectToRoom";

    export default function Home() {
      return (
        <main>
          <ConnectToRoom />
        </main>
      );
    }

点击全屏按钮进入全屏,点击退出按钮退出全屏

好的,Liveblocks 提供方已正确设置。现在,让我们进入最酷的部分:测试我们的协同编辑器。你是不是很激动?我也超激动的!我们下一节就来试试吧。

测试一下

运行下面的命令来启动开发服务器。

你可以运行 `npm run dev` 来启动开发环境。

进入全屏,退出全屏

服务器将在localhost:3000运行起来,你会看到“加入房间”的界面窗口。

连接到房间页面的图片链接:连接到房间页面

输入房间ID,您将进入房间页面,在那里您可以和其他用户实时工作。

这里有一个例子,两个用户连接到了同一个房间,local-room

[![demo-gif](https://imgapi.imooc.com/6705e9e20a520b9408000403.jpg)](https://imgapi.imooc.com/6705e9e20a520b9408000403.jpg)

干得不错,恭喜你成功创建了支持实时协作的编辑器。

感谢你读到最后。不想错过有趣的内容和项目构建技巧,那么就订阅我们的Dev Buddy周报

结尾

本指南将探索协作应用程序的强大之处,重点介绍如何利用Next.js和Liveblocks来构建实时协作的文本编辑器。

我们首先理解协作编辑者的作用,然后设置一个具有实时编辑、用户在线状态跟踪和文档版本管理等功能的项目。

资源
点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消