tldraw 未保存更改检测实战:用 `store.listen` 文档级监听与 `squashRecordDiffs` 累积 diff 实现脏状态跟踪与保存按钮
tldraw 未保存更改检测实战用store.listen文档级监听与squashRecordDiffs累积 diff 实现脏状态跟踪与保存按钮【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. Worlds best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw在 tldraw 应用中编辑器内部每一笔操作绘制、删除、拖拽、缩放视图都会写进同一个 Store如何区分真正需要保存的文档改动和无所谓的会话状态变化本指南基于仓库内的事件类示例 unsaved-changes 展开介绍如何借助editor.store.listen(handler, { scope: document })精确拦截持久化文档记录的变更并用squashRecordDiffs把每次事务的 diff 折叠成一个不断累积的RecordsDiff据此点亮/熄灭保存按钮。读完你将掌握一套可复用的脏状态dirty state跟踪模式以及 diff 与快照两种持久化数据形态的取舍。一、示例解决的问题与应用场景tldraw 内部的一切状态都收敛在同一个响应式 Store 中你画下的矩形、移动光标引起的选区变化、滚动画布产生的相机位移都会作为记录Record写入 Store 并触发监听器。因此若不做任何过滤想判断用户是否有未保存的改动最简单粗暴的store.listen会连相机移动、选区框选这类瞬时状态一并算入结果就是每拖动一次画布保存按钮都会亮起来完全不可用。该示例的核心思路是用两条机制将信号收敛作用域过滤只在scope: document上监听排除session相机、选区、当前页等与presence多人协作的在线状态两类记录diff 累积压缩不维护一个简单的布尔脏标记而是把每次事务的RecordsDiff用squashRecordDiffs折叠进一个持续累积的 diff使得创建又删除互相抵消、连续编辑十次塌缩为从保存态到当前态的一次更新。最终效果正如 README 描述在画布上随便画点东西顶栏按钮亮起删除刚才画的内容按钮回到 No changes因为折叠后的 diff 再次变空。演示源码位于 UnsavedChangesExample.tsx同目录 README 即关联文档属于apps/examples示例工程中events事件系列的一部分同系列还有store-events、prevent-instance-change、permissions等兄弟示例。本地运行方式在仓库安装依赖后进入 apps/examples 目录执行dev脚本对应vite --host再按示例浏览入口定位到本示例即可示例编写与组织规范可参考 apps/examples/writing-examples.md。二、方案总览为什么累积一个 diff优于维护一个布尔脏标记按钮状态最终呈现为两种文案No changes禁用与Save changes可用。但如果只用true/false记住脏不脏会遇到经典难题用户画了形状脏true→ 又撤销到原状脏仍为 true无法自动回落想向服务端发送自上次保存以来到底改了什么时布尔值给不出任何信息还得重新对比快照每次事务都产生监听回调一个布尔值会带来无谓的 React 重渲染。示例采用的做法是让状态本身携带语义用一个运行中的RecordsDiffTLRecord表示自上次保存以来累积的全部净改动。实现文件开头就定义了空 diff 的构造助手function emptyDiff(): RecordsDiffTLRecord { return { added: {}, removed: {}, updated: {} } }它的形状对应 Store 包中的公开类型定义见 RecordsDiff.ts字段类型语义addedRecordId, Record按 id 索引的新增记录集合updatedRecordId, [from, to]按 id 索引的修改集合每条是一个[旧值, 新值]二元组removedRecordId, Record按 id 索引的删除记录集合当这个 diff 三个集合全部为空时即自上次保存以来没有净改动保存按钮熄灭反之点亮。三、精确监听editor.store.listen(handler, { scope: document })监听逻辑全部封装在SaveButton组件内。注意本示例没有使用 React 事件或编辑器的 onChange 回调而是直接订阅最底层的 Storefunction SaveButton() { const editor useEditor() const [hasUnsavedChanges, setHasUnsavedChanges] useState(false) // [1] 累积 diff 放在 ref 里会在下文解释原因 const rUnsavedChanges useRefRecordsDiffTLRecord(emptyDiff()) useEffect(() { const handleDocumentChange: TLEventMapHandlerchange (entry) { squashRecordDiffs([rUnsavedChanges.current, entry.changes], { mutateFirstDiff: true }) setHasUnsavedChanges(!isDiffEmpty(rUnsavedChanges.current)) } // [2] store.listen 返回取消订阅函数直接作为 effect 的清理函数 return editor.store.listen(handleDocumentChange, { scope: document }) }, [editor]) // ... }3.1scope: document过滤掉了什么store.listen的第二参数字典来自StoreListenerFilters见 Store.ts其中scope的取值空间并非自由字符串而是RecordScope | all。RecordScope在 RecordType.ts 中被定义为三种export type RecordScope session | document | presencedocument属于持久化文档的记录——按示例 README 与代码注释即 shapes图形、pages页面、assets资源、bindings绑定、document 记录本身。只有这类记录的变化才算未保存的文档改动session相机camera、选区selection、当前页等实例级会话状态。它们也会写进 Store、也会触发监听器但不应计入未保存的工作presence多人协作时用于广播在线光标、用户状态等瞬时信息的记录all不过滤等价于监听 Store 的所有写入。Store 内部按类型名维护了每种 scope 对应的记录集合scopedTypes见 Store.ts监听器触发时会据此判断该批事务改动是否属于目标 scope从而保证回调里拿到的entry.changes只包含文档级净变化。附带说明StoreListenerFilters还支持按source过滤user | remote若只想监听本机用户操作而非远端同步写回可另行组合本示例未使用。3.2 监听回调里拿到的是什么当 Store 的一次历史记录被刷新flush时监听器收到的是一个HistoryEntry其结构定义于 Store.tsexport interface HistoryEntryR extends UnknownRecord UnknownRecord { /** 该历史记录中发生的变化 */ changes: RecordsDiffR /** 这些变化的来源 */ source: ChangeSource }也就是说每个事务transaction对应一个RecordsDiff回调里通过entry.changes拿到它。这正好是累积机制的最小输入单元。四、累积压缩squashRecordDiffs的折叠语义4.1 为什么用mutateFirstDiff: true ref示例每收到一个事务 diff就把它折叠进既有的累积 diffsquashRecordDiffs([rUnsavedChanges.current, entry.changes], { mutateFirstDiff: true })squashRecordDiffs的签名见 RecordsDiff.ts接受一个 diff 数组与可选项export function squashRecordDiffsT extends UnknownRecord( diffs: RecordsDiffT[], options?: { mutateFirstDiff?: boolean } ): RecordsDiffT传mutateFirstDiff: true时直接原地修改数组第一个 diff这里是累积中的rUnsavedChanges.current而不是返回新对象——这正是示例把累积 diff 放进useRef而非 React state 的原因它会被原地改写放进 state 容易引起额外的渲染与对象身份漂移React state 中那个布尔值hasUnsavedChanges只负责在必要时驱动按钮重渲染。不传或传false时则基于空 diff 新建一个纯结果不触碰输入。4.2 折叠带来的三种神奇效果squashRecordDiffs的合并逻辑在底层实现squashRecordDiffsMutableImpl中逐条处理同文件 RecordsDiff.ts它保证了按任意顺序施加的一系列事务都能收敛成语义正确的净 diff创建后删除互相抵消若某记录先出现在某次事务的added中、之后又出现在另一次事务的removed中两次操作在累积 diff 里直接对消记录先落入added后续removed发现它在added中便直接删除该条目updated侧也按同样原则回退。因此示例 README 所述画了再删按钮回到 No changes是精确成立的。连续编辑塌缩为一次更新同一记录被改了十次累积结果只剩一条updated[id] [最初值, 最新值]中间状态全部丢弃。先删后建退化为更新记录若先被removed、后又added会转换为updated[id] [被删时的旧值, 新添加的值]removed条目中若存在对应added则更新会直接写到added的最终态上。Store 包在 RecordsDiff.ts 中还公开了一个现成的空判断isRecordsDiffEmpty(diff)其实现与示例内联的isDiffEmpty完全等价——示例选择手写一份是为了让读者直接看到检查三个集合是否都无键这一最小判定逻辑。五、点击保存diff 与editor.getSnapshot()两条出路保存动作本身在handleSave中完成const handleSave useCallback(() { saveChanges(rUnsavedChanges.current, editor.getSnapshot()) rUnsavedChanges.current emptyDiff() setHasUnsavedChanges(false) }, [editor]) function saveChanges(_diff: RecordsDiffTLRecord, _snapshot: TLEditorSnapshot) { // Send the diff or the snapshot to your server here. }注意这里把两种数据形态都传给了占位函数saveChanges真实项目按需二选一或组合并在之后把累积 diff 重置为空让跟踪从刚刚保存过这一新起点重新开始累积 diffRecordsDiff体积小携带的正是从上次保存状态到现在到底变了什么适合做增量持久化、审计日志或与远端做最小同步缺点是存储侧需要自己把增量 apply 到已有文档上。完整快照editor.getSnapshot()返回的是TLEditorSnapshot其结构为{ document: TLStoreSnapshot; session: TLSessionStateSnapshot }见 TLEditorSnapshot.ts服务端保存/恢复最简单——需要还原时用同文件提供的loadSnapshot加载即可它会自动做 schema 迁移并过滤 session 等非文档态。代价是每次保存都要传输整份文档。Store 层级的序列化入口getStoreSnapshot默认也按documentscope 取值见 Store.ts与监听过滤的口径保持一致进一步印证持久化关心的就是文档记录这一设计。按钮的 UI 表达也很直白——它把整个 diff 的判空结果映射成文案与禁用态TldrawUiButton typenormal onClick{handleSave} disabled{!hasUnsavedChanges} {hasUnsavedChanges ? Save changes : No changes} /TldrawUiButton而把SaveButton挂进编辑器顶栏则通过组件插槽机制完成const components: TLComponents { TopPanel: SaveButton, } export default function UnsavedChangesExample() { return ( div classNametldraw__editor Tldraw components{components} / /div ) }六、模式要点与工程化建议把整个示例拆开看它其实是一套可迁移到任何内嵌 tldraw 需要做草稿持久化场景的四步模板用 scope 而非所有 store 变更判断要不要记录——把store.listen的第二个参数写成{ scope: document }从源头排除相机、选区、多人 presence 等瞬态用累积 diff 而非布尔值表达脏状态——监听回调中把entry.changes以{ mutateFirstDiff: true }折叠进一个位于useRef的运行中 diffmutateFirstDiff的原地语义正是用 ref 承载、用布尔 state 驱动重渲染这一分工的理由把保存定义为 diff 快照 重置——save 时携带累积 diff 或editor.getSnapshot()交给后端然后立刻把 diff 重置为空使后续监听自动从新的基线开始清理即取消订阅——store.listen的返回值就是退订函数把它作为useEffect的返回函数可避免组件卸载后继续累积 diff 造成的内存泄漏与多余回调。若你的需求更进一步——例如多标签页草稿、离开页面前的确认弹窗、自动保存到 localStorage、或把增量 diff 通过 tlsync/协作同步 推给远端——这套scope 过滤 squash 累积 重置基线的骨架依然成立只需替换第 3 步的落点。示例完整的组件实现与逐条设计注释代码尾部[1]/[2]/[3]三块 guide 注释都保留在 UnsavedChangesExample.tsx 中RecordsDiff的合并规则与边界处理则可在 RecordsDiff.ts 中逐行对照研读两处合起来即可形成对 tldraw 变更追踪机制的完整认识。【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. Worlds best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考