BlockSuite Block Service 深入指南:自定义块级逻辑、生命周期与运行时配置
【免费下载链接】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 编辑框架中,每一种块(Block)都可以通过注册自己的 Service 来定义编辑器生命周期内可被调用的块级专属方法。本文以官方文档 block-service.md 为主线,结合@blocksuite/block-std与@blocksuite/blocks的源码实现,系统讲解 Block Service 的定义方式、单例实例化机制、生命周期钩子、快捷键绑定、事件处理以及图片代理等运行时配置的实战写法,帮助开发者掌握自定义块扩展的标准姿势。
Block Service 是 BlockSuite Block Spec 三大组成部分(schema / service / view)中的核心一环:Schema 定义数据结构,Service 承载块级行为逻辑,View 负责渲染。理解 Service 就能理解 BlockSuite 中"如何在编辑器加载期间为某类块注入能力"。
什么是 Block Service
在 BlockSuite 中,每种块类型可以注册自己的 Service,用于定义在编辑器生命周期中需要被调用的块级专属方法。Service 是一个继承自BlockService类的类,来自@blocksuite/block-std包:
import { BlockService } from '@blocksuite/block-std'; import { defineBlockSchema, type SchemaToModel } from '@blocksuite/store'; const myBlockSchema = defineBlockSchema({ //... 定义块的字段与结构 }); type MyBlockModel = SchemaToModel<typeof myBlockSchema>; class MyBlockService extends BlockService<MyBlockModel> { //... 自定义块级逻辑 }这里的泛型参数MyBlockModel由SchemaToModel<typeof myBlockSchema>推导而来,使得 Service 内部访问this.doc、this.host等成员时能获得类型安全的块模型提示。块 Schema 的完整定义方式可参考 Block Schema 指南。
单例实例化机制
对于每一种块类型,其 Service 只会被实例化一次;并且即使编辑器里没有任何该类型的块实例,Service 依然会被实例化。因此,Service 被设计为"为某种块定义编辑器级方法"的载体——它与块实例的生命周期解耦,而与编辑器的生命周期绑定。
这一点在源码中有直接体现。查看 spec-store.ts 中的_diffServices方法:
- 每当
applySpecs被调用(例如编辑器初始化或动态更换 Spec 时),框架会比较新旧 Spec 映射; - 对于新出现的 flavour,若
_services中尚不存在对应 Service,则用newSpec.service ?? BlockService实例化它(未指定 service 时回退到基类),随后调用service.mounted(); - 对于被移除的 flavour,则依次调用
service.dispose()与service.unmounted()并从 Map 中删除。
也就是说,Service 的创建/销毁完全由 Spec 的应用与卸载驱动,与页面里是否存在对应块无关。这也解释了为什么"为某类块绑定全局快捷键"这类与块数量无关的逻辑适合放在 Service 中。
与 Block Spec 的关系
Block Service 是 Block Spec 的一个属性,通过service: MyBlockService字段挂载到 Spec 上:
import type { BlockSpec } from '@blocksuite/block-std'; import { literal } from 'lit/static-html.js'; const MyBlockSpec: BlockSpec = { schema: MyBlockSchema, service: MyBlockService, view: { component: literal`my-block-component`, widgets: { myBlockToolbar: literal`my-block-toolbar`, myBlockMenu: literal`my-block-menu`, }, }, };当编辑器以affine:page等根块 Spec 组装时,SpecStore会遍历所有 Spec 并完成 Service 的注册与实例化。
生命周期钩子(Lifecycle Hooks)
BlockService基类提供了两个生命周期钩子供子类覆写:
mounted:Service 被实例化时调用。unmounted:Service 被销毁时调用。
源码中基类的默认实现会通过specSlots向外广播对应事件(见 service/index.ts):
mounted() { this.specSlots.mounted.emit({ service: this }); } unmounted() { this.specSlots.unmounted.emit({ service: this }); }同时SpecStore在unmount()时会对所有 Service 依次执行dispose()与unmounted()(见 spec-store.ts),dispose()会释放DisposableGroup中注册的所有资源。因此,在mounted中通过this.disposables.add(...)注册的监听器无需手动清理,Service 销毁时会自动释放——这正是bindHotkey、handleEvent的注册都返回Disposable并被加入disposables的原因。
BlockSpecSlots中还额外提供了viewConnected/viewDisconnected/widgetConnected/widgetDisconnected等与视图和挂件连接状态相关的事件槽位(见 slots.ts),可用于在块视图挂载/卸载时做更细粒度的响应。
实战示例:在 mounted 中绑定创建块的快捷键
官方文档给出了一个典型场景:在 Service 中为某类块绑定"新建块"的快捷键。即使页面上暂时没有这类块,快捷键依然全局生效,新建后的块会立即出现在文档中:
class MyBlockService extends BlockService<MyBlockModel> { override mounted() { super.mounted(); this.bindHotkey( { 'Alt-1': this._addMyBlock, }, { global: true } ); } private _addMyBlock = () => { this.doc.addBlock('my-block', {}); }; }代码要点说明:
super.mounted()必须先调用,以触发基类的specSlots.mounted广播;bindHotkey的第二个参数{ global: true }表示快捷键在所有块上下文中生效;若不传该选项,快捷键会被限制在当前块 flavour 的上下文中触发;_addMyBlock使用箭头函数属性,保证回调中的this始终指向 Service 实例;this.doc.addBlock('my-block', {})通过std暴露的当前文档句柄创建块,块会追加到文档根下。
从源码看,bindHotkey的实际实现是(service/index.ts):
bindHotkey(keymap: Record<string, UIEventHandler>, options?: { global: boolean }) { this.disposables.add( this.uiEventDispatcher.bindHotkey(keymap, { flavour: options?.global ? undefined : this.flavour, }) ); }global: true时 flavour 为undefined,快捷键不限定 flavour;否则事件会被过滤为仅当焦点位于该 flavour 块内时才触发。事件机制细节可参考 事件指南。
事件处理:handleEvent
与bindHotkey平行的还有handleEvent方法,用于注册一般的 UI 事件处理器:
handleEvent( name: EventName, fn: UIEventHandler, options?: { global: boolean } ) { this.disposables.add( this.uiEventDispatcher.add(name, fn, { flavour: options?.global ? undefined : this.flavour, }) ); }两者的注册方式与 flavour 过滤语义完全一致,都通过this.std.event(UI 事件分发器)完成,且都会自动纳入disposables管理。区别在于:bindHotkey面向快捷键组合(如'Alt-1'),handleEvent面向原生事件名(如click、keydown等)。
便捷访问器:从 Service 访问编辑器上下文
BlockService基类为子类提供了一组只读访问器,让你在 Service 方法内部可以方便地触达编辑器核心对象(service/index.ts):
| 访问器 | 返回对象 | 说明 |
|---|---|---|
this.collection | BlockCollection | 文档集合,可访问所有文档元信息 |
this.doc | Doc | 当前文档,用于addBlock、updateBlock等数据操作 |
this.host | EditorHost | 编辑器宿主元素,可用于renderModel等渲染操作 |
this.selectionManager | SelectionManager | 选区管理,可注册/操作块选区 |
this.uiEventDispatcher | UIEventDispatcher | UI 事件分发器,bindHotkey/handleEvent的底层依赖 |
this.std | BlockStdScope | 标准作用域对象,框架级 API 的入口 |
this.flavour | string | 当前 Service 对应的块 flavour |
this.specSlots | BlockSpecSlots | Spec 事件槽,见上文生命周期钩子 |
这些访问器让 Service 天然具备了"操作文档数据 + 响应 UI 事件 + 管理选区"的完整能力,是编写块级行为逻辑的基础设施。
设置运行时配置(Set Runtime Configs)
有时你可能希望为某些块设置运行时配置以更好地贴合自身需求。官方文档以图片块为例:默认情况下,图片块会使用 AFFiNE 的图片代理来绕过 CORS 限制;在自托管(self-hosted)场景下,默认代理不可用,你可能需要设置自己的图片代理中间件 URL。
图片代理 URL 的默认值与覆盖机制
默认图片代理端点在 affine/shared/src/consts/index.ts 中定义:
export const DEFAULT_IMAGE_PROXY_ENDPOINT = 'https://affine-worker.toeverything.workers.dev/api/worker/image-proxy';代理中间件的"可覆写"机制实现在 middlewares.ts:customImageProxyMiddleware(url)生成一个 Job 中间件,把imageProxy写入adapterConfigs;而imageProxyMiddlewareBuilder维护一个"当前中间件"闭包,setImageProxyMiddlewareURL就是其set方法——调用一次即可全局替换默认中间件,影响后续所有导入/导出适配器对该配置的读取。
图片块 Service 将其暴露为静态方法(image-service.ts):
static setImageProxyURL = setImageProxyMiddlewareURL;通过 editorHost 获取 Service 并设置配置
官方文档给出的调用方式是:先拿到editor-host元素,再通过其spec.getService('affine:image')获取图片块 Service,最后调用具体方法设置运行时配置:
import type { ImageService } from '@blocksuite/blocks'; const editorRoot = document.querySelector('editor-host'); if (!editorRoot) return; const imageService = editorRoot.spec.getService('affine:image') as ImageService; // 调用具体方法设置运行时配置 imageService.setImageProxyURL('https://example.com/image-proxy');从源码看,spec.getService(flavour)是由SpecStore.getService实现的(spec-store.ts):它以 flavour 为键从_servicesMap 中取出已实例化的 Service 单例并返回,类型上支持BlockSuite.ServiceKeys泛型约束,返回的 Service 类型会随 flavour 自动收窄(例如affine:image对应ImageService)。
不同块设置运行时配置的方法可能不同,需要查阅各块的 API 文档或源码来确定。以图片块为例,除了代理 URL,还可以在实例上直接覆盖一些公开字段,例如:
imageService.maxFileSize = 50 * 1000 * 1000; // 将默认 10MB 的拖放/粘贴图片大小上限调至 50MBmaxFileSize默认值为10 * 1000 * 1000(10MB),在 image-service.ts 中定义,并被文件拖放管理器(FileDropManager)及addSiblingImageBlock等工具用于校验图片大小(见 utils.ts)。
注:
editorRoot.spec的形态可能因使用PageEditor/EdgelessEditor预设组件还是自建EditorHost而略有差异,但spec.getService是统一的服务获取入口。
真实仓库中的 Service 实战:ParagraphBlockService 与 ImageBlockService
为了更直观地理解 Service 的用法,下面剖析仓库中两个真实的 Service 实现。
ParagraphBlockService:mounted 中装配内联编辑器
paragraph-service.ts 中的ParagraphBlockService展示了如何在mounted阶段完成编辑器装配工作:
export class ParagraphBlockService< TextAttributes extends AffineTextAttributes = AffineTextAttributes, > extends BlockService<ParagraphBlockModel> { readonly inlineManager = new InlineManager<TextAttributes>(); placeholderGenerator: (model: ParagraphBlockModel) => string = model => { if (model.type === 'text') { return "Type '/' for commands"; } const placeholders = { h1: 'Heading 1', h2: 'Heading 2', h3: 'Heading 3', h4: 'Heading 4', h5: 'Heading 5', h6: 'Heading 6', quote: '', }; return placeholders[model.type]; }; readonly referenceNodeConfig = new ReferenceNodeConfig(); override mounted(): void { super.mounted(); this.referenceNodeConfig.setDoc(this.doc); const inlineSpecs = getAffineInlineSpecsWithReference( this.referenceNodeConfig ); this.inlineManager.registerSpecs(inlineSpecs); this.inlineManager.registerMarkdownMatches(affineInlineMarkdownMatches); } }它演示了 Service 的三个常见用途:
- 持有编辑器级共享状态:
inlineManager、referenceNodeConfig等实例字段供块视图组件在渲染时使用; - 在
mounted中完成一次性初始化:注册内联渲染 Spec 与 Markdown 匹配规则(如输入#转标题、-转列表等行内 Markdown 快捷语法),并为referenceNodeConfig绑定当前文档; - 通过字段暴露可覆写的配置:
placeholderGenerator是一个可被外部替换的函数字段,用于按块类型生成占位文案(普通文本显示Type '/' for commands,标题显示Heading 1~6等)。
ImageBlockService:生命周期内的复杂能力装配
image-service.ts 中的ImageBlockService在mounted中注册了选区类型、文件拖放管理器与拖拽手柄选项:
override mounted(): void { super.mounted(); this.selectionManager.register(ImageSelection); this.fileDropManager = new FileDropManager(this, this._fileDropOptions); this.disposables.add( AffineDragHandleWidget.registerOption(this._dragHandleOption) ); }可以看到三个关键点:
this.selectionManager.register(ImageSelection):为图片块注册专属选区类型,支撑图片的块级选中与后续拖拽;FileDropManager:接管图片文件的拖放/粘贴,_fileDropOptions中定义了按 flavour 过滤、判定拖放目标(文档流内还是 edgeless 画布内)并调用addSiblingImageBlock或edgelessRoot.addImages创建图片块的完整逻辑;AffineDragHandleWidget.registerOption(this._dragHandleOption):注册块拖拽手柄选项,返回的Disposable被加入disposables,Service 销毁时自动解除注册——这也是前文所说"生命周期自动清理"的最佳实践。
这些实现印证了 Block Service 的本质:它是某类块在编辑器生命周期内的"能力中枢",负责把选区、快捷键、拖放、渲染所需配置等跨组件能力统一装配起来,并且只装配一次。
总结与最佳实践
- Service 是单例:每种 flavour 的 Service 在编辑器中只实例化一次,与块实例数量无关,适合承载编辑器级块逻辑(快捷键、事件、共享配置等)。
- 生命周期钩子:在
mounted中完成初始化(务必先调用super.mounted()),unmounted中做清理;通过this.disposables.add(...)注册的资源在dispose()时自动释放,无需手动清理。 - 绑定快捷键与事件:
bindHotkey用于快捷键组合,handleEvent用于一般 UI 事件;两者都支持{ global: true }以忽略 flavour 过滤。 - 运行时配置:通过
editorRoot.spec.getService(flavour)获取 Service 实例后,调用其公开方法或覆写公开字段即可调整行为(如setImageProxyURL、maxFileSize);不同块的配置方法不同,应以各块 API 文档为准。 - 参考实现:段落块的
ParagraphBlockService(内联编辑器装配)、图片块的ImageBlockService(选区 + 拖放 + 拖拽)是仓库内最值得阅读的 Service 范本。
如需进一步了解 Service 在整体架构中的位置,可继续阅读 Block Spec 总览、Block View 指南 与 Block Schema 指南;底层事件与数据模型可参考 事件指南 与 Store 指南。
【免费下载链接】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),仅供参考