tldraw SDK:用 After-Create / After-Change 副作用处理器跨记录维持数据不变量
在基于 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 的完整说明。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00