资讯动态

tldraw 深度解析:用 sideEffects.registerBeforeDeleteHandler 拦截并取消图形删除

发布时间:2026/9/8 22:57:26 来源:尧图企业网站定制
tldraw 深度解析:用 sideEffects.registerBeforeDeleteHandler 拦截并取消图形删除【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. Worlds best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw本文围绕 tldraw 官方示例 Before delete shape 展开,讲解如何在图形从 store 中被移除之前插入拦截逻辑:通过editor.sideEffects.registerBeforeDeleteHandler注册 before-delete 处理器,返回false即可取消删除。文中给出可完整运行的示例代码,并深入packages/store源码,还原该拦截机制从Editor删除调用到Store.remove落地的完整执行链,以及它在单元测试中被验证的行为边界。一、这个示例要解决什么问题tldraw 中,形状(Shape)只是 store 里的一种记录(Record)。删除一个形状,本质上就是store.remove一次记录删除操作。而 tldraw 的Side Effects(副作用管理器,又称correct state enforcer)机制,允许你在记录的创建、更新、删除的前后挂入处理器,用来校验数据、变换记录、维护业务规则。官方示例文档 before-delete-shape/README.md 的核心结论是:editor.sideEffects.registerBeforeDeleteHandlerruns before a record is removed from the store. Returnfalseto cancel the deletion; return nothing to let it proceed. (该处理器在记录从 store 中移除之前运行。返回false取消删除;返回什么都不做则放行。)示例的场景是:红色文字图形不可被删除,其他图形可以自由删除。验证方式是依次选中画布上的文字图形并按 Delete 键——红色那个无论你怎么删都删不掉:直接按删除、全选后删除、甚至剪贴(Cut)都会被拦截。但文档也明确划定了边界:这个钩子只守卫删除这一种操作,用户仍然可以先把图形重新着色,再把它删掉。二、完整示例代码:红色形状不可删以下是该示例的完整实现 BeforeDeleteShapeExample.tsx,可直接复制到你的 tldraw 应用中运行:import { Editor, Tldraw, createShapeId, toRichText } from tldraw import tldraw/tldraw.css export default function BeforeDeleteShapeExample() { return ( div classNametldraw__editor Tldraw onMount{(editor) { // 核心:注册 shape 的 before-delete 处理器 editor.sideEffects.registerBeforeDeleteHandler(shape, (shape) { if (color in shape.props shape.props.color red) { return false // 取消删除 } return // 放行删除 }) createDemoShapes(editor) }} / /div ) } function createDemoShapes(editor: Editor) { editor .createShapes([ { id: createShapeId(), type: text, props: { richText: toRichText(Red shapes cant be deleted), color: red, }, }, { id: createShapeId(), type: text, y: 30, props: { richText: toRichText(but other shapes can), color: black, }, }, ]) .zoomToFit({ animation: { duration: 0 } }) }三个关键细节:注册时机在onMount回调里。此时editor实例已经就绪,在渲染第一帧之前完成钩子注册,避免用户操作早于注册完成。color in shape.props防御性判断。示例注册的是shape这一通用类型名,处理器会收到所有形状的删除请求;而color这个 prop 并非每种形状都有(例如某些形状的 props 结构不同),所以先用in运算符确认属性存在再比较,避免运行时读到undefined。返回值语义:return false表示拦截这条删除,return(即返回undefined)表示放行。这对应处理器类型签名void | false——只有精确的false才构成否决,其他任何返回值(包括true、数字)都不拦截。三、API 签名:注册函数接收什么、返回什么从源码 StoreSideEffects.ts 看,处理器类型定义为:export type StoreBeforeDeleteHandlerR extends UnknownRecord ( record: R, source: remote | user ) void | false而注册方法registerBeforeDeleteHandler(StoreSideEffects.ts#L609-L617)的签名是:registerBeforeDeleteHandlerT extends R[typeName]( typeName: T, handler: StoreBeforeDeleteHandlerR { typeName: T } )参数/返回值说明typeName要监听的记录类型名,如shape、page。处理器按类型名分桶存储(_beforeDeleteHandlers[record.typeName]),只会命中对应类型的删除record即将被删除的完整记录,类型被收窄为R { typeName: T },所以注册shape时shape.props有完整类型提示source这次删除操作来自user(本地用户交互)还是remote(远端同步合并)。从Store.remove的源码可见,source由this.isMergingRemoteChanges ? remote : user判定返回值false取消该记录的删除返回值void允许删除继续进行返回值(注册函数)一个 dispose 回调,调用它可注销这个处理器,方便在组件卸载时清理注意第二个参数source给了你一个很实用的能力:只对本地用户删除做拦截,放行远端协作方的删除——例如本机用户没有权限删除,但服务端或房主发起的删除应当生效这类场景,只需在处理器里判断source user再决定是否return false。四、底层执行链:一条删除请求如何被拦截这是理解为什么直接删除、全选删除、剪贴(Cut)都会被拦住的关键。在 Editor.ts#L9150-L9180 中,Editor.deleteShapes最终收敛为一次 store 级删除:deleteShapes(_ids: TLShapeId[] | TLShape[]): this { // ... 展开形状及其子节点,得到 allShapeIdsToDelete return this.run(() this.store.remove([...allShapeIdsToDelete])) }也就是说,tldraw 里几乎所有删除入口(Delete 键、右键菜单删除、全选后删除、剪贴、editor.deleteShape/editor.deleteShapes编程调用)最终都汇入同一个store.remove。而 Store.ts#L697-L725 的remove实现里,拦截正是发生在原子操作内部:remove(ids: IdOfR[]): void { this.atomic(() { const toDelete new SetIdOfR(ids) const source this.isMergingRemoteChanges ? remote : user if (this.sideEffects.isEnabled()) { for (const id of ids) { const record this.records.__unsafe__getWithoutCapture(id) if (!record) continue if (this.sideEffects.handleBeforeDelete(record, source) false) { toDelete.delete(id) // 从待删集合中剔除该记录 } } } const actuallyDeleted this.records.deleteMany(toDelete) // ... 只有真正被删的记录才写入历史与 after 事件 diff }) }要点:拦截不是整个操作失败,而是逐记录粒度:被return false命中的 id 只是从toDelete集合中剔除,同批其他记录照常删除,历史记录(updateHistory)和 after 事件也只包含实际被删的记录。editor.sideEffects并不神秘——Editor.ts#L580 中它就是this.store.sideEffects的别名,编辑器与 store 共享同一个副作用管理器。再看 StoreSideEffects.ts#L338-L350 的handleBeforeDelete:handleBeforeDelete(record: R, source: remote | user) { if (!this._isEnabled) return true const handlers this._beforeDeleteHandlers[record.typeName] as StoreBeforeDeleteHandlerR[] if (handlers) { for (const handler of handlers) { if (handler(record, source) false) { return false } } } return true }两个行为规则由源码直接给出:按注册顺序串行执行,一票否决:同一类型可以注册多个 before-delete 处理器,它们按注册先后依次调用,任意一个返回false整个删除就被取消,后续处理器不再执行。副作用开关可整体旁路:_isEnabled为false时直接放行,处理器根本不会被调用。五、单元测试如何验证这些行为tldraw 仓库为这套机制提供了成体系的测试 StoreSideEffects.test.ts,两条用例直接印证了示例的行为边界:SE4:返回false只阻止那一条删除(StoreSideEffects.test.ts#L179-L195):it([SE4] beforeDelete can return false to prevent that deletion only, () { store.put([book1, book2]) store.sideEffects.registerBeforeDeleteHandler(book, (record) { if (record.id book1Id) return false return undefined }) const afterDelete vi.fn() store.sideEffects.registerAfterDeleteHandler(book, afterDelete) store.remove([book1Id, book2Id]) expect(store.has(book1Id)).toBe(true) // book1 被保住 expect(store.has(book2Id)).toBe(false) // book2 正常删除 expect(afterDelete).toHaveBeenCalledTimes(1) expect(afterDelete.mock.calls[0][0].id).toBe(book2Id) // after 钩子只收到 book2 })这条测试精确对应了示例里的全选后删除场景:批量删除中,受保护的记录存活,不受保护的记录删除并触发 afterDelete,互不干扰。SE6:副作用被禁用时,拦截器失效(StoreSideEffects.test.ts#L240-L268):用store.atomic(() store.remove([book1Id]), false)(第二参数false表示本次原子操作不启用 side effects)时,beforeDelete即使return false也无法阻止删除,beforeCreate / afterCreate / beforeDelete / afterDelete / operationComplete全部不会被调用。这提示你在做临时批量数据修复、导入等不希望被钩子干扰的操作时,可以借atomic的开关参数绕开所有处理器。此外,StoreSideEffects.test.ts#L226-L238 还验证了处理器确实会收到source参数:普通操作得到user,而store.mergeRemoteChanges包裹的操作得到remote。六、边界与常见误解它只拦截删除,不拦截变红再删。文档原话:This only guards deletions: the user can still recolor the shape and then delete it. 拦截器在删除那一刻读取记录状态做判断,它不会阻止用户先把color改成black。若需要图形一旦被标记为受保护就永久不可删,应把受保护标记放在删除处理器能读到的、且用户改不动的地方(比如自定义的、只由代码写入的 prop,配合registerBeforeChangeHandler防止该 prop 被修改)。before-delete 只应处理正在被删的记录本身。从 StoreSideEffects.ts 的文档注释看,官方明确建议:如果你想响应某个删除去修改其他记录(例如级联清理关联箭头),应该用registerAfterDeleteHandler而不是在这里动手。注销处理器:注册函数返回的回调就是注销函数。示例组件是常驻页面所以不需要,但如果在可热切换的场景中动态注册,记得在卸载时调用它,避免处理器累积——由于任意一个返回 false 即否决,残留的旧处理器可能造成删不掉的幽灵行为。多处理器按序执行且短路:后注册的处理器感知不到前面谁否决了,设计多规则拦截(如权限 锁定状态)时要考虑注册顺序。七、小结能力实现位置注册删除拦截器(示例使用的 API)StoreSideEffects.ts拦截判定(串行执行、 false否决)StoreSideEffects.ts删除落点(逐记录剔除、历史/diff 只含实删记录)Store.tsdeleteShapes收敛到store.removeEditor.ts行为验证(部分拦截、source 参数、开关旁路)StoreSideEffects.test.ts官方可运行示例BeforeDeleteShapeExample.tsxregisterBeforeDeleteHandler之所以一处注册、全链路生效,根因在于 tldraw 把删除统一收敛到store.remove这一个落点,而 side effects 就挂在这个落点的原子操作内部。理解这一点后,你不仅能复刻红色形状不可删的演示,还能把同样的模式用于权限控制、锁定资源保护、协作场景下按source区分本地与远端删除,以及结合registerAfterDeleteHandler完成级联清理。【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. Worlds best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价