首页
/ tldraw 实战:用 Side Effects 在形状写入 Store 前拦截并约束其位置

tldraw 实战:用 Side Effects 在形状写入 Store 前拦截并约束其位置

2026-09-07 17:23:36作者:冯爽妲Honey

本篇以 tldraw 仓库中的官方示例 before-create-update-shape 为主体,讲解如何用 editor.sideEffects.registerBeforeCreateHandlerregisterBeforeChangeHandler 在形状被写入 Store 之前拦截、修改甚至拒绝一次变更。示例场景是让所有形状被约束在一个圆内——画出来的形状超出圆形边界时会被"拉回"边缘。读完本文,你将掌握 before/after 两类生命周期 Handler 的差异、完整的可运行示例代码、约束函数(isShapeIdVec)的逐行原理,以及 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.registerBeforeCreateHandlerregisterBeforeChangeHandler 在记录写入 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) 的放行逻辑TLShapeparentId 可能是 PageIdFrameId,也可能是其他 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 的不可变更新语义一致。

两点使用注意:

  1. 约束原点而非包围盒:如上文所述,x/y 是形状原点,大形状拖到边缘时主体仍可探出圆外。若需要"整个 bounds 都在圆内",需要把 Vec.From(shape) 换成形状四角的判断。
  2. 返回 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.tsthis.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 返回的函数里(TldrawonMount 支持返回 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 的实现

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
}

从源码结构可以确认三个关键行为:

  1. 链式传递、按注册顺序执行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)。
  2. typeName 精确分发:Handler 只在被创建的记录类型与其注册类型一致时才运行。测试 SE1 "handlers only fire for their registered type" 验证:注册 book 的 Handler,放入 author 记录时不会被调用。所以示例注册 'shape' 不会影响 pageframe(frame 也是一种 shape,会被命中)、camera 等其他记录。
  3. 可整体停用_isEnabledfalse 时所有 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.tshandleBeforeCreate/handleBeforeChange 在原子操作写入路径中的调用位置。
  • after-create-update-shape:after 一侧的姊妹示例——"页面上只保留一个红色形状",可对照理解 before/after 的取舍。
  • disableTransparency:同一模式在官方模板中的真实业务用法。

本文所有 API 与行为均以当前仓库源码为准(@tldraw/storeStoreSideEffects@tldraw/editorsideEffects 代理);若在较旧版本中使用,注册方法名与 source 参数可能有所不同,升级前建议以本仓库 api-report 为准核对。

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

项目优选

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