tldraw 实战:用 before-change 副作用为每个 Shape 打上 updatedBy / updatedAt 元数据
本文围绕 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 会存储并同步这份数据,但自身从不读取它。)
也就是说:
meta与props一样必须可 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。它的设计要点:
-
按记录类型分桶。handler 以
typeName(这里是'shape')注册,只有该类型的记录变更时才触发,见registerBeforeChangeHandler的实现(_beforeChangeHandlers[typeName]数组)。 -
返回即改写。
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 } -
在写入前生效。从源码结构看,Store 在应用一次"覆盖已有记录"的更新时,先执行
sideEffects.handleBeforeChange(initialValue, record, source),再对新值做 schema 校验(见 Store.ts 中的调用点)。这意味着 before-change handler 返回的记录就是最终被写入 store 的记录,而不是事后补丁——因此它适合用来"修正"数据本身,而afterChange系列 handler 则适合去更新"其他记录"(例如更新关联数据、触发通知)。 -
可以整段阻断。按类型定义(
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(),
})
getInitialMetaForShape 是 Editor 上的公开可覆写方法,默认实现返回空对象 {}。它的调用时机在 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 文档 及同目录下的 permissions、prevent-shape-change 等示例——它们演示了同一套 handler 机制在权限校验、禁止某类修改等场景下的用法。
七、落地要点小结
- 在
onMount中完成两件事:替换getInitialMetaForShape(管"出生")、注册registerBeforeChangeHandler('shape', ...)(管"变化"),保证元数据字段全生命周期形态一致; - handler 中务必先判
source,source !== 'user'时原样返回next,避免多协作文档里覆盖真实作者; - 用交叉类型(
TLShape & { meta: ... })为自己的 meta 字段建立精确类型; - UI 侧用
useValue+editor.getOnlySelectedShape()实现随记录变化自动刷新的实时展示; - before-change 返回的记录会替代原记录写入,因此它既能改写数据也能(返回
prev时)阻断更新——这是它与 after-change handler 的本质分工。
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