BlockSuite React WebSocket 协同编辑示例:基于 y-websocket 的文档同步与存储实战指南
【免费下载链接】blocksuite🧩 Content editing tech stack for the web - BlockSuite is a toolkit for building editors and collaborative applications.项目地址: https://gitcode.com/GitHub_Trending/bl/blocksuite
本篇技术指南围绕 BlockSuite 仓库中的 React WebSocket 示例 展开,讲解如何将 BlockSuite 编辑器与文档集合(Doc Collection)封装进 React 组件,并通过 y-websocket 后端实现多端文档同步与存储。读者将掌握pnpm create vite-express工程下的前后端启动方式、WebSocket 提供者(Provider)的封装思路、文档「房间」的建连与切换逻辑,以及 BlockSuite 文档变更通过回调写入持久化存储的完整链路。
示例概览:在 React 中接入 WebSocket 同步的 BlockSuite
examples/react-websocket是一个完整的可运行示例,它将 BlockSuite 编辑器与文档集合封装在 React 应用中,重点演示了基于 WebSocket 的文档同步与存储。与仅使用内存或本地 IndexedDB 的示例不同,这里的文档数据通过 WebSocket 实时同步到后端,并借助 y-websocket 的能力持久化到磁盘。
示例的架构关系可概括为下图(出自 README):
┌────────────┐ │ Express │ ◀──────┐ │ Server │ │ ydoc update └────────────┘ │ callback │ ┌────────────┐ ┌─────────────┐ ┌────────────┐ │ Editor │ │ Y-Websocket │ │ Document │ │ Client │◀───────────▶│ Backend │ ─────────▶│ Storage │ └────────────┘ ydoc room └─────────────┘ └────────────┘链路从左到右依次为:React 中的编辑器客户端(Editor Client)通过「ydoc room」与 Y-Websocket 后端双向通信;后端在收到更新后触发 callback,将 ydoc update 通知到 Express 服务器;同时文档更新被持久化到文档存储(Document Storage)中。
WebSocket 后端由 yjs 社区的 y-websocket 提供,仓库中还提到 yjs 社区同时提供带鉴权的替代方案 y-redis,其仓库中的 demos 目录下也包含 BlockSuite 的接入示例。本项目使用pnpm create vite-expressCLI 创建,因此同时具备 Vite 前端构建与 Express 后端能力。
快速开始:环境准备与一键启动
示例运行依赖 pnpm 工作区。在当前仓库根目录下按以下步骤启动:
git clone https://github.com/toeverything/blocksuite.git cd blocksuite/examples pnpm install pnpm dev react-websocket其中pnpm dev react-websocket依赖 examples 目录下的 package.json 与 pnpm-workspace.yaml 定义的工作区脚本,pnpm install会一次性安装react-websocket及其所有 workspace 依赖(如@blocksuite/blocks、@blocksuite/presets、@blocksuite/store等)。
启动成功后,浏览器打开 Vite 默认端口即可看到编辑器界面:顶部为连接状态栏,左侧为文档列表(All Docs),右侧为主体编辑器区域。也可以直接运行cd examples/react-websocket && pnpm dev在该子项目内单独启动。
工程结构:一次 dev 命令如何拉起前后端
react-websocket的 package.json 定义了清晰的脚本编排:
| Script | 说明 |
|---|---|
dev | 用concurrently -r并行启动dev:server与ws-server |
dev:server | nodemon -w src/server -x tsx src/server/main.ts,监听src/server目录改动并热重启 Express 服务器 |
start:server | NODE_ENV=production pnpm tsx src/server/main.ts,以生产模式启动 Express 服务器 |
ws-server | node --env-file .env.websocket node_modules/y-websocket/bin/server.cjs,启动 y-websocket 后端进程 |
build | vite build构建前端产物 |
一次pnpm dev会拉起两个进程:Express 服务器(通过 nodemon + tsx 运行 TypeScript 源码)与 y-websocket 后端(通过 Node 运行y-websocket/bin/server.cjs)。concurrently -r的-r标志表示任一进程退出时同时终止另一个进程,避免残留后台任务。
前端:Vite + React 集成
src/client 是完整的 React 前端,结构如下:
components/:EditorProvider、EditorContainer、Sidebar、TopBar四个组件;editor/:provider.ts(WebSocket 提供者封装)、editor.ts(编辑器初始化)、context.ts(React Context)、utils.ts(文档工具函数);App.tsx组装整体布局,main.tsx作为 React 入口。
vite.config.ts 仅注册了@vitejs/plugin-react插件,无特殊配置,项目整体遵循 Vite 5 + React 18 的标准结构(依赖版本见 package.json)。
后端:Express + vite-express 一体化服务
src/server/main.ts 是一个极简 Express 服务器:
import { DocCollectionMetaState } from '@blocksuite/store'; import express, { json } from 'express'; import ViteExpress from 'vite-express'; // Create http server const app = express(); app.use(json()); // The data structure of the callback body is defined here. type EmptyObject = Record<string, never>; type BasicWsCallbackBody = { room: string; data: { meta: { type: 'Map'; content: DocCollectionMetaState | EmptyObject; }; blocks: { type: 'Map'; content: Record<string, unknown> | EmptyObject; }; }; }; // It is called in regular intervals when the document changes. app.post('/basic-ws-callback', async (req, res) => { const { room, data } = req.body as BasicWsCallbackBody; if (Object.keys(data.meta.content).length !== 0) { console.log(`Meta doc in room "${room}" updated`); } else if (Object.keys(data.blocks.content).length !== 0) { console.log(`BlockSuite doc in room "${room}" updated`); } res.sendStatus(200); }); // This port is the same as the port in the CALLBACK_URL in the file `.env.websocket` const port = 5173; ViteExpress.listen(app, 5173, () => console.log(`Server listening at http://localhost:${port}`) );关键点:
app.use(json())启用 JSON 中间件以解析回调请求体;BasicWsCallbackBody类型精确描述了 y-websocket 回调的数据结构:room为房间名,data内含meta与blocks两个Map类型字段,其content分别对应文档集合元数据(DocCollectionMetaState)与块数据;- 回调按内容区分日志:
meta有更新时打印 "Meta doc in room ... updated",否则若blocks有更新则打印 "BlockSuite doc in room ... updated"; - 端口固定为 5173,注释明确指出该端口必须与
.env.websocket中CALLBACK_URL的端口保持一致(见下方环境变量小节)。
WebSocket 后端环境变量:.env.websocket
pnpm ws-server使用node --env-file .env.websocket加载 .env.websocket 中的配置:
# Basic WebSocket Host HOST=localhost # Basic WebSocket port PORT=3001 # Persist document updates in a LevelDB database. YPERSISTENCE=./storage # Basic WebSocket callback CALLBACK_URL=ws://localhost:5173/basic-ws-callback # Post blocks data when blocksuite document update CALLBACK_OBJECTS={"meta":"Map","blocks":"Map"}| 变量 | 默认值(本示例) | 作用 |
|---|---|---|
HOST | localhost | y-websocket 服务监听地址 |
PORT | 3001 | y-websocket 服务端口,前端 Provider 需指向该端口 |
YPERSISTENCE | ./storage | 开启 LevelDB 持久化,将文档更新保存到./storage目录 |
CALLBACK_URL | ws://localhost:5173/basic-ws-callback | 文档更新时回调的地址,指向 Express 的/basic-ws-callback接口 |
CALLBACK_OBJECTS | {"meta":"Map","blocks":"Map"} | 指定回调需要投递的 Y.Map 对象:meta(文档元数据)与blocks(块数据) |
设置YPERSISTENCE=./storage后,所有 WebSocket 房间的文档更新会以 LevelDB 的形式落盘,关闭浏览器再打开时数据依然存在,这正是「Document Storage」环节的实现基础。
核心封装:Provider 如何连接 WebSocket 房间
前端同步逻辑的核心在 provider.ts,它封装了WebsocketProvider的创建、元数据同步等待与文档房间连接。
连接状态与事件插槽
export type ConnectionStatus = 'connected' | 'disconnected' | 'error'; export class Provider { metaWs: WebsocketProvider; docWs: WebsocketProvider | null = null; slots = { connectStatusChanged: new Slot<ConnectionStatus>(), docSync: new Slot<Doc>(), }; ... }ConnectionStatus描述三种连接状态;slots使用 BlockSuite 的Slot(事件总线)暴露两个事件:
connectStatusChanged:连接状态变化(供 TopBar.tsx 实时显示);docSync:某个文档完成同步(供编辑器加载文档内容)。
初始化:先同步元数据,再进入业务文档
static async init(wsBaseUrl: string) { const collection = initCollection(); const metaWs = new WebsocketProvider( wsBaseUrl, collection.id, collection.doc ); // Make sure all document meta information is loaded. await new Promise<void>((resolve, reject) => { metaWs.once('sync', () => { collection.doc.load(); resolve(); }); metaWs.once('connection-error', () => { reject(); }); }); return new Provider(wsBaseUrl, collection, metaWs); }init做了两件事:
- 通过
initCollection()(来自 utils.ts)创建DocCollection——用new Schema().register(AffineSchemas)注册 BlockSuite 全部块 schema,并以固定 idblocksuite-example命名集合; - 以
collection.id作为房间名创建「元数据 WebSocket 连接」,等待sync事件后调用collection.doc.load()加载元数据文档,从而确保所有文档的元信息在进入业务逻辑前已就绪;若连接出错则 reject,使初始化失败。
connect:按房间切换文档
connect(room: string) { if (this.docWs?.roomname === room) return; this.docWs?.destroy(); const doc = this.collection.getDoc(room)!; this.docWs = new WebsocketProvider(this.wsBaseUrl, room, doc.spaceDoc); this.docWs.on('status', (e: { status: 'connected' | 'disconnected' }) => { this.slots.connectStatusChanged.emit(e.status); }); this.docWs.once('connection-error', () => { this.slots.connectStatusChanged.emit('error'); }); this.docWs.on('sync', () => { this.slots.docSync.emit(doc); }); this.docWs.connect(); }connect(room)的行为:
- 若目标房间与当前
docWs.roomname相同则直接返回(幂等); - 否则销毁旧连接,通过
collection.getDoc(room)获取对应文档,并用doc.spaceDoc(该文档对应的 Y.Doc)创建新的WebsocketProvider; - 监听
status事件透传connected/disconnected,监听一次性connection-error透传error,监听sync事件通知docSync槽位; - 显式调用
connect()发起连接。
这里的核心模型是:每个 BlockSuite 文档对应一个 WebSocket 房间(room),切换文档即切换房间连接。
编辑器初始化:恢复会话、默认文档与事件绑定
editor.ts 负责创建编辑器并绑定 Provider:
export async function initEditor() { const editor = new AffineEditorContainer(); // Same as .env.websocket const provider = await Provider.init('ws://localhost:3001'); const { collection } = provider; editor.slots.docLinkClicked.on(({ docId }) => { provider.connect(docId); }); provider.slots.docSync.on(doc => { doc.load(); editor.doc = doc; setRoom(doc.id); }); let doc: Doc | null = null; const currentRoom = getCurrentRoom(); if (currentRoom) { doc = collection.getDoc(currentRoom); } if (doc == null) { collection.docs.forEach(d => { doc = doc ?? d; }); } if (doc === null) { doc = createDoc(collection); } provider.connect(doc.id); editor.doc = doc; return { editor, collection, provider }; }流程可拆解为四步:
- 创建编辑器容器:实例化
AffineEditorContainer(来自@blocksuite/presets),并导入 affine.css 主题样式; - 初始化 Provider:注意
'ws://localhost:3001'与.env.websocket中PORT=3001一一对应; - 绑定事件:
docLinkClicked(点击文档内链接)触发provider.connect(docId)切换房间;docSync到达时加载文档并setRoom更新 URL; - 选择默认文档:优先恢复 URL 路径中的房间(
getCurrentRoom),其次取集合中第一个文档,最后都没有时用createDoc新建。
createDoc(utils.ts)展示了新建 BlockSuite 文档的标准骨架:
export function createDoc(collection: DocCollection) { const doc = collection.createDoc(); doc.load(() => { const pageBlockId = doc.addBlock('affine:page', {}); doc.addBlock('affine:surface', {}, pageBlockId); const noteId = doc.addBlock('affine:note', {}, pageBlockId); doc.addBlock('affine:paragraph', {}, noteId); }); doc.resetHistory(); return doc; }依次添加affine:page(页面根块)、affine:surface(画布块)、affine:note(笔记块)与affine:paragraph(段落块),最后resetHistory()清空初始化产生的历史记录。URL 工具函数getCurrentRoom/setRoom(utils.ts)通过window.location.pathname读取房间 id,并用history.pushState写入编码后的房间路径,使刷新页面后能恢复到同一文档。
React 集成:Provider 组件、编辑器挂载与侧边栏文档管理
EditorProvider:Context 提供初始化后的编辑器
EditorProvider.tsx 在useEffect中只调用一次initEditor()(用hasInitCalledref 防止 React StrictMode 下的重复初始化),随后将editor、collection、provider三个对象放入 context.ts 定义的EditorContext,供任意子组件通过useEditor()钩子获取。
EditorContainer:挂载 Web Component 编辑器
useEffect(() => { if (editorContainerRef.current && editor) { editorContainerRef.current.innerHTML = ''; editorContainerRef.current.appendChild(editor); } }, [editor]);AffineEditorContainer是 Web Component 形态,因此 EditorContainer.tsx 用 ref 拿到容器 div 后,清空内容并appendChild(editor)将其挂载进 React 的 DOM 树。
TopBar 与 Sidebar:状态可视化与多文档管理
TopBar.tsx 订阅connectStatusChanged,将connected/disconnected/error渲染为状态文本并挂上对应 CSS 类。
Sidebar.tsx 实现了文档集合的完整管理:
- 通过
collection.meta.docMetas读取全部文档元信息,并订阅docMetaUpdated与editor.slots.docUpdated保持列表与当前文档同步(返回的 disposable 在卸载时统一dispose); addDoc:用createAndInitDoc新建文档并provider.connect(doc.id)立即进入同步;deleteDoc:若删除的是当前文档,先切换到相邻文档(首删则切到第二个,否则切到前一个,仅剩一个时新建),再调用collection.removeDoc(docId)移除。
整体布局在 App.tsx 中组装:EditorProvider包裹 Sidebar、TopBar 与 EditorContainer 三栏结构,样式定义于 index.css 与 App.css。
同步链路:从编辑到回调再到落盘
将各环节串起来,一次编辑操作经历如下链路:
- 用户在编辑器中输入内容,BlockSuite 以 Yjs CRDT 增量更新(ydoc update)的形式产生变更;
- 当前房间对应的
WebsocketProvider将更新推送到 y-websocket 后端(HOST=localhost:3001); - y-websocket 后端依据
YPERSISTENCE=./storage将更新写入 LevelDB,完成持久化(对应架构图中的 Document Storage); - 依据
CALLBACK_URL与CALLBACK_OBJECTS,后端把meta、blocks两个 Y.Map 的最新内容以 POST 回调到 Express 的/basic-ws-callback; - Express 校验
data.meta.content与data.blocks.content是否为空,打印对应房间的更新日志,并返回200确认(见 main.ts); - 其他打开同一房间(room)的客户端通过
sync事件收到更新,Provider发出docSync,编辑器随之刷新——这就是多端实时同步的基础。
整个示例印证了 BlockSuite 文档集合与 Yjs 生态的天然融合:BlockSuite 负责编辑与文档模型,y-websocket 负责网络同步,LevelDB 负责持久化,Express 负责应用层回调。需要生产级鉴权与更复杂部署时,可参考 yjs 社区的 y-redis 方案替换后端,客户端Provider的封装思路无需改动。
小结
examples/react-websocket是一个麻雀虽小、五脏俱全的端到端示例:它演示了 BlockSuite 文档集合(DocCollection)的初始化、按房间(room)切换文档的 Provider 封装、React Context 状态管理、URL 会话恢复、侧边栏多文档增删,以及 y-websocket 后端 + LevelDB 持久化 + Express 回调的完整同步链路。以此为模板,开发者可以快速搭建基于 BlockSuite 与 WebSocket 的实时协同编辑器——唯一需要替换的就是将回调日志与本地 LevelDB 换成自己的后端存储逻辑。
【免费下载链接】blocksuite🧩 Content editing tech stack for the web - BlockSuite is a toolkit for building editors and collaborative applications.项目地址: https://gitcode.com/GitHub_Trending/bl/blocksuite
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考