首页
/ tldraw 实战:用 before-change 副作用为每个 Shape 打上 updatedBy / updatedAt 元数据

tldraw 实战:用 before-change 副作用为每个 Shape 打上 updatedBy / updatedAt 元数据

2026-09-07 17:46:30作者:房伟宁

本文围绕 tldraw 官方示例 Shape meta (on change) 展开,讲清一个高频实战需求:如何让每个图形(Shape)在每次被用户修改时,自动在 meta 字段上记录"谁改的、什么时候改的"。读完后你将掌握三个关键 API 的完整用法:editor.sideEffects.registerBeforeChangeHandler(变更前拦截)、editor.getInitialMetaForShape(新建图形初始 meta)与 useValue(响应式读取),并理解 source: 'remote' | 'user' 在多协作文档中为什么必须区分。

一、背景:Shape 的 meta 是给你自定义数据的"逃生舱"

在 tldraw 中,每个 Shape 都自带一个 meta 属性,用于存放你自己的 JSON 数据。官方文档 shapes 中的明确定位是:

Tldraw stores and syncs this data but doesn't use it itself.(tldraw 会存储并同步这份数据,但自身从不读取它。)

也就是说:

  • metaprops 一样必须可 JSON 序列化;
  • 它随文档一起持久化、随协作一起同步;
  • 默认值是空对象,类型为 JsonObject
  • 不只是 Shape,page、binding、asset、document 等记录也都有 meta

本示例要解决的具体问题是:为每个 Shape 打上 updatedBy(最后修改者的外部用户 ID)和 updatedAt(毫秒时间戳),并且每次用户改动都实时刷新。示例的完整可运行代码位于 OnChangeShapeMetaExample.tsx

二、示例完整代码

下面就是该示例的全部核心实现,可以直接复制到你自己的项目中(依赖 tldraw 包):

import { TLComponents, TLShape, Tldraw, useEditor, useValue } from 'tldraw'
import 'tldraw/tldraw.css'

// [1] 用交叉类型给 meta 补充精确类型
type ShapeWithMyMeta = TLShape & { meta: { updatedBy: string; updatedAt: number } }

// 顶部面板:实时显示当前选中图形的 meta
function MetaUiHelper() {
	const editor = useEditor()
	// [2] useValue 让面板随选区 / 记录变化自动重渲染
	const onlySelectedShape = useValue(
		'only selected shape',
		() => editor.getOnlySelectedShape() as ShapeWithMyMeta | null,
		[editor]
	)

	return (
		<pre className="tlui-menu" style={{ margin: 0, padding: 8 }}>
			{onlySelectedShape
				? JSON.stringify(onlySelectedShape.meta, null, '\t')
				: 'Select one shape to see its meta data.'}
		</pre>
	)
}

const components: TLComponents = {
	TopPanel: MetaUiHelper, // 用自定义组件替换顶栏
}

export default function OnChangeShapeMetaExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw
				persistenceKey="tldraw_change_meta_example"
				components={components}
				onMount={(editor) => {
					// [3] 替换 getInitialMetaForShape:新图形出生即带同样的字段
					editor.getInitialMetaForShape = (_shape) => {
						return {
							updatedBy: editor.user.getExternalId(),
							updatedAt: Date.now(),
						}
					}
					// [4] 注册 before-change 副作用:每次用户改图形时重写 meta
					editor.sideEffects.registerBeforeChangeHandler('shape', (_prev, next, source) => {
						if (source !== 'user') return next
						return {
							...next,
							meta: {
								updatedBy: editor.user.getExternalId(),
								updatedAt: Date.now(),
							},
						}
					})
				}}
			/>
		</div>
	)
}

运行方式:创建任意图形并选中它,然后拖动位置或修改样式——顶部面板里的 updatedAt 时间戳会随每一次变化更新;若有多人协作,远端同伴的编辑不会覆盖各自真实的作者信息。

三、核心机制 1:registerBeforeChangeHandler 拦截并改写变更

示例的关键在 [4] 处:

editor.sideEffects.registerBeforeChangeHandler('shape', (_prev, next, source) => {
	if (source !== 'user') return next
	return { ...next, meta: { updatedBy: editor.user.getExternalId(), updatedAt: Date.now() } }
})

这条 handler 的底层实现在 StoreSideEffects.ts。它的设计要点:

  1. 按记录类型分桶。handler 以 typeName(这里是 'shape')注册,只有该类型的记录变更时才触发,见 registerBeforeChangeHandler 的实现(_beforeChangeHandlers[typeName] 数组)。

  2. 返回即改写handleBeforeChange 会按注册顺序串联所有 handler,前一个的返回值作为后一个的 next

    // packages/store/src/lib/StoreSideEffects.ts
    handleBeforeChange(prev: R, next: R, source: 'remote' | 'user') {
        if (!this._isEnabled) return next
        const handlers = this._beforeChangeHandlers[next.typeName] as StoreBeforeChangeHandler<R>[]
        if (handlers) {
            let r = next
            for (const handler of handlers) {
                r = handler(prev, r, source)
            }
            return r
        }
        return next
    }
    
  3. 在写入前生效。从源码结构看,Store 在应用一次"覆盖已有记录"的更新时,先执行 sideEffects.handleBeforeChange(initialValue, record, source),再对新值做 schema 校验(见 Store.ts 中的调用点)。这意味着 before-change handler 返回的记录就是最终被写入 store 的记录,而不是事后补丁——因此它适合用来"修正"数据本身,而 afterChange 系列 handler 则适合去更新"其他记录"(例如更新关联数据、触发通知)。

  4. 可以整段阻断。按类型定义(StoreBeforeChangeHandler),handler 也可以返回 prev 来完全阻止本次更新,这是 tldraw 做权限控制、防误删等场景的官方推荐方式。

为什么必须过滤 source !== 'user'

handler 的第三个参数 source 标明这次变更的来源:

  • 'user':本地用户直接操作(拖拽、改色、编辑文本……);
  • 'remote':由远端协作者同步进来的变更。

如果不做过滤,在多协作文档中,A 拖动 B 创建的图形后,B 端收到远端同步时也会触发 handler,把 updatedBy 再次写成 B 的本地用户 ID——真实作者信息就被覆盖了。示例中 if (source !== 'user') return next 这一行正是为了让"远端到达的变更保留其原本作者"。

四、核心机制 2:getInitialMetaForShape 让新图形"出生即合规"

只有变更时打戳还不够——刚创建的图形在第一次修改前没有 meta 数据。[3] 处的做法是在 onMount 中替换 editor.getInitialMetaForShape

editor.getInitialMetaForShape = (_shape) => ({
	updatedBy: editor.user.getExternalId(),
	updatedAt: Date.now(),
})

getInitialMetaForShapeEditor 上的公开可覆写方法,默认实现返回空对象 {}。它的调用时机在 createShapes 内部:每创建一个新 Shape,都会把该方法的结果与调用方显式传入的 meta 做浅合并,显式传入的字段优先

// packages/editor/src/lib/editor/Editor.ts(createShapes 内部)
shapeRecordsToCreate.forEach((shape) => {
	shape.meta = {
		...this.getInitialMetaForShape(shape),
		...shape.meta,
	}
})

这条合并顺序有单测佐证:getInitialMetaForShape.test.ts 验证了默认情况下 meta{},以及替换该方法后新建图形会带上自定义 meta 字段。

由于 before-change handler 和 getInitialMetaForShape 维护的是同一组字段updatedBy / updatedAt),新建图形与后续修改之间的元数据形态保持一致,UI 侧无需处理"字段缺失"的分支。官方文档 shapes — Initial meta 对这一合并行为也有相同描述:"Your explicit meta wins."

五、UI 侧:用 useValue 实时展示 meta

顶部面板 MetaUiHelper 演示了"如何响应式读取 meta":

const onlySelectedShape = useValue(
	'only selected shape',
	() => editor.getOnlySelectedShape() as ShapeWithMyMeta | null,
	[editor]
)

useValue 是 tldraw 提供的响应式取值 Hook:只要 getOnlySelectedShape() 依赖的选区或该图形记录本身发生变化,组件就自动重渲染。这正是"拖动图形时时间戳实时跳动"的原因——before-change handler 每次写入新 meta,记录变更信号驱动了面板刷新。

类型处理上,示例用交叉类型而不是修改原类型:

type ShapeWithMyMeta = TLShape & { meta: { updatedBy: string; updatedAt: number } }

因为 meta 的基类类型是 JsonObject(任意 JSON),交叉出 { meta: {...} } 就能让自己的字段获得完整补全,同时保留 TLShape 的全部其他属性。

六、两个相邻示例的分工:on create vs on change

apps/examples/src/examples/events 目录下有一对姊妹示例,边界划分清晰:

示例 时机 机制 文档
Shape meta (on create) 仅创建时 替换 editor.getInitialMetaForShape README
Shape meta (on change)(本文) 每次用户变更时 registerBeforeChangeHandler('shape', ...) + 初始 meta README

如果只需要"谁创建的、何时创建",用前者即可,后续编辑不会改动元数据;如果需要"最后修改者、最后修改时间"这类持续更新的审计字段,就用本文的 before-change 方案。想深入了解副作用体系的完整钩子面(before/after × create/change/delete、operationComplete 等),可以阅读 side effects 文档 及同目录下的 permissionsprevent-shape-change 等示例——它们演示了同一套 handler 机制在权限校验、禁止某类修改等场景下的用法。

七、落地要点小结

  1. onMount 中完成两件事:替换 getInitialMetaForShape(管"出生")、注册 registerBeforeChangeHandler('shape', ...)(管"变化"),保证元数据字段全生命周期形态一致;
  2. handler 中务必先判 sourcesource !== 'user' 时原样返回 next,避免多协作文档里覆盖真实作者;
  3. 用交叉类型(TLShape & { meta: ... })为自己的 meta 字段建立精确类型;
  4. UI 侧用 useValue + editor.getOnlySelectedShape() 实现随记录变化自动刷新的实时展示;
  5. before-change 返回的记录会替代原记录写入,因此它既能改写数据也能(返回 prev 时)阻断更新——这是它与 after-change handler 的本质分工。
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

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