使用 React 封装 BlockSuite:基于 IndexedDB 的多文档持久化实战(react-indexeddb 示例深度解析)
2026/9/17 6:15:41 网站建设 项目流程

使用 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 的核心设计目标非常明确:

  1. 在浏览器端把整个 DocCollection 的增量更新(updates)写入 IndexedDB;
  2. 页面刷新后从 IndexedDB 恢复集合与所有文档;
  3. 将编辑器实例与集合通过 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 中还内置了buildtsc && vite build)、lintpreview脚本,并提供了 StackBlitz 启动命令pnpm i && pnpm dev,便于在线体验。

启动后浏览器会自动打开示例页面:左侧为文档列表(Sidebar),右侧为 BlockSuite 编辑器(AffineEditorContainer),顶部为标题栏。你在编辑器中输入的所有内容会实时以增量更新的形式写入 IndexedDB,刷新页面后数据依然存在。


三、存储层设计:三个 Object Store 的分工

持久化的地基是 src/editor/provider/db.ts 中实现的IndexedDBClient类。它使用idb库打开数据库,并在upgrade回调中创建三个 object store:

Store 名称keyPath / 键策略存储内容用途
docskeyPath: 'doc_id'{ doc_id, root_doc_id }文档注册表:记录每个文档的 id 及其所属根文档 id,用于定位集合根
updatesautoIncrement: true+ 索引doc_id{ doc_id, data: Uint8Array }Yjs 增量更新日志:按文档追加二进制 update,支持按doc_id查询
blobskeyPath: '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 持久化的三类数据:

  1. docs(元数据):只存doc_idroot_doc_id两个字段,轻量且稳定。getRootDocId()通过db.getAll(DOCS_STORE)后寻找!root_doc_id的记录来定位集合根文档——这是「集合」与「单文档」持久化的关键区别,单文档场景不需要这一层(可对照 vanilla-indexeddb)。
  2. updates(增量更新):每条记录是一个追加写入的 Yjs update(Uint8Array),autoIncrement保证写入顺序即应用顺序;索引doc_idgetUpdates(docId)可以通过index.getAll(IDBKeyRange.only(docId))一次性取出某文档的全部历史更新。
  3. 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:surfaceaffine:noteaffine:paragraph构造一个最小可编辑页面,最后doc.resetHistory()清除初始化产生的历史记录。

再次访问(有库)_loadCollectionFromDbdocsstore 读取根文档 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四个方法,并注入DocCollectionblobSources.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缓存editorprovider,在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.docUpdatededitor.slots.docLinkClicked两个信号驱动列表刷新(订阅返回dispose用于清理);
  • 点击列表项时设置editor.doc = doc完成当前文档切换;
  • 当前文档以active类高亮,标题来自doc.meta?.title || 'Untitled'

组件装配见 App.tsx:EditorProvider包裹Sidebar+TopBar+EditorContainer,形成"Provider 提供状态、子组件消费状态"的清晰层级;入口 main.tsx 使用 React 18 的createRoot挂载。


六、运行机制串联:一次编辑到落盘的全链路

把上面的模块串起来,一次「新建文档 → 输入内容 → 刷新恢复」的完整流程是:

  1. 应用启动 →CollectionProvider.init()检测库状态:空库则创建集合与首篇文档,有库则从docsstore 找到根文档并回放updatesstore 中的全部 Yjs 更新;
  2. initEditor()创建AffineEditorContainer,加载首篇文档,编辑器渲染完成;
  3. 用户输入 → Yjs 产生 update →collection.doc/ 子文档触发update事件 →client.insertUpdate追加写入updatesstore;
  4. 插入图片 →ClientBlobSource.setclient.insertBlob写入blobsstore;
  5. 新增子文档 →subdocs事件 →client.insertDoc登记docsstore 映射;
  6. 刷新页面 → 再次执行步骤 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询