协作应用现在已成为现代软件中的必备元素,允许多人可以同时在同一文档、设计或代码库上进行协作。想想 Google 文档、Figma 或多人编程平台这样的工具,它们之所以强大,在于提供了实时协作功能,确保每个人的改动都能即时同步给其他参与者。
在这篇指南里,我教你如何使用 Next.js 和 Liveblocks (一个简单易用的实时协作库)构建一个协同文本编辑器。到了最后,你将拥有一个让多个用户可以编辑同一份文档、实时看到对方的修改,并还能看到谁在线以及他们正在做什么的编辑器。
你将从本指南中得到什么?第一部分:了解协作编辑器及其在Liveblocks中的作用
在第一部分,我们将看看什么是协作编辑器,它们的工作原理,以及它们背后的技术。我们将特别关注WebSockets在实时更新中的应用,并讨论Liveblocks如何让构建这些功能变得更容易。
第二部分:一步一步搭建协同编辑器
在第二部分,我们将从头开始构建编辑器。我们将设置项目环境,集成Liveblocks,并添加实时编辑功能、用户在线状态显示和管理文档状态的功能。到最后,你将拥有一款可以展示的实时共享编辑器。
准备好了吗?首先,让我们来了解一下协作编辑器的基础,以及它们背后的技术。为什么Liveblocks非常适合构建这样的编辑器,接下来就来探讨。
什么是协作编辑工具?想象你正在和朋友一起编辑一份文档,就像这样。你们都能实时看到对方的改动,就像你们在同一台电脑上一起工作一样。这就是协作编辑器的神奇之处。
一种协作编辑工具是一款软件,允许多用户同时编辑同一文档或文件。每位用户都可以进行修改,所有人都能即时看到这些修改。例如,Google Docs、Notion 和 Figma。
协同编辑器是怎么工作的?协作编辑器让用户和共享服务器之间进行实时通信。让我们简单了解一下它是如何工作的。
当用户编辑文档时,更改会发送到服务器。
服务器处理这些改动,并把它们发给所有在线的用户。
每个人都能实时看到变化,显示更新立即。
在多个用户同时进行更改的场景中,编辑器必须有效处理这些更新以确保一致。这里就需要能实现实时同步的技术发挥作用了,这种技术就是WebSocket。
实时协同背后的技术:WebSockets大多数传统网站使用HTTP请求来与服务器通信。这对静态页面非常有效,但对于实时应用,比如协作编辑器来说,速度就显得太慢了。
WebSockets 通过允许客户端和服务器之间持续的双向通信解决了此问题。客户端不再为每个更新单独发送请求,而是保持 WebSocket 连接 开启。这样,数据可以即时发送和接收,无论何时有变化。
下面是一个关于WebSocket通信是如何工作的简单解释
WebSocket通信流程图 (WebSocket communication flowchart)
用户1做了个修改。
-
服务器收到编辑,然后把编辑发给用户1和用户2。
- 用户2又进行了编辑,然后服务器就把这个编辑内容再次发送给两位用户。
这样,每个人都能实时看到同一个文档版本。
Liveblocks: 简化实时协作从零开始搭建一个协作编辑器是可能的,但处理实时同步、冲突解决和用户在线状态可能会有些棘手。这就是 Liveblocks 能发挥作用的地方。
Liveblocks 是一个强大的库,使构建协作应用更加简单。无需自己手动编写 WebSocket 和状态管理代码,而是可以使用 Liveblocks 内置的功能,
-
在线状态:查看谁在线以及他们在做什么。
-
存储:分享和同步文档、图片或任何类型的内容。
- 房间管理:管理多个用户的权限设置和连接。
使用 Liveblocks 可以帮你节省不少时间,原因如下:
-
实时查看谁正在编辑,他们的光标位置,以及他们在输入的内容,全部实时更新。
-
冲突处理:轻松处理多个用户同时更改时的冲突。
- 简单的集成:它与流行的前端框架如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 密钥”,然后获取秘密密钥。
这是实时协作的仪表盘图片。(Zhè shì shíshí zōuzuó de yànbǎn píngmú.)
创建一个名为 .env
的文件,并添加环境变量 LIVEBLOCKS_SECRET_KEY
,并将您从 API 密钥仪表板中复制的密钥赋值给该环境变量。
LIVEBLOCKS_SECRET_KEY=sk_dev_xxxxxxxxxx_xxxxxxx_xxx_xxxxx_x # 生产密钥, 用于LIVEBLOCKS
进入全屏;退出全屏
好了,项目设置已经完成。接下来,我们将为编辑器创建一些组件。
为编辑器创建组件我不会讨论样式,因为这篇文章讲的是如何用Liveblocks和Next.js来构建一个协作编辑器。点击这里可以查看CSS和图标文件的GitHub链接。
让我们先创建一个工具栏组件,并添加如下代码。我们将使用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 提供了一个 YJS 提供者,帮助定义和存储编辑器中的内容。此外,Tiptap 支持协作扩展功能,使得设置协作变得简单易行。
太好了,我们的编辑功能已经准备就绪。但为了协作,我们需要设置Liveblocks和RoomProvider。这将允许用户验证身份并创建一个会话以便他们加入所选的房间。
在下一节中,我们将讨论房间功能以及Liveblocks如何支持实时协作。
设置 Liveblocks Provider 和房间在编写代码之前,让我们先看看图解,理解一下 Liveblocks 如何工作。
-
当用户打开应用时,它会发送一个请求来确定用户想加入哪个房间。房间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:read
和 room: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:roomId
和 initialPresence
,用于用户的光标,即光标的 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来构建实时协作的文本编辑器。
我们首先理解协作编辑者的作用,然后设置一个具有实时编辑、用户在线状态跟踪和文档版本管理等功能的项目。
资源- 感谢 Liveblocks 创建了各种示例,可以在这里查看 [链接]