使用 React 封装 BlockSuite:基于 IndexedDB 的多文档持久化实战(react-indexeddb 示例深度解析)
【免费下载链接】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 官方示例 examples/react-indexeddb 为蓝本,完整讲解如何将 BlockSuite 编辑器与文档集合(DocCollection)封装进 React 组件,并借助浏览器原生 IndexedDB 实现「多文档 + 二进制更新 + 二进制资源(Blob)」的本地持久化方案。读完本文,你将掌握 BlockSuite 的更新驱动(update-driven)持久化模型、BlobSource资源接入方式、React 上下文封装模式,以及一套可直接复制到业务项目中的 IndexedDB 存储层实现。
说明:该示例持久化的是一个包含多篇文档的 DocCollection;如果你的场景只需要保存单篇文档,官方提供了更精简的 vanilla-indexeddb 示例可供参考。
一、示例定位:多文档集合的本地持久化
在 BlockSuite 中,DocCollection是承载多篇文档(Doc)的容器,而每篇文档的编辑数据本质上是 Yjs(Y.Doc)的二进制更新流。示例 react-indexeddb 的核心设计目标非常明确:
- 在浏览器端把整个 DocCollection 的增量更新(updates)写入 IndexedDB;
- 页面刷新后从 IndexedDB 恢复集合与所有文档;
- 将编辑器实例与集合通过 React Context 提供给组件树使用。
这与「单文档保存」的 vanilla-indexeddb 形成对照:单文档只需存一份更新,而集合模式必须额外维护「根文档(root doc)→ 子文档(subdoc)」的关联关系,以及集合元数据(collection.meta)。
示例基于 Vite + React 18 + TypeScript 构建(由pnpm create vite脚手架创建,见 package.json),依赖idb(IndexedDB 的 Promise 封装库)以及 BlockSuite 的@blocksuite/blocks、@blocksuite/presets、@blocksuite/store、@blocksuite/sync四个包。
项目结构速览
examples/react-indexeddb/ ├── package.json # 依赖与脚本(dev/build/lint/preview) ├── vite.config.ts # Vite 配置 ├── index.html └── src/ ├── main.tsx # React 挂载入口 ├── App.tsx # 组件树装配 ├── index.css ├── components/ │ ├── EditorProvider.tsx # 提供 EditorContext 的 Provider │ ├── EditorContainer.tsx # 挂载编辑器 DOM 的容器 │ ├── Sidebar.tsx # 文档列表(支持切换当前文档) │ └── TopBar.tsx # 顶部标题栏 └── editor/ ├── context.ts # EditorContext 与 useEditor Hook ├── editor.ts # 编辑器初始化逻辑 └── provider/ ├── db.ts # IndexedDB 客户端(核心存储层) └── provider.ts # DocCollection 的持久化 Provider二、环境准备与快速启动
该示例作为独立 workspace 维护在仓库 examples 目录下,它不通过workspace:*引用 monorepo 内部包,而是直接安装发布到 npm 的 BlockSuite canary 版本(详见 examples/README.md)。
git clone https://github.com/toeverything/blocksuite.git cd blocksuite/examples pnpm install pnpm dev react-indexeddb命令说明:
pnpm install:在examples这个独立 pnpm workspace 中安装全部依赖(含 React、idb、BlockSuite 各包)。pnpm dev react-indexeddb:启动指定示例的开发服务器(dev脚本映射为vite,见 package.json)。
此外,package.json 中还内置了build(tsc && vite build)、lint与preview脚本,并提供了 StackBlitz 启动命令pnpm i && pnpm dev,便于在线体验。
启动后浏览器会自动打开示例页面:左侧为文档列表(Sidebar),右侧为 BlockSuite 编辑器(AffineEditorContainer),顶部为标题栏。你在编辑器中输入的所有内容会实时以增量更新的形式写入 IndexedDB,刷新页面后数据依然存在。
三、存储层设计:三个 Object Store 的分工
持久化的地基是 src/editor/provider/db.ts 中实现的IndexedDBClient类。它使用idb库打开数据库,并在upgrade回调中创建三个 object store:
| Store 名称 | keyPath / 键策略 | 存储内容 | 用途 |
|---|---|---|---|
docs | keyPath: 'doc_id' | { doc_id, root_doc_id } | 文档注册表:记录每个文档的 id 及其所属根文档 id,用于定位集合根 |
updates | autoIncrement: true+ 索引doc_id | { doc_id, data: Uint8Array } | Yjs 增量更新日志:按文档追加二进制 update,支持按doc_id查询 |
blobs | keyPath: 'blob_id' | { blob_id, blob_data: Blob } | 二进制资源:图片、附件等 Blob 数据 |
const DB_NAME = 'react-indexeddb'; const DB_VERSION = 1; const DOCS_STORE = 'docs'; const UPDATES_STORE = 'updates'; const BLOBS_STORE = 'blobs'; this.dbPromise = openDB(DB_NAME, DB_VERSION, { upgrade(db) { if (!db.objectStoreNames.contains(DOCS_STORE)) { db.createObjectStore(DOCS_STORE, { keyPath: 'doc_id' }); } if (!db.objectStoreNames.contains(UPDATES_STORE)) { const store = db.createObjectStore(UPDATES_STORE, { autoIncrement: true, }); store.createIndex('doc_id', 'doc_id', { unique: false }); } if (!db.objectStoreNames.contains(BLOBS_STORE)) { db.createObjectStore(BLOBS_STORE, { keyPath: 'blob_id' }); } }, });三个 store 的分工对应 BlockSuite 持久化的三类数据:
docs(元数据):只存doc_id与root_doc_id两个字段,轻量且稳定。getRootDocId()通过db.getAll(DOCS_STORE)后寻找!root_doc_id的记录来定位集合根文档——这是「集合」与「单文档」持久化的关键区别,单文档场景不需要这一层(可对照 vanilla-indexeddb)。updates(增量更新):每条记录是一个追加写入的 Yjs update(Uint8Array),autoIncrement保证写入顺序即应用顺序;索引doc_id让getUpdates(docId)可以通过index.getAll(IDBKeyRange.only(docId))一次性取出某文档的全部历史更新。blobs(二进制资源):以blob_id为主键存放Blob,提供insertBlob/getBlob/deleteBlob/getAllBlobIds四个方法,与后文BlobSource接口一一对应。
IndexedDBClient模块末尾导出单例export const client = new IndexedDBClient();,整个应用共享同一数据库连接。
四、持久化 Provider:把 Yjs 更新流接到 IndexedDB
核心业务逻辑位于 src/editor/provider/provider.ts 的CollectionProvider类。它的职责是:把 DocCollection 生命周期内产生的所有 Yjs 更新,透明地落盘到 IndexedDB;在初始化时反向回放更新,重建完整集合。
4.1 初始化:空库建集合,有库回放更新
CollectionProvider.init()首先调用client.checkForExistingData()(即db.count(DOCS_STORE) > 0)判断数据库是否有历史数据,然后走两条路径:
static async init() { const hasData = await client.checkForExistingData(); if (hasData) { return CollectionProvider._loadCollectionFromDb(); } else { return CollectionProvider._initEmptyCollection(); } }首次运行(空库):_initEmptyCollection生成一个随机 id(${Math.random()}.slice(2, 12)),用createCollection(id)创建集合,注册update/subdocs监听,初始化collection.meta,写入根文档记录,并调用createFirstDoc创建第一篇文档——它通过doc.addBlock('affine:page', {})、affine:surface、affine:note、affine:paragraph构造一个最小可编辑页面,最后doc.resetHistory()清除初始化产生的历史记录。
再次访问(有库):_loadCollectionFromDb从docsstore 读取根文档 id,重建集合后:
- 先对集合自身的
collection.doc回放更新(_applyUpdates); - 再遍历
collection.docs,对每个子文档spaceDoc回放更新并doc.load(); - 最后重连
update/subdocs监听,保证后续编辑继续落盘。
4.2 更新监听:增量即存,双向连通
private _connectCollection() { const { collection } = this; collection.doc.on('update', async update => { await client.insertUpdate(collection.id, update); }); collection.doc.on('subdocs', subdocs => { subdocs.added.forEach((doc: Y.Doc) => { client.insertDoc(doc.guid, collection.id); this._connectSubDoc(doc); }); }); } private _connectSubDoc(doc: Y.Doc) { doc.on('update', async update => { client.insertUpdate(doc.guid, update); }); }这段代码揭示了 BlockSuite 持久化模型的本质:不保存"最终快照",只追加"增量更新"。每次编辑触发update事件,携带的二进制 update 就作为一条记录追加到updatesstore;新增子文档(subdoc)时自动在docsstore 登记映射并递归监听。回放时用DocCollection.Y.applyUpdate(doc, update)按序应用全部更新,即可精确还原文档状态。这种模式天然兼容 BlockSuite 的 CRDT 数据模型,也便于后续演进为 WebSocket 等实时同步(可对比仓库中的 react-websocket 示例)。
4.3 BlobSource:让图片与附件也走 IndexedDB
文档中的图片、附件等二进制资源不进入 Yjs 更新流,而是通过BlobSource接口管理。provider.ts 中的ClientBlobSource实现了@blocksuite/sync导出的BlobSource四个方法,并注入DocCollection的blobSources.main:
class ClientBlobSource implements BlobSource { readonly = false; name = 'client'; async get(key: string) { return client.getBlob(key); } async set(key: string, value: Blob) { await client.insertBlob(key, value); return key; } async delete(key: string) { return client.deleteBlob(key); } async list() { return client.getAllBlobIds(); } }从源码结构可以推断,BlobSource是 BlockSuite sync 模块抽象的资源读写接口,将资源层与传输层解耦:本地场景下把 Blob 存进 IndexedDB 的blobsstore,远程场景则可以换成 HTTP 上传。getAllBlobIds对应的list()方法为未来实现资源清理(垃圾回收)预留了能力。
五、React 集成:Context 封装与编辑器挂载
5.1 Context 与 Hook
src/editor/context.ts 定义并导出EditorContext(值为{ editor, provider })与useEditor()Hook。provider在模块加载时即通过export const provider = await CollectionProvider.init();完成初始化(顶层 await,见 provider.ts),并挂到window.provider便于调试。
5.2 EditorProvider:异步初始化的标准姿势
EditorProvider.tsx 是 React 集成的核心:由于编辑器与集合的初始化是异步的,它用useState缓存editor与provider,在useEffect中调用initEditor()后写入 state,再把二者通过 Context 下发:
useEffect(() => { initEditor().then(({ editor, provider }) => { setEditor(editor); setProvider(provider); }); }, []);5.3 编辑器初始化与文档切换
src/editor/editor.ts 中的initEditor()创建AffineEditorContainer(来自@blocksuite/presets,需引入其主题样式@blocksuite/presets/themes/affine.css),将集合中的第一篇文档设为当前文档,并订阅docLinkClicked事件实现文档间跳转:
export async function initEditor() { const { collection } = provider; const editor = new AffineEditorContainer(); const docs = [...collection.docs.values()].map(blocks => blocks.getDoc()); editor.doc = docs[0]; editor.slots.docLinkClicked.on(({ docId }) => { const target = <Doc>collection.getDoc(docId); editor.doc = target; }); return { editor, provider, collection }; }EditorContainer.tsx 负责把 Web Component 形态的编辑器实例挂载进 React 的 DOM 树:useEffect中清空容器后appendChild(editor)。由于AffineEditorContainer不是 React 组件,这种「ref 容器 + appendChild」是官方示例采用的桥接方式。
5.4 Sidebar:响应式文档列表
Sidebar.tsx 展示多文档管理的交互闭环:
- 通过
collection.slots.docUpdated与editor.slots.docLinkClicked两个信号驱动列表刷新(订阅返回dispose用于清理); - 点击列表项时设置
editor.doc = doc完成当前文档切换; - 当前文档以
active类高亮,标题来自doc.meta?.title || 'Untitled'。
组件装配见 App.tsx:EditorProvider包裹Sidebar+TopBar+EditorContainer,形成"Provider 提供状态、子组件消费状态"的清晰层级;入口 main.tsx 使用 React 18 的createRoot挂载。
六、运行机制串联:一次编辑到落盘的全链路
把上面的模块串起来,一次「新建文档 → 输入内容 → 刷新恢复」的完整流程是:
- 应用启动 →
CollectionProvider.init()检测库状态:空库则创建集合与首篇文档,有库则从docsstore 找到根文档并回放updatesstore 中的全部 Yjs 更新; initEditor()创建AffineEditorContainer,加载首篇文档,编辑器渲染完成;- 用户输入 → Yjs 产生 update →
collection.doc/ 子文档触发update事件 →client.insertUpdate追加写入updatesstore; - 插入图片 →
ClientBlobSource.set→client.insertBlob写入blobsstore; - 新增子文档 →
subdocs事件 →client.insertDoc登记docsstore 映射; - 刷新页面 → 再次执行步骤 1,文档完整恢复,Sidebar 列出全部文档,点击即可切换。
七、总结与扩展方向
通过 react-indexeddb 示例可以看到 BlockSuite 在「编辑器集成」之外的完整持久化能力面:
- 更新驱动的存储模型:以 Yjs 增量更新为唯一事实来源,天然支持多端合并与后续实时同步;
- 资源与文档分离:
BlobSource抽象让二进制资源可以按需接入不同后端; - 框架无关的嵌入方式:编辑器是标准 Web Component,React(以及 vue-basic、svelte-basic、solid-basic、preact-basic、angular-basic 等示例)只需负责宿主组件与生命周期管理。
在此基础上可继续探索仓库中的进阶示例:需要后端协作可参考 react-websocket,需要本地 SQL 存储可参考 react-sqlite,单文档轻量场景则参考 vanilla-indexeddb。需要注意的是,本示例依赖发布到 npm 的 canary 版本 BlockSuite 包(见 package.json),API 细节可能随版本演进,阅读源码时请以当前仓库版本为准。
如果你正在为自己的 React 应用集成 BlockSuite 并需要浏览器本地持久化,IndexedDBClient+CollectionProvider+BlobSource这套组合可以直接复用——只需替换DB_NAME、调整 object store 定义,并挂载你自己的业务 Schema(示例中使用AffineSchemas,来自@blocksuite/blocks)即可。
【免费下载链接】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),仅供参考