tldraw 增量派生视图实战用 store.history 与 computed 构建免全量扫描的响应式数据【免费下载链接】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 示例库中events/derived-view示例及其文档讲解如何用computed信号 store.history.getDiffSince把派生视图从全量扫描改写为增量更新首次计算遍历全部 shape此后仅根据变更历史中新增/删除的 draw 形状维护一个响应式 id 集合当无关变更移动、改色发生时直接复用上一次的值从而避免下游信号与 React 组件重复执行。读完你将掌握 tldrawtldraw/state增量计算的标准写法UNINITIALIZED哨兵、lastComputedEpoch纪元、RESET_VALUE回退重建以及useValue的引用相等性优化。问题背景全量扫描的派生有多浪费在 tldraw 中很多需求本质上是对文档中的记录做某种统计或过滤例如统计画布上有多少支手绘笔迹。最直接的写法是每次文档有任何变化就用store.query.ids(shape)取到全部 shape id再逐个检查type draw。文档小的时候这没问题但当画布上有成千上万条记录时绝大多数变更移动矩形、修改文字颜色根本不会改变draw 形状的数量全量扫描却照样每次执行属于纯浪费。derived-view示例给出的答案是让computed信号只关心自上次计算以来发生了什么而不是整个文档现在长什么样。tldraw 的 Store 本身就带有一个内置的变更历史原子每一次记录增删改都会往里面追加一条 diff这正是增量派生的数据源。完整示例一个响应式的 draw 形状计数器示例位于 DerivedViewExample.tsx其配套说明见 README.md。完整代码如下包含 UI 组件、派生函数两部分import { useMemo } from react import { computed, Editor, isShape, isUninitialized, RESET_VALUE, TLComponents, Tldraw, TLShapeId, useEditor, useValue, } from tldraw import tldraw/tldraw.css function DrawShapeCounter() { const editor useEditor() // [1] const drawShapeIds useMemo(() deriveDrawShapeIds(editor), [editor]) const count useValue(draw shape count, () drawShapeIds.get().size, [drawShapeIds]) return ( div classNametlui-menu style{{ padding: 4px 8px }} {count} draw shapes in document /div ) } const components: TLComponents { TopPanel: DrawShapeCounter, } export default function DerivedViewExample() { return ( div classNametldraw__editor Tldraw persistenceKeyderived-view components{components} / /div ) } // [2] export function deriveDrawShapeIds(editor: Editor) { const { store } editor const shapeIds store.query.ids(shape) function fromScratch() { return new Set([...shapeIds.get()].filter((id) editor.getShape(id)!.type draw)) } return computedSetTLShapeId(drawShapeIds, (prevValue, lastComputedEpoch) { // [3] if (isUninitialized(prevValue)) { return fromScratch() } // [4] const diff store.history.getDiffSince(lastComputedEpoch) if (diff RESET_VALUE) { return fromScratch() } // [5] let nextValue: SetTLShapeId | undefined for (const changes of diff) { for (const record of Object.values(changes.added)) { if (isShape(record) record.type draw) { nextValue ?? new Set(prevValue) nextValue.add(record.id) } } for (const record of Object.values(changes.removed)) { if (isShape(record) record.type draw) { nextValue ?? new Set(prevValue) nextValue.delete(record.id) } } } // [6] return nextValue ?? prevValue }) }页面上方会显示N draw shapes in document。你可以画几笔再删掉它们数字随之增减而移动、缩放或重新着色任意形状集合本身完全不受影响因此没有任何下游重新运行。README 中明确说明同一套模式可以套用到任何昂贵的文档派生视图上比如所有可导出为图片的 shape、所有被锁定的元素。六步拆解增量 computed 的标准写法示例源码中用[1]到[6]标注了六个关键点逐一对照说明。[1] 派生信号随 editor 创建一次用 useValue 读取useMemo(() deriveDrawShapeIds(editor), [editor])保证computed信号在 editor 生命周期内只构建一次。useValue负责把它接进 React这里传入函数形式useValue(name, fn, deps)只返回集合的size。由于useValue只在返回值变化时触发重渲染而移动或改色形状时size不变这个 TopPanel 组件一次都不会重渲染。[2] store.query.ids(shape) 只用于全量路径store.query.ids(shape)是一个响应式的全部 shape id 集合其实现见 StoreQueries.ts。注意它在本例中只服务于 from-scratch 重建路径日常增量路径读的是 history diff不依赖它。[3] 首次运行UNINITIALIZED 哨兵computed的计算函数签名是(prevValue, lastComputedEpoch) Value。第一次执行时prevValue是一个特殊的未初始化哨兵此时没有上一次的值可以增量修补只能全量扫描。哨兵与判断函数定义在 Computed.tsexport const UNINITIALIZED Symbol.for(com.tldraw.state/UNINITIALIZED) export function isUninitialized(value: any): value is UNINITIALIZED { return value UNINITIALIZED }源码注释也指出isUninitialized主要用于增量信号计算incremental signal computation。[4] 读取自上次计算以来的变更getDiffSince核心一行是store.history.getDiffSince(lastComputedEpoch)。store.history是一个特殊的原子它的值是一个单调递增的变更计数器number它的diff是每次更新之间发生的记录变更RecordsDiff即added/updated/removed三个桶。这一结构在 Store.ts 中可以直接看到/** * An atom containing the stores history. */ readonly history: Atomnumber, RecordsDiffR atom(history, 0, { historyLength: 1000, })getDiffSince(epoch)返回自指定纪元以来的所有变更记录如果历史缓冲区已无法回溯到那么远或 store 被重置则返回RESET_VALUE——此时只能放弃增量、全量重建。这正是示例中diff RESET_VALUE分支的由来。historyLength: 1000意味着 Store 最多保留最近 1000 条 diff 的环形缓冲区缓冲区实现见 HistoryBuffer.ts每次写入一条[fromEpoch, toEpoch, diff]三元组容量写满后覆盖最旧条目。getChangesSince从最新条目向前回溯一旦发现空洞或目标纪元早于缓冲区最旧条目就返回RESET_VALUE要求重建HistoryBuffer.ts。对编辑器这类变更通常连续发生的场景1000 条几乎总能覆盖两次计算之间的间隔一旦返回RESET_VALUE示例代码的兜底是回到fromScratch()正确性不受影响只是那一次会付出全量扫描的成本。[5] 只处理 added / removed跳过 updateddiff 里每条变更形如{ added: {...}, updated: {...}, removed: {...} }HistoryEntry.changes结构见 Store.ts。对集合成员这种派生只有形状被创建或销毁才会改变集合内容updated移动、改色、缩放一律跳过。细节上还有一处很讲究的写法惰性拷贝。nextValue ?? new Set(prevValue)只在确实发现相关增删时才复制上一次集合如果整批 diff 都与 draw 形状无关就根本不产生新对象。for (const changes of diff) { for (const record of Object.values(changes.added)) { if (isShape(record) record.type draw) { nextValue ?? new Set(prevValue) nextValue.add(record.id) } } for (const record of Object.values(changes.removed)) { if (isShape(record) record.type draw) { nextValue ?? new Set(prevValue) nextValue.delete(record.id) } } }[6] 无变化时返回原引用让下游免重跑return nextValue ?? prevValue这一行是整个增量方案性能收益的来源如果没有任何相关变更computed 返回的就是同一个 Set 引用。tldraw 的信号系统判定新旧值相等默认先用严格相等见 Computed.ts 中isEqual的默认策略时不会把该信号标记为已变更因此依赖它的useValue不触发 React 重渲染、下游 computed 不重新计算。对比全量扫描版每次任何 shape 一变都会新建一个 Set即使 size 相同也是新对象useValue收到的.size虽不变、但 computed 本身被迫重跑并参与相等性比较——增量版在信号层面就把这部分工作消掉了。底层机制computed 如何追踪上次计算到了哪个纪元示例能成立依赖computed计算函数的两个参数语义定义在 Computed.tscompute: ( previousValue: Value | typeof UNINITIALIZED, lastComputedEpoch: number ) Value | WithDiffValue, DiffpreviousValue信号上一次算出的值首次运行时为UNINITIALIZED哨兵。lastComputedEpoch上次计算完成时的全局纪元。信号系统内部在每次求值前后记录lastCheckedEpoch/lastChangedEpoch见 Computed.ts依赖未变化时直接返回缓存状态不重新执行计算函数。把lastComputedEpoch喂给store.history.getDiffSince就形成了上次我读世界到哪个时间从那里开始告诉我新发生了什么的闭环。这正是 tldraw 官方 store 内部自己使用的同款技巧——例如 StoreQueries.ts 中的响应式索引同样是先getDiffSince(lastComputedEpoch)再增量维护derived-view只是把 store 的内部手法用在了用户侧派生上。另外两个值得知道的配套工具withDiffcomputed 也可以把自己算出的 diff 显式传给下游见 Computed.ts配合historyLength让该 computed 本身成为别人的可增量读取数据源。RESET_VALUE的语义它不是一个普通值而是历史不可信请重建的信号。任何基于getDiffSince的增量派生都必须处理它否则会在 store 重置或缓冲区被覆盖后悄悄持有过期数据。适用前提与限制适用任何派生结果只由记录的增删决定或可以按 diff 增量修补的视图按类型/属性过滤的 id 集合、统计计数、可增量维护的倒排索引等。README 的结论是同一模式适用于任何昂贵的文档派生视图。必须提供全量兜底UNINITIALIZED首次运行和RESET_VALUE两种情况都要求你能写出一条正确的 from-scratch 路径增量逻辑再快也替代不了它。历史深度有限Store 的 history 默认保留 1000 条 diffStore.ts。如果你的派生信号可能长时间无人读取例如挂载在不可见面板里的 computed两次计算之间隔开的变更可能超过缓冲区容量此时会退化为重建——这是可接受的性能退化而不是错误。不要读changes.updated中不需要的字段示例刻意忽略updated因为成员集合对它免疫如果你的派生需要响应更新同样应按 diff 增量处理而不是退回全量。小结derived-view示例用约 40 行代码展示了 tldraw 状态体系中一个可复用的性能模式环节API作用首次运行检测isUninitialized(prevValue)无历史可增量时走全量扫描读取增量变更store.history.getDiffSince(lastComputedEpoch)只拿到上次计算之后的 added/removed/updated历史失效兜底diff RESET_VALUE时fromScratch()缓冲区不可回溯或 store 重置时全量重建惰性拷贝 原引用复用nextValue ?? new Set(prevValue)return nextValue ?? prevValue无相关变更时不产生新对象、不触发下游重算UI 消费useValue(name, fn, deps)只读.size值不变则 React 组件零重渲染这套computed 纪元 getDiffSince 相等性短路的写法不依赖 React纯tldraw/state层面即可复现是构建大型画布文档派生数据搜索索引、权限过滤、导出清单等时值得直接借鉴的基线方案。【免费下载链接】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),仅供参考
