ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

BlockSuite Block Service 深入指南:自定义块级逻辑、生命周期与运行时配置

BlockSuite Block Service 深入指南:自定义块级逻辑、生命周期与运行时配置 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 SchemaToModeltypeof myBlockSchema; class MyBlockService extends BlockServiceMyBlockModel { //... 自定义块级逻辑 }这里的泛型参数MyBlockModel由SchemaToModeltypeof 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: literalmy-block-component, widgets: { myBlockToolbar: literalmy-block-toolbar, myBlockMenu: literalmy-block-menu, }, }, };当编辑器以affine:page等根块 Spec 组装时SpecStore会遍历所有 Spec 并完成 Service 的注册与实例化。生命周期钩子Lifecycle HooksBlockService基类提供了两个生命周期钩子供子类覆写mountedService 被实例化时调用。unmountedService 被销毁时调用。源码中基类的默认实现会通过specSlots向外广播对应事件见 service/index.tsmounted() { this.specSlots.mounted.emit({ service: this }); } unmounted() { this.specSlots.unmounted.emit({ service: this }); }同时SpecStore在unmount()时会对所有 Service 依次执行dispose()与unmounted()见 spec-store.tsdispose()会释放DisposableGroup中注册的所有资源。因此在mounted中通过this.disposables.add(...)注册的监听器无需手动清理Service 销毁时会自动释放——这正是bindHotkey、handleEvent的注册都返回Disposable并被加入disposables的原因。BlockSpecSlots中还额外提供了viewConnected/viewDisconnected/widgetConnected/widgetDisconnected等与视图和挂件连接状态相关的事件槽位见 slots.ts可用于在块视图挂载/卸载时做更细粒度的响应。实战示例在 mounted 中绑定创建块的快捷键官方文档给出了一个典型场景在 Service 中为某类块绑定新建块的快捷键。即使页面上暂时没有这类块快捷键依然全局生效新建后的块会立即出现在文档中class MyBlockService extends BlockServiceMyBlockModel { 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.tsbindHotkey(keymap: Recordstring, 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.eventUI 事件分发器完成且都会自动纳入disposables管理。区别在于bindHotkey面向快捷键组合如Alt-1handleEvent面向原生事件名如click、keydown等。便捷访问器从 Service 访问编辑器上下文BlockService基类为子类提供了一组只读访问器让你在 Service 方法内部可以方便地触达编辑器核心对象service/index.ts访问器返回对象说明this.collectionBlockCollection文档集合可访问所有文档元信息this.docDoc当前文档用于addBlock、updateBlock等数据操作this.hostEditorHost编辑器宿主元素可用于renderModel等渲染操作this.selectionManagerSelectionManager选区管理可注册/操作块选区this.uiEventDispatcherUIEventDispatcherUI 事件分发器bindHotkey/handleEvent的底层依赖this.stdBlockStdScope标准作用域对象框架级 API 的入口this.flavourstring当前 Service 对应的块 flavourthis.specSlotsBlockSpecSlotsSpec 事件槽见上文生命周期钩子这些访问器让 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.tscustomImageProxyMiddleware(url)生成一个 Job 中间件把imageProxy写入adapterConfigs而imageProxyMiddlewareBuilder维护一个当前中间件闭包setImageProxyMiddlewareURL就是其set方法——调用一次即可全局替换默认中间件影响后续所有导入/导出适配器对该配置的读取。图片块 Service 将其暴露为静态方法image-service.tsstatic 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 * 100010MB在 image-service.ts 中定义并被文件拖放管理器FileDropManager及addSiblingImageBlock等工具用于校验图片大小见 utils.ts。注editorRoot.spec的形态可能因使用PageEditor/EdgelessEditor预设组件还是自建EditorHost而略有差异但spec.getService是统一的服务获取入口。真实仓库中的 Service 实战ParagraphBlockService 与 ImageBlockService为了更直观地理解 Service 的用法下面剖析仓库中两个真实的 Service 实现。ParagraphBlockServicemounted 中装配内联编辑器paragraph-service.ts 中的ParagraphBlockService展示了如何在mounted阶段完成编辑器装配工作export class ParagraphBlockService TextAttributes extends AffineTextAttributes AffineTextAttributes, extends BlockServiceParagraphBlockModel { readonly inlineManager new InlineManagerTextAttributes(); 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被加入disposablesService 销毁时自动解除注册——这也是前文所说生命周期自动清理的最佳实践。这些实现印证了 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),仅供参考
返回列表