BlockSuite 内容编辑技术栈深度指南:无头框架、CRDT 协作与组件体系
2026/9/17 20:53:28 网站建设 项目流程

BlockSuite 内容编辑技术栈深度指南:无头框架、CRDT 协作与组件体系

【免费下载链接】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 是一套用于构建编辑器与协作应用的内容编辑技术栈(Content Editing Tech Stack)。本文以其官方文档主页(packages/docs/index.md)为核心骨架,系统讲解它的三大支柱——无头(headless)编辑器框架、可组合的 UI 组件体系、以 CRDT 为核心的原生协作能力,并给出从零初始化、块树操作到自定义块的完整实战路径。读完本文,你将掌握 BlockSuite 的架构分层、PageEditor/EdgelessEditor的接入方式,以及如何基于BlockSpec扩展属于自己的编辑体验。

一、BlockSuite 是什么

People who are really serious about editor should make their own framework.

BlockSuite 是一套用于构建编辑器和协作应用的工具包,它独立实现了一系列内容编辑基础设施、UI 组件与编辑器本体(overview)。你可以把 BlockSuite 看作一个用于构建各种编辑器的 UI 组件库,底层由一个极简的无头框架作为运行时支撑。

借助 BlockSuite,你可以做到三件事:

  • 直接复用第一方编辑器PageEditor是基于块(block)的文档编辑器,支持深度定制;EdgelessEditor是支持按需开启 Canvas 渲染的图形编辑器,同时与PageEditor共享相同的富文本能力(page-editor、edgeless-editor)。
  • 组合与扩展:用丰富的 BlockSuite 组件(见 components overview)和示例(仓库 examples 目录)定制、扩展、增强这些编辑器。所有 BlockSuite 组件(含编辑器)都是原生 Web Components,与框架无关,易于和主流框架互操作。
  • 从零构建:基于底层无头框架从 scratch 打造全新编辑器。

BlockSuite 起源于 AFFiNE 知识库,其设计目标包括:

  • 支持多模态可编辑内容:知识作为唯一事实来源(single source of truth)时,其文本、幻灯片、思维导图、表格等多种视图不应依赖多套互不兼容的框架,而应有一致的框架支撑。
  • 组织与可视化复杂知识:现有编辑器大多聚焦单文档编辑,面对相互引用交织的复杂结构力不从心,因此框架需要原生管理跨多文档的状态。
  • 协作就绪:实时协作不应是事后插件,而应在编辑器状态管理中直接使用底层 CRDT 技术,构建更清晰、更可靠的数据流(见 CRDT-Native Data Flow)。

⚠️ BlockSuite 仍处于早期阶段,组件与扩展能力还在持续打磨中,文中 API 以当前仓库实际内容为准。

二、整体架构:框架层与组件层

BlockSuite 与 AFFiNE 的关系类似于 Monaco Editor 与 VSCode,但有一个关键差异:BlockSuite 并非由 AFFiNE 代码自动生成,而是以不同技术栈独立维护——AFFiNE 使用 React,而 BlockSuite 使用 Web Components。这一选择促使 BlockSuite 在包之间划清边界,保证:

  • AFFiNE 与其他项目通过组件平等地复用和扩展 BlockSuite,不享有任何特权;
  • 无论你使用 React 还是其他框架,BlockSuite 组件都能轻松复用。

据此,项目被划分为两组核心包(packages/framework/README.md):

框架层(Headless Framework)

包名职责
@blocksuite/store用于建模协作文档状态的数据层,原生构建在 CRDT 库 Yjs 之上,为所有 BlockSuite 文档提供内置的实时协作与时间旅行(time-travel)能力
@blocksuite/inline极简的富文本组件,用于行内编辑。BlockSuite 允许把不同块节点中的富文本拆分成多个 inline 编辑器,让复杂内容易于组合,显著降低实现传统富文本编辑特性的复杂度
@blocksuite/block-std框架无关的块建模库,能力覆盖块字段结构、事件、选区(selection)、剪贴板支持等

组件层(Prebuilt Components)

包名职责
@blocksuite/blocks组合预设编辑器所需的默认块实现,包括隶属于每个块的 widgets
@blocksuite/presets即插即用的可编辑组件,包含编辑器(PageEditor/EdgelessEditor)以及名为 fragments 的辅助 UI 组件(CopilotPanelDocTitle等)

@blocksuite/store的 CRDT 数据层、@blocksuite/inline的扁平化富文本、@blocksuite/block-std的无头块建模,共同构成这套"既是组件库又是框架"的架构基石。

三、四类核心组件:Editor / Fragment / Block / Widget

BlockSuite 将组件划分为四种类型(component-types):

  • Editor:以各种形式呈现文档内容的容器。不同编辑器由不同的 block spec 组合而成。
  • Block:在编辑器内构建文档的原子单元。注册 block spec 后,可在编辑器中渲染多个对应块实例。
  • Widget:按需在编辑器中情境化出现的辅助组件,如搜索栏、取色器。每个块都可定义自己的 widgets。
  • Fragment:编辑器之外的外部组件。它们与编辑器共享文档,但拥有各自的生命周期。

Editor 与 Fragment 的区别在于复杂度与功能:Fragment 通常提供更简化的能力、服务于特定 UI 目的,而 Editor 提供对块树的完整编辑能力;但两者共享相似的数据流(见 CRDT-Native Data Flow)。

Block 与 Widget 的关系:编辑器被架构为多个可编辑块的组装体,每个块 spec 封装数据 schema、视图、服务与逻辑;在每个块 spec 内可以有该块专属的 Widget,BlockSuite 正是借助这一机制在页面编辑器中注册了拖拽手柄(drag handle)、斜杠菜单(slash menu)等动态 UI(block-widgets)。

用块组合编辑器:type Editor = BlockSpec[]

在 BlockSuite 中,编辑器被设计得极其轻量,真正的可编辑块注册到EditorHost组件上——它是挂载块 UI 组件的容器。默认基于 lit 框架提供 host 实现,这是一个概念上可用的编辑器(component-types):

// 默认的 BlockSuite 可编辑块 import { PageEditorBlockSpecs } from '@blocksuite/blocks'; // 挂载块 UI 组件的容器 import { EditorHost } from '@blocksuite/lit'; // 操作块树的 store import { type Doc } from '@blocksuite/store'; // 标准 lit 框架原语 import { html, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; @customElement('simple-page-editor') export class SimplePageEditor extends LitElement { @property({ attribute: false }) doc!: Doc; override render() { return html` <editor-host .doc=${this.doc} .specs=${PageEditorBlockSpecs} ></editor-host> `; } }

换言之,可以把 BlockSuite 编辑器理解为:

type Editor = BlockSpec[];

真实仓库中PageEditor正是这一模式的体现——它是一个自定义 Web Component(packages/presets/src/editors/page-editor.ts),内部持有EditorHost的引用,并在渲染中挂载PageEditorBlockSpecs。只要存在对应的 host 实现,你也可以用 React、Vue 等框架的组件模型来实现自己的 BlockSuite 编辑器(framework-agnostic)。

一块多 Spec:同一文档,多种编辑体验

BlockSuite 鼓励从单一块模型派生出多种块 spec 实现。例如块树根节点(root block)在PageEditorEdgelessEditor中有不同的 spec 实现,但共享同一个RootBlockModel,两个 spec 分别作为各自编辑器的顶层 UI 组件。这让你能在同一份文档之上轻松实现多种编辑器,提供多样化的编辑体验与巨大的定制潜力。

四、快速上手:安装与第一个编辑器

从官方示例项目起步

BlockSuite 兼容所有常见框架,仓库 examples 提供了基于 BlockSuite 构建的 TodoMVC 风格笔记应用,覆盖以下框架:

框架示例目录
Vanilla(原生 TS)examples/vanilla-indexeddb
Next.jsexamples/react-basic-next
Reactexamples/react-basic
Vueexamples/vue-basic
Angularexamples/angular-basic
Preactexamples/preact-basic
Svelteexamples/svelte-basic
Solidexamples/solid-basic

例如 examples/react-basic/src/editor/editor.ts 展示了标准的初始化流程:先构造Schema并注册AffineSchemas,再创建DocCollectiondoc,在doc.load()回调中addBlock构建块树,最后创建AffineEditorContainer并挂载doc

在现有项目中从零集成

在已有项目中安装核心包(quick-start):

pnpm install \ @blocksuite/presets@canary \ @blocksuite/blocks@canary \ @blocksuite/store@canary

要点说明:

  • @blocksuite/presets包含预构建的编辑器与可选的附加 UI 组件;
  • 操作 BlockSuite 文档模型与第一方块需要@blocksuite/store@blocksuite/blocks
  • BlockSuite 的canary版本基于 master 分支每日发布,也被 AFFiNE 生产环境使用。

随后即可开箱即用地使用预构建的PageEditor,并为其挂载一个已初始化的doc实例(quick-start):

import '@blocksuite/presets/themes/affine.css'; import { createEmptyDoc, PageEditor } from '@blocksuite/presets'; import { Text } from '@blocksuite/store'; (async () => { // 以默认块树初始化编辑器 const doc = createEmptyDoc().init(); const editor = new PageEditor(); editor.doc = doc; document.body.appendChild(editor); // 给块节点写入初始文本内容 const paragraphs = doc.getBlockByFlavour('affine:paragraph'); const paragraph = paragraphs[0]; doc.updateBlock(paragraph, { text: new Text('Hello World!') }); })();

这里的PageEditor是标准 Web Component,也可以用<page-editor>HTML 标签复用。另一个EdgelessEditor用法类似——只需给editor挂上doc即可。

从源码运行与测试

如需在本地运行 BlockSuite 源码(BUILDING.md),确保已安装 Node.js 与 pnpm 后:

pnpm install pnpm dev

然后可以选择多个入口:localhost:5173/starter/?init推荐用于本地调试;localhost:5173/starter/列出所有 starter 预设;localhost:5173是包含本地优先(IndexedDB 持久化)与实时协作支持的完整示例。构建各包使用pnpm build。测试方面,E2E 使用 Playwright、单元测试使用 vitest:pnpm test以无头模式运行,pnpm test -- --debug以有头模式调试;BROWSER=firefox pnpm test可指定浏览器(支持firefox|webkit|chromium)。

五、块树基础:理解与操作文档模型

每个doc对象管理一棵独立的块树(working-with-block-tree),块通过BlockSchema定义字段与允许的嵌套关系(block-schema)。每个块类型有唯一的block.flavour,遵循namespace:name命名结构;由于预设编辑器源自 AFFiNE,默认可编辑块使用affine前缀。

操作块树的主要 API 有doc.addBlockdoc.updateBlockdoc.deleteBlockdoc.getBlockById。示例:

// 第一个块将作为根 const rootId = doc.addBlock('affine:page'); // 以空 props 在根下插入第二个块 const props = {}; const noteId = doc.addBlock('affine:note', props, rootId); // 还可提供可选的 parentIndex const paragraphId = doc.addBlock('affine:paragraph', props, noteId, 0); const modelA = doc.root!.children[0].children[0]; const modelB = doc.getBlockById(paragraphId); console.log(modelA === modelB); // true // 将段落类型更新为 'h1' doc.updateBlock(modelA, { type: 'h1' }); doc.deleteBlock(modelA);

注意:在把文档挂到编辑器之前,必须初始化一个合法有效的文档结构,这正是createEmptyDoc()之后需要init()的原因。同时,块树层级是预设编辑器专属约定——在框架层面,@blocksuite/store并不会对第一方affine:*块做任何特殊处理,你可以自由地在块树中加入不同 namespace 的块。

撤销与重做

所有对doc的块操作都会被自动记录,可用doc.undo()doc.redo()回退。默认情况下,一定时间内的操作会被自动合并为一条记录;若想在操作过程中显式添加历史记录,可在块操作之间插入doc.captureSync()

const rootId = doc.addBlock('affine:page'); const noteId = doc.addBlock('affine:note', props, rootId); // 现在就捕获一条历史记录 doc.captureSync(); // ...

这在一次添加多个块、但又希望逐个撤销时尤其有用。

编辑器中的块树:host 与 std

const { host } = editor; const { spec, selection, command } = host.std;

其中两个核心概念由 BlockSuite 的框架无关架构决定:

  • editor.host(即EditorHost组件)是挂载块 UI 组件的容器,负责把块树映射到组件树这一繁重工作。
  • 无论EditorHost用什么框架实现,都可以通过host.std访问同一套为可编辑块设计的无头标准库,例如std.spec中包含所有已注册的BlockSpec。通常用host.spec代替host.std.spec以简化代码。

六、选择块:SelectionManager 与原子选区

编辑器的本质是让用户动态选择并修改数据。BlockSuite 通过SelectionManager管理选区,可通过std.selectionhost.selection访问。例如在编辑器选中若干块后,可在控制台逐行执行:

// 获取当前选区状态 const cached = selection.value; // 清空当前选区状态 selection.clear(); // 从缓存恢复选区状态 selection.set(cached); // 尝试只设置部分选区 selection.set([cached[0]]);

block-stdSelectionManager实现了若干原子选区类型,如TextSelectionBlockSelection。用户当前选中的内容被自动划分为这些基本选区数据结构,记录在selection.value返回的列表中;通过selection.set()也可以编程方式控制编辑器的当前选区。

selection.value中,不同类型的选区可同时共存。每个选区对象至少记录对应块的idpath(从根块到该块的所有块 id 序列)。此外还可通过group字段进一步分类——例如在PageEditor中,TextSelectionBlockSelection都属于note组。上图块选区示例结构如下:

[ { type: 'block', group: 'note', path: ['root_id', 'note_id', 'paragraph_1_id'], }, { type: 'block', group: 'note', path: ['root_id', 'note_id', 'paragraph_2_id'], }, ];

对于更复杂的原生 Selection,TextSelection通过fromto字段标记原生选区在块中的起止位置,只记录各自块中行内文本序列的indexlength。这种简化之所以可行,是因为可编辑块使用@blocksuite/inline作为富文本组件——每个块树节点的富文本内容被独立渲染到不同 inline 编辑器中,消除了富文本实例之间的嵌套(flat-inlines)。

另外,整个selection.value对象在当次会话的clientId作用域内隔离。协作编辑时,不同客户端之间的选区实例会实时分发(通过 providers),便于实现远程光标等 UI 状态。高级用法参见 Selection 文档。

七、Service 与 Command:编辑器内的高层封装

对块树的操作往往需要进一步封装。比如根据选区 id 获取对应块模型,若手写会有些样板代码:

function getFirstSelectedModel(host: EditorHost) { const { selection, doc } = host; const firstSelection = selection.value[0]; const { path } = firstSelection; const leafId = path[path.length - 1]; const blockModel = doc.getBlockById(leafId); return blockModel; }

BlockSuite 鼓励把编辑器完全拆分为不同BlockSpec,这意味着编辑器全局可用的方法与属性也应落到块级别实现,因此引入了BlockService(block-service)。

Service:注册某类块专属的状态与方法

例如不必自己实现getFirstSelectedModel,可直接使用RootService上的快捷方式:

const rootService = host.spec.getService('affine:page'); // 获取选中块的模型 rootService.selectedModel; // 获取选中块的 UI 组件 rootService.selectedBlocks;

getService用于获取某块 spec 对应的 service。每个 service 是普通类,在host整个生命周期内以单例存在(含mounted生命周期钩子)。典型用途包括:

  • 对作为块树根节点的块,可在其 service 上注册面向应用开发者的常用编辑器 API;
  • 对需要动态配置的块,通过 service 传入对应选项(如 image block 的上传配置);
  • 对需要在编辑器加载时执行副作用(如订阅键盘快捷键)的块,在 service 的mounted回调中操作host,这样即使块尚不存在于块树中,对应逻辑也会执行。

Command:可复用的操作链

Service 示例中还使用了this.std.command(即CommandManager,command)。当需要把某些操作当作变量、动态构建控制流时,Command 机制尤为合适——它能把复杂操作序列记录为可复用的链,并简化操作之间的上下文共享

基本用法:

  • pipe方法用于开启新的命令链;
  • tryAll方法在当前链的上下文上依次执行多个子命令;
  • getSelectedBlockgetTextSelection等命令执行实际的块树操作;
  • inline方法把命令上下文对象上的状态传递到外部或执行其他副作用;
  • run方法最终执行命令链,执行后上下文被销毁。

值得强调的是:getSelectedBlock这类任务型命令并非由命令管理器实现,而是由各块自行注册——这正是 BlockSuite 将框架相关的host与框架无关的block-std分离的体现。

八、定义新块:schema + service + view

块 spec 由三大部分构成:schema(block-schema)、service(block-service)与view(block-view)。其中view的定义与所用前端框架相关。以基于@blocksuite/litPageEditor/EdgelessEditor为例,用 lit 原语定义块 spec:

import type { BlockSpec } from '@blocksuite/block-std'; import { literal } from 'lit/static-html.js'; const MyBlockSpec: BlockSpec = { schema: MyBlockSchema, // 用 defineBlockSchema 定义 service: MyBlockService, // 继承 BlockService // 在此定义 lit 组件 view: { component: literal`my-block-component`, widgets: { myToolbar: literal`my-toolbar`, myMenu: literal`my-menu`, }, }, };

该设计在易用性与可定制性之间取得平衡:service 与 view 都围绕 schema 构建,允许为同一块模型实现不同组件、服务与 widgets,从而做到:

  • 一块多视图:例如PageEditorEdgelessEditor对 root block 有不同的实现;
  • 不同的 widget 组合:例如移除全部 widgets 以组合出只读编辑器;
  • 基于不同前端框架实现同一块:只需为相应框架提供EditorHost中间件实现。

更简单的自定义方式:Embed Block

BlockSuite 还支持以更直接的方式定义最常见的自定义块——embed block。这种块不嵌套其他块,完全自行管理内部区域状态。以在PageEditor中展示 GitHub 链接卡片为例(源码见 packages/blocks/src/embed-github-block):

先定义强类型块模型:

import { BlockModel } from '@blocksuite/store'; import { defineEmbedModel } from '@blocksuite/blocks'; // 定义强类型块模型 export class EmbedGithubModel extends defineEmbedModel<{ owner: string; repo: string; }>(BlockModel) {}

再基于该模型定义 lit UI 组件:

import { EmbedBlockComponent } from '@blocksuite/blocks'; import type { EmbedGithubBlockModel } from './embed-github-model.js'; import { html } from 'lit'; import { customElement } from 'lit/decorators.js'; @customElement('affine-embed-github-block') export class EmbedGithubBlock extends EmbedBlockComponent<EmbedGithubModel> { // styles... override render() { return this.renderEmbed(() => { return html` <div class="affine-embed-github-block"> <h3>GitHub Card</h3> <div>${this.model.owner}/${this.model.repo}</div> </div> `; }); } }

然后定义对应BlockSpec

import { createEmbedBlock } from '@blocksuite/blocks'; import { EmbedGithubBlockModel } from './embed-github-model.js'; export const EmbedGithubBlockSpec = createEmbedBlock({ schema: { name: 'github', version: 1, toModel: () => new EmbedGithubModel(), props: () => ({ owner: '', repo: '', }), }, view: { component: literal`affine-embed-github-block`, }, });

最后,把该BlockSpec插入host.specs数组即可扩展新块类型:

// ... import { PageEditorBlockSpecs } from '@blocksuite/blocks'; import { EmbedGithubBlockSpec } from './embed-block-spec.js'; const editor = new PageEditor(); editor.specs = [...PageEditorBlockSpecs, EmbedGithubBlockSpec]; editor.doc = doc;

完成后即可把新块类型插入块树:

const props = { owner: 'toeverything', repo: 'https://github.com/toeverything/blocksuite', }; // 默认保留 'affine' 前缀,你也可以覆盖它 doc.addBlock('affine:embed-github', props, parentId);

关于跨框架支持,BlockSuite 与流行编辑器框架最大的区别在于:BlockSuite 没有自己的 DOM host,而是实现@blocksuite/lit之类的中间件,把块树映射到框架组件树。因此 BlockSuite 编辑器的整个内容区原生由不同框架掌控。默认选用 lit,是因为作为 Web Component 框架,lit 组件树本身就是 DOM 树,从而把"块树 → 组件树 → DOM 树"三阶段更新简化为"块树 → 组件(DOM)树"两阶段。

九、数据同步:Snapshot API 与文档流式传输

数据同步(即保存与加载文档)有两条路径(data-synchronization)。

Snapshot API:类似 editor.load() 的 JSON 方案

import { Job } from '@blocksuite/store'; const { collection } = doc; // 执行任务需要 job const job = new Job({ collection }); // 将当前 doc 内容导出为 snapshot JSON const json = await job.docToSnapshot(doc); // 将 snapshot JSON 导入到新 doc const newDoc = await job.snapshotToDoc(json);

Snapshot 保存的是doc块树的 JSON 表示,保留其嵌套结构。BlockSuite 还在 Snapshot 之上设计了 Adapter API,处理块树与 markdown、HTML 等第三方格式之间的转换(具体适配器实现见 packages/blocks/src/_common/adapters)。

文档流式传输:始终是ui = f(data)

与经典机制不同,BlockSuite 原生支持一种可在心智上类比 React Server Components 的状态管理策略:块树状态可直接作为可序列化数据,从服务器(或本地数据库)流式传输到客户端

这种情况下,服务器上存储的文档数据不再是 JSON,而始终是 CRDT 的二进制表示(类似 protobuf 或 RSC payload)。由于块树原生由 CRDT 实现、且状态更新时 CRDT 数据总是最先更新(见 CRDT-Native Data Flow),BlockSuite 编辑器的块树状态完全由 CRDT 数据驱动:

ui = f(data)

这等价于:每次更新 todo 列表项时先更新服务器,再用服务器返回的数据更新状态。借助 CRDT 自动解决冲突的能力,这个过程可以可靠地在本地完成并与远程文档同步。相比之下,传统编辑器通常只支持editor.load()之类的 API,更接近打了折扣的f(data)(state)模型,在应对多数据源的实时协作时复杂度更高。

在 BlockSuite 中,数据驱动的同步策略通过 providers 实现:

  • 创建新文档时,只需把doc连接到某个 provider(或多个 provider),块树的 CRDT 数据就会通过这些 provider 同步;
  • 加载已有文档时,创建新的空doc并连接到对应 provider,块树数据会从 provider 数据源流入:
import { AffineSchemas } from '@blocksuite/blocks'; import { AffineEditorContainer } from '@blocksuite/presets'; import { Schema } from '@blocksuite/store'; import { DocCollection, Text } from '@blocksuite/store'; import { IndexeddbPersistence } from 'y-indexeddb'; const schema = new Schema().register(AffineSchemas); const collection = new DocCollection({ schema }); collection.meta.initialize(); // 从一个空 doc 开始 const doc = collection.createDoc(); const editor = new AffineEditorContainer(); editor.doc = doc; document.body.append(editor); // Case 1. // 创建新 doc 时,这样 init,块会被自动写入 IndexedDB function createDoc() { new IndexeddbPersistence('provider-demo', doc.spaceDoc); doc.load(() => { const pageBlockId = doc.addBlock('affine:page', { title: new Text('Test'), }); doc.addBlock('affine:surface', {}, pageBlockId); const noteId = doc.addBlock('affine:note', {}, pageBlockId); doc.addBlock( 'affine:paragraph', { text: new Text('Hello World!') }, noteId ); }); } // Case 2. // 加载已有 doc 时,只需用 provider 回调加载内容 function loadDoc() { const provider = new IndexeddbPersistence('provider-demo', doc.spaceDoc); provider.on('synced', () => doc.load()); }

通过同时连接多个 provider,文档可自动同步到多种不同的后端(如 IndexedDB、WebSocket 等,见 react-indexeddb、react-sqlite、react-websocket 等示例)。

十、CRDT 原生数据流:协作为何与生俱来

传统上,CRDT 常被视为专精于冲突解决的技术。许多先为单用户设计的编辑器通过集成 CRDT 库实现实时协作,导致两种异构模型间的双向数据流(crdt-native-data-flow):

  • 本地模型更新时,把原生模型状态同步到 CRDT 模型;
  • 远端对端更新时,把 CRDT 模型解析出的数据同步回原生模型。

这种双向绑定难以可靠实现且需要不小的改动,同时应用层代码往往需要区分更新是否来自远端,增加了复杂度。

BlockSuite 的替代方案是直接以 CRDT 模型作为唯一事实来源(因使用 Yjs,也称YModel)。这意味着无论更新来自本地还是远端,都执行同一套流程:

  1. 先修改 YModel,触发包含本次更新全部增量状态变更的Y.Event
  2. 基于Y.Event更新块树中的模型节点;
  3. 更新块模型后发送对应 slot 事件,据此更新 UI 组件。

这一设计的优势是:应用层代码可以完全忽略块模型的更新来自本地编辑、历史栈还是与其他用户协作,只需订阅模型更新事件即可。

案例:删除一个段落块

假设当前块树结构为:

RootBlock NoteBlock ParagraphBlock 0 ParagraphBlock 1 ParagraphBlock 2

用户 A 选中ParagraphBlock 2并按删除键删除它,此时应调用doc.deleteBlock删除该块模型实例:

const blockModel = doc.root.children[0].children[2]; doc.deleteBlock(blockModel);

BlockSuite 并不会直接修改doc.root下的块树,而是首先修改底层 YBlock。CRDT 状态改变后,Yjs 生成对应的Y.Event数据结构(类似 git 和虚拟 DOM 中的增量补丁),BlockSuite 始终以此为基准同步块模型,再触发对应的 slot 事件完成 UI 更新。

本例中,作为ParagraphBlock 2父节点的NoteBlock会触发model.childrenUpdatedslot 事件,使 UI 框架组件树中的对应组件自我刷新。由于每个子块都有 id,这非常有利于配合 UI 框架常见的列表 key 优化,实现按需的块组件更新。

真正的威力在于:如果该块树被多人并发编辑,当用户 B 执行类似操作时,对应更新会被 Yjs 编码并由 provider 分发。当用户 A 收到并应用用户 B 的更新时,会触发与本地编辑完全相同的状态更新管线,应用无需为协作场景做任何额外修改或适配,便天然获得实时协作能力。

单向更新流

除了以 CRDT 为唯一事实来源的块树,BlockSuite 还管理不需要变更历史的共享状态(如每个用户光标位置的 awareness 状态),以及可能不向所有用户共享的用户元数据。这些状态的管理遵循一致的单向模式,完整的状态更新过程包含三个步骤:

  1. UI 事件处理:视图组件产生点击、拖拽等 UI 事件并触发对应回调,推荐用 command 建模和复用这些交互;
  2. 通过命令操作状态:命令可操作编辑器状态以完成 UI 更新;
  3. 状态驱动视图更新:状态变更后,通过 slot 事件通知并更新视图组件。

关于 command、view、event 等概念的深入理解,可参考各自文档章节。

总结

以 CRDT 模型为唯一事实来源,应用层代码无需关心更新源于本地还是远端,简化了同步并降低了复杂度——这让应用无需侵入式修改即可获得实时协作能力,也是 BlockSuite 编辑器从诞生第一天起就"天生协作"的关键原因。

回到本文主线:BlockSuite 通过**无头框架(store / inline / block-std)预构建组件(blocks / presets)**的清晰分层,把"内容编辑技术栈"落到了可组合、可扩展、可协作的工程实践上。入门路径建议为:先跑通 quick-start 示例,理解 组件类型 与块树操作,再按需阅读 Command、Selection、Adapter 等进阶文档,最终基于BlockSpec构建属于你自己的编辑器。

【免费下载链接】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),仅供参考

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

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

立即咨询