tldraw SDK:用 After-Create / After-Change 副作用处理器跨记录维持数据不变量
【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw
在基于 tldraw SDK 构建无限画板应用时,经常需要让一份数据的变化自动"约束"另一份数据,例如"同一页面上只能有一个红色图形"。本文基于官方示例 after-create-update-shape 展开,完整讲解editor.sideEffects.registerAfterCreateHandler与registerAfterChangeHandler的语义、配套的可运行示例代码,以及底层 StoreSideEffects 的调用链,帮你掌握用副作用钩子(side effects)在记录写入 store 之后更新其他记录的正确姿势。
示例要解决的问题:页面上只能存在一个红色图形
该示例的核心思路只有一句话:当某个 shape 被创建或被修改后,如果它恰好是红色的,就把同一页面上其他所有红色图形改成黑色。这样"至多一个红色 shape"的不变量始终成立。
README 中给出的手动验证方式是:选中任意一个黑色文字,在样式面板里把它的颜色改成红色,原本红色的那个文字会立刻变黑。
为什么必须同时注册 create 和 change 两种处理器?因为一个红色 shape 可能以两种方式进入画板:
- 直接被创建出来时 props 就是
color: 'red'(触发 after-create); - 先以黑色创建,随后被用户或代码改成红色(触发 after-change)。
只监听其中一种路径都会留下漏洞。示例组件 AfterCreateUpdateShapeExample.tsx 中的注册代码如下:
<Tldraw onMount={(editor) => { editor.sideEffects.registerAfterCreateHandler('shape', (shape) => { ensureOnlyOneRedShape(editor, shape.id) }) editor.sideEffects.registerAfterChangeHandler('shape', (_prevShape, nextShape) => { ensureOnlyOneRedShape(editor, nextShape.id) }) createDemoShapes(editor) }} />两个处理器都委托给同一个函数ensureOnlyOneRedShape,下面完整拆解它的实现。
核心实现:ensureOnlyOneRedShape 逐段解析
以下是示例中的完整实现(节选自 AfterCreateUpdateShapeExample.tsx),每一段都对应一个容易踩坑的细节:
type ShapeWithColor = Extract<TLShape, { props: { color: string } }> // [1] 如果目标 shape 是红色,把同页面上其他红色 shape 全部改黑 function ensureOnlyOneRedShape(editor: Editor, shapeId: TLShapeId) { const shape = editor.getShape(shapeId)! if (!isRedShape(shape)) return const pageId = editor.getAncestorPageId(shape.id)! const otherRedShapesOnPage = Array.from(editor.getPageShapeIds(pageId)) .map((id) => editor.getShape(id)!) .filter( (otherShape): otherShape is ShapeWithColor => otherShape.id !== shape.id && isRedShape(otherShape) ) editor.updateShapes( otherRedShapesOnPage.map( (shape) => ({ id: shape.id, type: shape.type, props: { color: 'black' }, }) as TLShapePartial // [2] ) ) } function isRedShape(shape: TLShape) { return 'color' in shape.props && shape.props.color === 'red' }细节 1:先判断红色,否则就是死循环
if (!isRedShape(shape)) return这一行是整个示例的"安全阀",也是 README 特别强调的一点:
after-change handlers also fire for the updates they cause, make sure your handler is a no-op for records that already satisfy your rule, or it will loop.(after-change 处理器也会因处理器自己引发的更新而再次触发,所以处理器对已经满足规则的记录必须是空操作,否则会无限循环。)
触发链是这样的:shape A 变红 → after-change 处理器把同页的红色 shape B 改成黑色 → 对 B 而言这就是一次 change → B 的 after-change 处理器再次执行 → 此时 B 是黑色,isRedShape为 false,直接 return,链条终止。如果这里不提前返回、每次都执行updateShapes,就会形成 A→B→A 的无限触发循环。编写任何"跨记录修正"型处理器时,都必须保证规则满足时处理器是幂等的 no-op。
细节 2:不是每种 shape 都有 color 属性
isRedShape中先做'color' in shape.props检查,因为并非所有 shape 类型都带color属性——示例文件底部注释指出,比如图片和视频就没有color。在shape: 'shape'这个统一的记录类型上,props是各具体 shape 类型的联合,读取属性前必须先做存在性判断,否则会拿到undefined或触发类型错误。
细节 3:为什么需要as TLShapePartial断言
ShapeWithColor是所有带color属性的 shape 类型的联合。构造{ id, type, props: { color: 'black' } }时,type是运行时值,TypeScript 无法把这个对象字面量收窄回联合类型的某一个具体成员,因此示例用一个断言把结果标记为TLShapePartial。断言并不改变运行数据——id与type都来自原始 shape 本身,所以这次更新仍是类型安全的。
完整可运行示例
将以下代码作为 React 组件即可复现整个演示(完整版见 AfterCreateUpdateShapeExample.tsx):
import { Editor, TLShape, TLShapeId, TLShapePartial, Tldraw, createShapeId, toRichText, } from 'tldraw' import 'tldraw/tldraw.css' type ShapeWithColor = Extract<TLShape, { props: { color: string } }> function ensureOnlyOneRedShape(editor: Editor, shapeId: TLShapeId) { const shape = editor.getShape(shapeId)! if (!isRedShape(shape)) return const pageId = editor.getAncestorPageId(shape.id)! const otherRedShapesOnPage = Array.from(editor.getPageShapeIds(pageId)) .map((id) => editor.getShape(id)!) .filter( (otherShape): otherShape is ShapeWithColor => otherShape.id !== shape.id && isRedShape(otherShape) ) editor.updateShapes( otherRedShapesOnPage.map( (shape) => ({ id: shape.id, type: shape.type, props: { color: 'black' }, }) as TLShapePartial ) ) } function isRedShape(shape: TLShape) { return 'color' in shape.props && shape.props.color === 'red' } export default function AfterCreateUpdateShapeExample() { return ( <div className="tldraw__editor"> <Tldraw onMount={(editor) => { editor.sideEffects.registerAfterCreateHandler('shape', (shape) => { ensureOnlyOneRedShape(editor, shape.id) }) editor.sideEffects.registerAfterChangeHandler('shape', (_prevShape, nextShape) => { ensureOnlyOneRedShape(editor, nextShape.id) }) createDemoShapes(editor) }} /> </div> ) } function createDemoShapes(editor: Editor) { editor .createShapes( 'there can only be one red shape'.split(' ').map((word, i) => ({ id: createShapeId(), type: 'text', y: i * 30, props: { color: i === 5 ? 'red' : 'black', richText: toRichText(word), }, })) ) .zoomToFit({ animation: { duration: 0 } }) }初始化函数createDemoShapes用createShapes一次性创建 7 个文字 shape,拼成 "there can only be one red shape" 这句话,其中第 6 个(i === 5)为红色,其余为黑色,最后zoomToFit让画板自动适配视图。注意:createShapes批量创建时,每个 shape 写入 store 后都会各自触发 after-create 处理器——这正演示了钩子对程序化创建同样生效。
底层原理:StoreSideEffects 的处理器生命周期
上面的示例建立在editor.sideEffects这一 API 之上。从源码结构看,sideEffects是挂在 store 上的 StoreSideEffects 实例,其类型定义为(节选自 StoreSideEffects.ts):
export type StoreAfterCreateHandler<R extends UnknownRecord> = ( record: R, source: 'remote' | 'user' ) => void export type StoreAfterChangeHandler<R extends UnknownRecord> = ( prev: R, next: R, source: 'remote' | 'user' ) => void几个对本示例有直接意义的机制:
1. 按记录类型注册,处理器在注册顺序中依次执行。registerAfterCreateHandler('shape', handler)把处理器存入以typeName为键的数组(StoreSideEffects.ts#L509-L517);写入记录后,store 调用handleAfterCreate遍历同类型的全部处理器(StoreSideEffects.ts#L273-L282)。这意味着处理器只在目标类型的记录发生变化时被调用,而'shape'是统一的记录类型——具体是文字、矩形还是箭头,需要在处理器内部用shape.type进一步区分。
2. "after" 处理器在记录已经写入 store 之后运行。这正是 README 的核心论断:"run after a record has been written to the store, that makes them the right place to update other records in response to a change"(在记录写入 store 之后运行,这使它们成为响应变化去更新其他记录的合适位置)。在 Store.ts 中可以看到 store 在写入前后调用this.sideEffects.handleAfterChange(before, after, source)的实际接线。这也解释了为什么处理器里能安全地调用editor.getShape拿到刚刚写入的 shape——数据已经在 store 里了。
3.source参数区分来源。第二个参数source为'user'(本地用户操作)或'remote'(远端同步到达)。本示例没有用到它,但如果你的规则只想约束本地操作、放行受信远端数据,可以像这样加一层过滤:
editor.sideEffects.registerAfterChangeHandler('shape', (_prev, next, source) => { if (source !== 'user') return ensureOnlyOneRedShape(editor, next.id) })4. 注册返回清理函数。两个register*方法都返回一个移除该处理器的回调(StoreSideEffects.ts#L516)。在 React 中注册时通常保存返回值并在组件卸载时调用,避免已卸载组件的闭包继续修改编辑器:
useEffect(() => { const disposeCreate = editor.sideEffects.registerAfterCreateHandler('shape', (shape) => ensureOnlyOneRedShape(editor, shape.id) ) const disposeChange = editor.sideEffects.registerAfterChangeHandler('shape', (_prev, next) => ensureOnlyOneRedShape(editor, next.id) ) return () => { disposeCreate() disposeChange() } }, [editor])5. after 处理器引发的新变更会进入后续处理轮次。按官方文档 side-effects 的说明,before 处理器在每条记录写入时内联执行;after 处理器则被排队,在外层 store 操作完成后运行,因此处理器内做的修改与触发它的变更属于同一事务、同一撤销步;如果 after 处理器又引发了新的记录变更,那些变更的处理器会在后续轮次(follow-up pass)中执行——这正是"A 变红 → 处理器把 B 改黑 → B 的 after-change 再次触发"这条链能够成立、且最终能收敛的机制前提。
与其他钩子的分工
Side effects 一共有六类处理器(before/after × create/change/delete),选型原则在源码注释里写得很明确:
| 你的目的 | 应使用的处理器 |
|---|---|
| 修改"正在被操作的那条记录"本身 | registerBeforeCreateHandler/registerBeforeChangeHandler(可返回修改后的记录;beforeChange返回prev可拦截本次更新) |
| 响应变化去更新其他记录 | registerAfterCreateHandler/registerAfterChangeHandler(返回值为 void) |
| 阻止删除 / 级联清理 | registerBeforeDeleteHandler(返回false阻止)/registerAfterDeleteHandler |
本示例属于第二种:updateShapes的目标是页面上别的shape,而非触发者自己,所以必须用 after 处理器而非 before 处理器。同目录下的兄弟示例分别演示了另外三种路径,可作为对照:before-create-update-shape、after-delete-shape、before-delete-shape。
小结
这篇示例浓缩了用 tldraw side effects 实现"跨记录数据不变量"的完整套路:
- 用
registerAfterCreateHandler+registerAfterChangeHandler同时覆盖"创建即为红"和"后来变红"两条路径; - 处理器先做幂等检查(
isRedShape为 false 直接返回),避免自身引发的更新造成循环; - 用
getAncestorPageId+getPageShapeIds圈定作用域,只对同页记录做约束; - 读取联合类型 props 前先做
'color' in props存在性检查,构造 partial 时用断言配合"值来自原记录"保证类型安全; - 注册时保留返回的清理函数,在组件卸载时移除处理器。
配套源码可进一步阅读:示例组件 AfterCreateUpdateShapeExample.tsx、钩子实现 StoreSideEffects.ts、store 接线 Store.ts,以及官方文档 Side Effects 中对执行顺序与operationComplete的完整说明。
【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考