news 2026/9/19 2:00:05

BlockSuite React WebSocket 协同编辑示例:基于 y-websocket 的文档同步与存储实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
BlockSuite React WebSocket 协同编辑示例:基于 y-websocket 的文档同步与存储实战指南

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说明
devconcurrently -r并行启动dev:serverws-server
dev:servernodemon -w src/server -x tsx src/server/main.ts,监听src/server目录改动并热重启 Express 服务器
start:serverNODE_ENV=production pnpm tsx src/server/main.ts,以生产模式启动 Express 服务器
ws-servernode --env-file .env.websocket node_modules/y-websocket/bin/server.cjs,启动 y-websocket 后端进程
buildvite build构建前端产物

一次pnpm dev会拉起两个进程:Express 服务器(通过 nodemon + tsx 运行 TypeScript 源码)与 y-websocket 后端(通过 Node 运行y-websocket/bin/server.cjs)。concurrently -r-r标志表示任一进程退出时同时终止另一个进程,避免残留后台任务。

前端:Vite + React 集成

src/client 是完整的 React 前端,结构如下:

  • components/EditorProviderEditorContainerSidebarTopBar四个组件;
  • 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内含metablocks两个Map类型字段,其content分别对应文档集合元数据(DocCollectionMetaState)与块数据;
  • 回调按内容区分日志:meta有更新时打印 "Meta doc in room ... updated",否则若blocks有更新则打印 "BlockSuite doc in room ... updated";
  • 端口固定为 5173,注释明确指出该端口必须与.env.websocketCALLBACK_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"}
变量默认值(本示例)作用
HOSTlocalhosty-websocket 服务监听地址
PORT3001y-websocket 服务端口,前端 Provider 需指向该端口
YPERSISTENCE./storage开启 LevelDB 持久化,将文档更新保存到./storage目录
CALLBACK_URLws://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做了两件事:

  1. 通过initCollection()(来自 utils.ts)创建DocCollection——用new Schema().register(AffineSchemas)注册 BlockSuite 全部块 schema,并以固定 idblocksuite-example命名集合;
  2. 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 }; }

流程可拆解为四步:

  1. 创建编辑器容器:实例化AffineEditorContainer(来自@blocksuite/presets),并导入 affine.css 主题样式;
  2. 初始化 Provider:注意'ws://localhost:3001'.env.websocketPORT=3001一一对应;
  3. 绑定事件docLinkClicked(点击文档内链接)触发provider.connect(docId)切换房间;docSync到达时加载文档并setRoom更新 URL;
  4. 选择默认文档:优先恢复 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 下的重复初始化),随后将editorcollectionprovider三个对象放入 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读取全部文档元信息,并订阅docMetaUpdatededitor.slots.docUpdated保持列表与当前文档同步(返回的 disposable 在卸载时统一dispose);
  • addDoc:用createAndInitDoc新建文档并provider.connect(doc.id)立即进入同步;
  • deleteDoc:若删除的是当前文档,先切换到相邻文档(首删则切到第二个,否则切到前一个,仅剩一个时新建),再调用collection.removeDoc(docId)移除。

整体布局在 App.tsx 中组装:EditorProvider包裹 Sidebar、TopBar 与 EditorContainer 三栏结构,样式定义于 index.css 与 App.css。

同步链路:从编辑到回调再到落盘

将各环节串起来,一次编辑操作经历如下链路:

  1. 用户在编辑器中输入内容,BlockSuite 以 Yjs CRDT 增量更新(ydoc update)的形式产生变更;
  2. 当前房间对应的WebsocketProvider将更新推送到 y-websocket 后端(HOST=localhost:3001);
  3. y-websocket 后端依据YPERSISTENCE=./storage将更新写入 LevelDB,完成持久化(对应架构图中的 Document Storage);
  4. 依据CALLBACK_URLCALLBACK_OBJECTS,后端把metablocks两个 Y.Map 的最新内容以 POST 回调到 Express 的/basic-ws-callback
  5. Express 校验data.meta.contentdata.blocks.content是否为空,打印对应房间的更新日志,并返回200确认(见 main.ts);
  6. 其他打开同一房间(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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 23:26:38

IDC运维工程师面试:Linux、MySQL、Redis、Docker排障

简介&#xff1a;面向 IDC 机房运维岗位求职者与初级运维工程师的面试备考资料&#xff0c;以一份 PDF 问答文档形式呈现&#xff0c;覆盖 Windows、Linux 与网络基础三大知识板块。内容按基础技能测试题组织&#xff0c;逐条给出参考答案&#xff0c;涉及远程登录工具与端口辨…

作者头像 李华