tldraw 实战:用 Side Effects 在形状写入 Store 前拦截并约束其位置
本篇以 tldraw 仓库中的官方示例 before-create-update-shape 为主体,讲解如何用 editor.sideEffects.registerBeforeCreateHandler 与 registerBeforeChangeHandler 在形状被写入 Store 之前拦截、修改甚至拒绝一次变更。示例场景是让所有形状被约束在一个圆内——画出来的形状超出圆形边界时会被"拉回"边缘。读完本文,你将掌握 before/after 两类生命周期 Handler 的差异、完整的可运行示例代码、约束函数(isShapeId、Vec)的逐行原理,以及 Handler 链式执行、source 参数与清理函数等源码级细节。
1. 核心机制:在写入前"改写"记录,而不是事后"补救"
tldraw 的侧边效应(Side Effects)系统建立在 @tldraw/store 包之上。其核心类 StoreSideEffects 的文档注释将其定位为 "a correct state enforcer"(正确状态强制器),负责保证 Store 状态始终一致,例如:父形状被删除时级联删除子形状、绑定目标被删除时解绑箭头、维护引用完整性等。
它把生命周期钩子分成"记录操作之前/之后"两组,对应六类 Handler:
| 时机 | 注册方法 | 能否修改记录 | 典型用途 |
|---|---|---|---|
| 创建前 | registerBeforeCreateHandler |
能(返回修改后的记录) | 校验、转换、修正即将写入的记录 |
| 创建后 | registerAfterCreateHandler |
不能(仅观察) | 更新其他记录、触发通知 |
| 变更前的 before-change | registerBeforeChangeHandler |
能(返回修改版,或返回 prev 整体拦截) |
阻止/改写某次更新 |
| 变更后 | registerAfterChangeHandler |
不能(仅观察) | 响应变化联动其他记录 |
| 删除前 | registerBeforeDeleteHandler |
返回 false 可阻止删除 |
保护特定记录不被删除 |
| 删除后 | registerAfterDeleteHandler |
不能(仅观察) | 清理、引用完整性维护 |
本文示例正是"before"一侧的典型用法:
editor.sideEffects.registerBeforeCreateHandler和registerBeforeChangeHandler在记录写入 Store 之前运行。Handler 返回什么,什么就会被写进 Store——因此你可以调整一次变更,或者通过返回旧记录来拒绝它,而不是等它发生后再去响应。
这与"after"一侧形成鲜明对照(仓库中另有示例 after-create-update-shape):after 系列 Handler 在记录已经写入后运行,适合去更新其他记录。选择 before 还是 after,取决于你要影响的是"这条记录本身"还是"别的记录"——这是 StoreSideEffects 官方文档注释中明确区分的两条使用准则。
每个 Handler 的第三个参数是 source: 'remote' | 'user',标识变更来源是本地用户操作还是远端同步(见 StoreBeforeCreateHandler 类型定义)。在 before-create 示例里约束逻辑对所有来源都生效,因此示例代码忽略了该参数;但如果你只想约束用户操作、放行同步进来的数据,可以直接判断 source。
2. 完整示例代码
示例组件位于 BeforeCreateUpdateShapeExample.tsx,可直接复制使用(需已安装 tldraw 包并引入样式):
import { Box, Editor, SVGContainer, TLShape, Tldraw, Vec, isShapeId } from 'tldraw'
import 'tldraw/tldraw.css'
const RADIUS = 500
// [1]
function constrainShapeToRadius(editor: Editor, shape: TLShape, radius: number) {
if (isShapeId(shape.parentId)) return shape
const shapePoint = Vec.From(shape)
const distanceFromCenter = shapePoint.len()
if (distanceFromCenter > radius) {
const newPoint = shapePoint.uni().mul(radius)
return {
...shape,
x: newPoint.x,
y: newPoint.y,
}
}
return shape
}
export default function BeforeCreateUpdateShapeExample() {
return (
<div className="tldraw__editor">
<Tldraw
onMount={(editor) => {
// [2]
editor.sideEffects.registerBeforeCreateHandler('shape', (shape) => {
return constrainShapeToRadius(editor, shape, RADIUS)
})
editor.sideEffects.registerBeforeChangeHandler('shape', (_prevShape, nextShape) => {
return constrainShapeToRadius(editor, nextShape)
})
// [3]
editor.zoomToBounds(new Box(-RADIUS, -RADIUS, RADIUS * 2, RADIUS * 2))
editor.setCameraOptions({ isLocked: true })
}}
components={{
OnTheCanvas: () => (
<SVGContainer>
<circle cx={0} cy={0} r={RADIUS} fill="none" stroke="black" />
</SVGContainer>
),
}}
/>
</div>
)
}
代码中三个标注点的含义(继承自示例文件自带的注释,结合源码补充说明):
- [1] 约束函数:返回一个副本,使其
x/y原点落在页面原点为圆心、radius为半径的圆内;若已在圆内则原样返回。特别注意:它约束的是形状的原点,不是整个包围盒(bounds),所以形状边缘仍可以"探出"圆外。 - [2] 双 Handler 注册:before-create 与 before-change 都在记录写入前运行,返回什么就写什么。同一个约束函数同时注册到两个时机,意味着约束对新画的形状,以及对被拖拽、缩放、粘贴的形状都成立。
- [3] 演示辅助:把相机锁定在圆形区域,只是为了让约束效果直观可见——相机锁定不属于 side effect 本身,是纯演示代码。
交互效果
在示例中画出形状并把它们拖出圆形边界:它们会在边缘处停下。原因是拖拽过程中每一次位置更新都会先经过 registerBeforeChangeHandler,被约束函数改写后再写入。
3. 约束函数逐行解析:原点约束、isShapeId 与向量运算
constrainShapeToRadius 只有十几行,但包含了几个值得展开的细节:
function constrainShapeToRadius(editor: Editor, shape: TLShape, radius: number) {
// (a) 父级是形状时直接放行
if (isShapeId(shape.parentId)) return shape
// (b) 取形状原点,计算到页面原点的距离
const shapePoint = Vec.From(shape)
const distanceFromCenter = shapePoint.len()
// (c) 超出半径时,把原点沿同一方向缩放回半径处
if (distanceFromCenter > radius) {
const newPoint = shapePoint.uni().mul(radius)
return { ...shape, x: newPoint.x, y: newPoint.y }
}
return shape
}
- (a)
isShapeId(shape.parentId)的放行逻辑:TLShape的parentId可能是PageId、FrameId,也可能是其他ShapeId(比如形状嵌在 group 或其他容器形状下)。当parentId是形状 ID 时,该形状的x/y是相对于父形状的局部坐标,而不是相对于页面坐标系的坐标——此时直接用shape.x/shape.y与"页面原点"比较就没有意义,所以直接返回原记录。这一行是示例能正确处理嵌套场景的关键。 - (b)
Vec.From(shape)与.len():tldraw导出的Vec工具能把任何带x/y的对象转成向量,.len()返回模长(即到页面原点的欧氏距离)。 - (c)
uni().mul(radius):.uni()把向量归一化为单位向量,再乘以半径,得到"同一方向上、恰好落在圆周上"的新原点坐标。返回的是展开原形状后的新对象({ ...shape, x, y }),不改动入参——这与 Store 的不可变更新语义一致。
两点使用注意:
- 约束原点而非包围盒:如上文所述,
x/y是形状原点,大形状拖到边缘时主体仍可探出圆外。若需要"整个 bounds 都在圆内",需要把Vec.From(shape)换成形状四角的判断。 - 返回
prev即拦截:在 before-change Handler 里,如果直接返回prev(旧记录),这次更新就被完全否决(见 registerBeforeChangeHandler 文档注释中的"prevent shapes from ever being locked"示例)。本例没有拦截,而是"改写后放行"。
4. 注册时机、source 过滤与清理函数
示例在 <Tldraw onMount={...}> 中注册 Handler——此时 editor 已经就绪,是挂载 side effect 的标准位置。
4.1 editor.sideEffects 就是 store.sideEffects
从源码结构看,Editor 构造时直接持有 Store 的侧边效应实例:Editor.ts 中 this.sideEffects = this.store.sideEffects,并对外暴露为 readonly sideEffects。也就是说 editor.sideEffects.register* 与 editor.store.sideEffects.register* 操作的是同一个 StoreSideEffects 实例,Handler 最终挂在按 typeName 分桶的内部数组上(如 _beforeCreateHandlers['shape'])。
4.2 用 source 参数区分来源
每个 before Handler 的完整签名(见 类型定义)是:
// before create:(record, source) => 修改后的 record
// before change:(prev, next, source) => 实际要存储的版本
source 为 'user' 表示变更来自本地用户交互,'remote' 表示来自远端同步。一个典型用法是"只约束本地操作":
editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next, source) => {
if (source !== 'user') return next
return constrainShapeToRadius(editor, next, RADIUS)
})
4.3 每次注册都会返回清理函数
register*Handler 均返回一个 remover(见 registerBeforeCreateHandler 实现:return () => remove(this._beforeCreateHandlers[typeName]!, handler))。在 React 中应在 onMount 返回的函数里(Tldraw 的 onMount 支持返回 cleanup)调用它,避免组件卸载后 Handler 仍然挂在 Store 上。仓库测试 StoreSideEffects.test.ts 中的 [SE1] each register call returns a remover 用例验证了这一点:cleanup() 之后,原 Handler 不再被调用。
此外还有一个批量注册的便捷入口 register(),可按类型一次性注册多个时机并返回单个清理函数:
const cleanup = editor.sideEffects.register({
shape: {
beforeCreate: (shape) => constrainShapeToRadius(editor, shape, RADIUS),
beforeChange: (_prev, next) => constrainShapeToRadius(editor, next, RADIUS),
},
})
// 需要时 cleanup() 一次摘除全部
4.4 同一包内对同一模式的真实复用
仓库的模板应用中也用完全相同的"before create + before change 注册同一函数"模式实现业务规则,例如 image-pipeline 的 disableTransparency:在创建/更新 image 形状时强制把 colorMode 改回 color,禁止透明背景进入 Store。可以看到这不是演示技巧,而是 SDK 生态里约定俗成的"入口校验"写法。
5. 源码纵深:before Handler 在写入链中的确切位置
在 Store.ts 的记录写入路径中,两个钩子的调用点非常直观:
// 更新路径(Store.ts 约 L628)
record = this.sideEffects.handleBeforeChange(initialValue, record, source)
// ...
// 创建路径(Store.ts 约 L647)
record = this.sideEffects.handleBeforeCreate(record, source)
handleBeforeCreate(record: R, source: 'remote' | 'user') {
if (!this._isEnabled) return record
const handlers = this._beforeCreateHandlers[record.typeName] as StoreBeforeCreateHandler<R>[]
if (handlers) {
let r = record
for (const handler of handlers) {
r = handler(r, source)
}
return r
}
return record
}
从源码结构可以确认三个关键行为:
- 链式传递、按注册顺序执行:
for (const handler of handlers) { r = handler(r, source) }让后一个 Handler 收到前一个的输出。仓库测试 SE1 用例对应的断言在 StoreSideEffects.test.ts:注册两个 before-create Handler(numPages + 10与* 2),输入5后落库值为30(即5 → 15 → 30)。handleBeforeChange同理(L294-L307)。 - 按
typeName精确分发:Handler 只在被创建的记录类型与其注册类型一致时才运行。测试 SE1 "handlers only fire for their registered type" 验证:注册book的 Handler,放入author记录时不会被调用。所以示例注册'shape'不会影响page、frame(frame 也是一种 shape,会被命中)、camera等其他记录。 - 可整体停用:
_isEnabled为false时所有 Handler 直接短路(setIsEnabled),tldraw 内部在批量导入/粘贴等场景会临时关闭 side effects 以避免连锁触发。
5.1 注意:before-change 不会在"没变化"时触发
before-change Handler 由 Store 在 update 路径上触发,前提是这次更新确实产生了一次写入。这也意味着:在 Handler 里对同一记录再做更新不会再次触发该 Handler(它已经在写入链中),但会触发其他记录的 side effect。这也是为什么仓库文档对 after 系列 Handler 特别警告"Handler 引起的更新会再次触发 Handler,务必保证对已满足规则的记录是 no-op,否则会死循环"(见 after-create-update-shape README);before 系列虽然不存在"改自己再触发自己"的问题,但跨记录的连锁更新(例如约束 A 时连带移动 B)同样需要保持幂等。
5.2 与 tldraw 默认 side effects 共存
Editor 初始化时会通过 this.sideEffects.register({...}) 注册一批内置 side effects(级联删除、箭头解绑、选择集维护等)。你的业务 Handler 与它们共享同一条链,按注册先后顺序串联执行,互不排斥;但要注意你的改写不能破坏内置约束(例如把形状的 parentId 改成已删除的页面)。
6. 适用边界与常见取舍
- 改记录本身 → before;联动其他记录 → after:这是 SDK 注释里明确的分工。before 系列只能影响"正在写入的那条记录";如果你的逻辑需要查询或更新其他记录(比如"只留一个红色形状",见 after-create-update-shape),用 after 系列。
- before-create 只覆盖"新写入":已经存在于 Store 中的记录(例如从文件载入的历史数据)不会经过 before-create;要约束存量数据需要在载入前自行迁移,或在 before-change 中兜底。
- 约束点选择:本例约束原点。若要约束包围盒、旋转后的四角,或限制在某个 shape/frame 的局部范围内,需要把
Vec.From(shape)换成对应点的换算,并注意 (a) 处"父级是形状时坐标是局部的"这一前提。 - Handler 应保持轻量纯函数:它位于每次写入的热路径上,拖拽一帧可能触发多次调用。避免在 Handler 里做 IO、打日志或触发 React 状态更新。
7. 小结与延伸阅读
本例展示了 tldraw side effects 体系中"写入前拦截"的标准范式:在 onMount 中为 'shape' 同时注册 before-create 与 before-change Handler,让同一个纯函数对所有新写入与位置更新生效。配合 StoreSideEffects 的链式执行、按类型分发与清理函数机制,这套 API 足以实现校验、归一化、业务规则强制等"在数据进入 Store 前就保持正确"的需求。
延伸阅读(均在当前仓库中):
- StoreSideEffects.test.ts:Handler 链式顺序、清理函数、类型隔离、
source传递等行为的完整测试用例(用例 ID 如[SE1]、[SE2]对应 SPEC.md §7)。 - Store.ts:
handleBeforeCreate/handleBeforeChange在原子操作写入路径中的调用位置。 - after-create-update-shape:after 一侧的姊妹示例——"页面上只保留一个红色形状",可对照理解 before/after 的取舍。
- disableTransparency:同一模式在官方模板中的真实业务用法。
本文所有 API 与行为均以当前仓库源码为准(@tldraw/store 的 StoreSideEffects 与 @tldraw/editor 的 sideEffects 代理);若在较旧版本中使用,注册方法名与 source 参数可能有所不同,升级前建议以本仓库 api-report 为准核对。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00