协作应用现在已成为现代软件中的必备元素,允许多人可以同时在同一文档、设计或代码库上进行协作。想想 Google 文档、Figma 或多人编程平台这样的工具,它们之所以强大,在于提供了实时协作功能,确保每个人的改动都能即时同步给其他参与者。
在这篇指南里,我教你如何使用 Next.js 和 Liveblocks (一个简单易用的实时协作库)构建一个协同文本编辑器。到了最后,你将拥有一个让多个用户可以编辑同一份文档、实时看到对方的修改,并还能看到谁在线以及他们正在做什么的编辑器。
你将从本指南中得到什么?在第一部分,我们将看看什么是协作编辑器,它们的工作原理,以及它们背后的技术。我们将特别关注WebSockets在实时更新中的应用,并讨论Liveblocks如何让构建这些功能变得更容易。
在第二部分,我们将从头开始构建编辑器。我们将设置项目环境,集成Liveblocks,并添加实时编辑功能、用户在线状态显示和管理文档状态的功能。到最后,你将拥有一款可以展示的实时共享编辑器。
准备好了吗?首先,让我们来了解一下协作编辑器的基础,以及它们背后的技术。为什么Liveblocks非常适合构建这样的编辑器,接下来就来探讨。
什么是协作编辑工具?想象你正在和朋友一起编辑一份文档,就像这样。你们都能实时看到对方的改动,就像你们在同一台电脑上一起工作一样。这就是协作编辑器的神奇之处。
一种协作编辑工具是一款软件,允许多用户同时编辑同一文档或文件。每位用户都可以进行修改,所有人都能即时看到这些修改。例如,Google Docs、Notion 和 Figma。
协同编辑器是怎么工作的?协作编辑器让用户和共享服务器之间进行实时通信。让我们简单了解一下它是如何工作的。
当用户编辑文档时,更改会发送到服务器。
服务器处理这些改动,并把它们发给所有在线的用户。
每个人都能实时看到变化,显示更新立即。
在多个用户同时进行更改的场景中,编辑器必须有效处理这些更新以确保一致。这里就需要能实现实时同步的技术发挥作用了,这种技术就是WebSocket。
实时协同背后的技术:WebSockets大多数传统网站使用HTTP请求来与服务器通信。这对静态页面非常有效,但对于实时应用,比如协作编辑器来说,速度就显得太慢了。
WebSockets 通过允许客户端和服务器之间持续的双向通信解决了此问题。客户端不再为每个更新单独发送请求,而是保持 WebSocket 连接 开启。这样,数据可以即时发送和接收,无论何时有变化。
下面是一个关于WebSocket通信是如何工作的简单解释
WebSocket通信流程图 (WebSocket communication flowchart)
用户1做了个修改。
服务器收到编辑,然后把编辑发给用户1和用户2。
这样,每个人都能实时看到同一个文档版本。
Liveblocks: 简化实时协作从零开始搭建一个协作编辑器是可能的,但处理实时同步、冲突解决和用户在线状态可能会有些棘手。这就是 Liveblocks 能发挥作用的地方。
Liveblocks 是一个强大的库,使构建协作应用更加简单。无需自己手动编写 WebSocket 和状态管理代码,而是可以使用 Liveblocks 内置的功能,
在线状态:查看谁在线以及他们在做什么。
存储:分享和同步文档、图片或任何类型的内容。
使用 Liveblocks 可以帮你节省不少时间,原因如下:
实时查看谁正在编辑,他们的光标位置,以及他们在输入的内容,全部实时更新。
冲突处理:轻松处理多个用户同时更改时的冲突。
通过使用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链接。
这里包含了所有组件、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与Tiptap编辑器的截图。
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来构建实时协作的文本编辑器。
我们首先理解协作编辑者的作用,然后设置一个具有实时编辑、用户在线状态跟踪和文档版本管理等功能的项目。
资源GitHub 仓库页面