首页
/ tldraw SDK:用 After-Create / After-Change 副作用处理器跨记录维持数据不变量

tldraw SDK:用 After-Create / After-Change 副作用处理器跨记录维持数据不变量

2026-09-07 17:17:49作者:何将鹤

在基于 tldraw SDK 构建无限画板应用时,经常需要让一份数据的变化自动"约束"另一份数据,例如"同一页面上只能有一个红色图形"。本文基于官方示例 after-create-update-shape 展开,完整讲解 editor.sideEffects.registerAfterCreateHandlerregisterAfterChangeHandler 的语义、配套的可运行示例代码,以及底层 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。断言并不改变运行数据——idtype 都来自原始 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 } })
}

初始化函数 createDemoShapescreateShapes 一次性创建 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-shapeafter-delete-shapebefore-delete-shape

小结

这篇示例浓缩了用 tldraw side effects 实现"跨记录数据不变量"的完整套路:

  1. registerAfterCreateHandler + registerAfterChangeHandler 同时覆盖"创建即为红"和"后来变红"两条路径;
  2. 处理器先做幂等检查(isRedShape 为 false 直接返回),避免自身引发的更新造成循环;
  3. getAncestorPageId + getPageShapeIds 圈定作用域,只对同页记录做约束;
  4. 读取联合类型 props 前先做 'color' in props 存在性检查,构造 partial 时用断言配合"值来自原记录"保证类型安全;
  5. 注册时保留返回的清理函数,在组件卸载时移除处理器。

配套源码可进一步阅读:示例组件 AfterCreateUpdateShapeExample.tsx、钩子实现 StoreSideEffects.ts、store 接线 Store.ts,以及官方文档 Side Effects 中对执行顺序与 operationComplete 的完整说明。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388