tldraw 工具库 @tldraw/utils 完全指南:索引键、媒体处理与通用工具 API 深度解析
【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw
本文以 tldraw 仓库中由 API Extractor 自动生成的 @tldraw/utils API 报告 为骨架,结合 源码实现 与配套测试,系统梳理这一"无限画布 SDK 私有工具包"的全部公开与内部 API。读完你将掌握 tldraw 用于图层排序的分数索引(Fractional Indexing)体系、媒体与文件处理工具、Result 错误处理范式、缓存与定时器设施,以及一批可直接复用到任何前端项目的 TypeScript 工具函数。
一、包定位:tldraw 的"瑞士军刀"工具层
在 tldraw 的 monorepo 结构中,packages/utils(npm 包名@tldraw/utils,当前版本 5.4.0,声明于 package.json)扮演着所有上层包(@tldraw/editor、@tldraw/tldraw、@tldraw/store、@tldraw/sync等)共享的基础设施角色。官方 README 对它只有一句朴素描述:"Utility functions used by tldraw"(tldraw 使用的工具函数),但这份 api-report.api.md 揭示的实际内容远不止此——它涵盖了几何数学、排序索引、媒体探测、文件转换、缓存、定时、重试、错误处理、类型体操等约 60 个导出符号。
该报告由 API Extractor 自动生成,是包发布时的"契约快照":每个导出都带有@public(对外稳定 API)或@internal(仅仓库内部使用)的可访问性标记。@public的 API 可放心直接使用,而@internal的导出(如assert、retry、ExecutionQueue等)虽在报告中可见,但按 tldraw 的版本语义不属于对外稳定承诺,消费时应视为实现细节。
依赖方面,该包只引入了 4 个 lodash 子模块(package.json):lodash.isequal、lodash.isequalwith、lodash.throttle、lodash.uniq,并原样转发为isEqual、isEqualWith、throttle、uniq四个导出——这是包保持轻量的关键设计。
包的 入口文件 还会在加载时调用registerTldrawLibraryVersion,从globalThis上读取TLDRAW_LIBRARY_NAME/VERSION/MODULES来登记当前运行库的版本信息,便于运行时诊断与版本告警。
二、排序核心:IndexKey 分数索引与图层重排
tldraw 画布中每个形状都带有index属性,用于决定形状的绘制与堆叠顺序。这个index的类型就是IndexKey——一个带品牌标记(string & { __brand: 'indexKey' })的字符串,由"整数部分 + 小数部分"组成,基于著名的 Fractional Indexing 算法 实现。
2.1 为什么不用连续整数排序
传统做法(1、2、3……)在"中间插入"时会出现需要重排所有后续元素的问题。分数索引的做法是:每次插入都在相邻两个 key 之间取"中点"生成新 key,只要 key 空间足够稠密(base-62 字母表 + 任意长度小数部分),就几乎永远不需要重排已有元素——这对高频拖拽换层、多人协同同时插入的场景至关重要。
2.2 tldraw 的实现特化
tldraw 将上游fractional-indexing与jittered-fractional-indexing两个包 vendored 并裁剪进 fractionalIndexing.ts(两者均为 CC0-1.0 公有领域协议)。该文件头注释说明了三处针对热路径的优化:
- 固定 base-62 字母表:
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',将上游可配置的digits参数特化掉,把validateOrderKey中每次调用都会做的digits[0].repeat(26)分配提升为模块常量SMALLEST_INTEGER; - 查表替代 indexOf:
digitIndex()用字符码算术(code - 48 / - 55 / - 61)直接算出 digit 值,避免在每次生成 key 时线性扫描字母表; - JITTER 抖动:
JITTER_BITS = 16,即新生成的 key 会在目标区间内做 16 次随机二分游走。每多一位抖动位约增加 0.17 个字符的 key 长度,换来并发插入的碰撞概率降低——注释中估算 16 位足以让约 10 个客户端在同一位置同时插入时碰撞率低于 0.1%,为多人实时协同留足余量。
2.3 公开 API 一览
IndexKey体系在 reordering.ts 中对外暴露,全部为@public:
| 函数 | 签名要点 | 行为 |
|---|---|---|
ZERO_INDEX_KEY | IndexKey常量 | 值为'a0',即第一个合法索引 |
getIndexAbove(below?) | below?: IndexKey \| null | 返回某索引之上的一个新 key,默认从null(顶端)开始 |
getIndexBelow(above?) | 同上 | 返回某索引之下的一个新 key |
getIndexBetween(below, above) | 两个边界可空 | 返回两索引之间的一个 key |
getIndices(n, start?) | start默认'a1' | 返回[start, ...n 个递增 key] |
getIndicesAbove/Below/Between | 批量版本 | 一次生成 n 个均匀分布的 key |
sortByIndex(a, b) | 要求{ index: IndexKey } | 按 index 字典序比较,返回 -1/0/1 |
sortByMaybeIndex(a, b) | 允许index为 null | 带 null 处理的排序比较器 |
validateIndexKey(index) | @internal | 断言字符串是合法 IndexKey,非法则抛错 |
源码示例(reordering.ts)给出的典型输出:
const indices = getIndicesBetween('a0' as IndexKey, 'a2' as IndexKey, 2) console.log(indices) // ['a0V', 'a1'] const index = getIndexBetween('a0' as IndexKey, 'a2' as IndexKey) console.log(index) // 'a1' const shapes = [ { id: 'b', index: 'a2' as IndexKey }, { id: 'a', index: 'a1' as IndexKey }, ] const sorted = shapes.sort(sortByIndex) // [{ id: 'a', index: 'a1' }, { id: 'b', index: 'a2' }]值得注意的实现细节是 reordering.ts 中的一行:generateKeysFn在测试环境(NODE_ENV === 'test')下使用无抖动的generateNKeysBetween(保证测试输出确定可断言),生产环境才使用generateNJitteredKeysBetween。这也是配套测试 fractionalIndexing.test.ts 与 reordering.test.ts 能稳定断言输出的原因。
2.4 排序家族的其他成员
报告中的sortById(要求{ id: any },仅返回 -1/1,不含相等分支)位于 sort.ts;rotateArray(arr, offset)、compact、dedupe、partition、last、maxBy、minBy等数组工具集中在 array.ts,其中dedupe支持传入自定义相等比较函数,rotateArray支持负数偏移实现反向旋转。
三、媒体工具箱:MediaHelpers 与 PngHelpers
@tldraw/utils提供了一套完备的浏览器媒体处理工具,是 tldraw 拖入图片/视频、生成缩略图、导出截图等功能的底层支撑,实现位于 lib/media 目录。
3.1 受支持的媒体类型常量
API 报告中共有 5 个相关常量(全部@public且经Object.freeze冻结,见 media.ts):
DEFAULT_SUPPORTED_IMAGE_TYPES:全部支持的图片类型 ——image/apng、image/avif、image/gif、image/jpeg、image/png、image/svg+xml、image/webp;DEFAULT_SUPPORT_VIDEO_TYPES:视频类型 ——video/mp4、video/quicktime、video/webm;DEFAULT_SUPPORTED_MEDIA_TYPES:图片 + 视频的并集;DEFAULT_SUPPORTED_MEDIA_TYPE_LIST:上述并集拼接成的逗号分隔字符串,可直接赋值给<input type="file" accept="...">的accept属性;- 内部另有
DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES(jpeg/png/webp)、DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES(svg+xml)、DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES(gif/apng/avif)三组细分常量,它们被isStaticImageType、isVectorImageType、isAnimatedImageType分别引用。
3.2 MediaHelpers 静态方法详解
MediaHelpers类(media.ts)提供以下核心能力:
loadVideo(src, doc?):创建<video>元素加载视频,设置crossOrigin = 'anonymous',在loadeddata事件时 resolve,失败时 reject'Could not load video';getVideoFrameAsDataUrl(video, time = 0):把视频某帧(默认第 0 秒)绘制到 canvas 并导出 data URL,内部监听loadedmetadata/loadeddata/canplay/seeked事件,等待readyState达标后用canvas.toDataURL()取帧;实现上用promiseWithResolve构造可外部控制的 Promise,并在finally中清理所有监听器,避免内存泄漏;getImageAndDimensions(src, doc?):加载图片并返回{ w, h, image }。实现中有一个针对 Firefox 的特殊分支(media.ts):Firefox 对 SVG 不提供naturalWidth/naturalHeight,因此需临时把图片挂到 DOM 上用clientWidth/clientHeight测量;图片本身会被隐藏(visibility:hidden; position:absolute; opacity:0)并设置referrerPolicy = 'strict-origin-when-cross-origin';getImageSize(blob, doc?):从 Blob 读取图片尺寸,返回{ w, h, pixelRatio }。对 PNG 会进一步解析pHYs块(见下文 PngHelpers)读取 DPI 信息:分别尝试 96(Windows/Web 基线)与 72(macOS 基线)两种标准,若得到大于 1 的整数倍率则按该倍率折算物理尺寸并返回pixelRatio(media.ts);getVideoSize(blob, doc?):通过usingObjectURL+loadVideo读取视频的videoWidth/videoHeight;isAnimated(file):按 MIME 类型分发到各格式解析器——GIF、AVIF、WebP、APNG 分别由 gif.ts、avif.ts、webp.ts、apng.ts 中的字节级解析函数判断是否含动画帧,每种格式都有配套测试(如 gif.test.ts);usingObjectURL(blob, fn):创建URL.createObjectURL,执行异步函数,并在finally中revokeObjectURL,是 Blob → URL → 消费 → 回收的标准模式封装;isImageType / isStaticImageType / isAnimatedImageType / isVectorImageType:基于上述常量集合的 MIME 类型判定。
3.3 PngHelpers:PNG 二进制级操作
PngHelpers(png.ts)直接操作DataView,提供对 PNG 文件格式的底层解析:
isPng(view, offset):校验 PNG 魔数;getChunkType(view, offset):读取 chunk 的类型四字节;findChunk(view, type)/readChunks(view):按类型查找或遍历全部 chunk,返回{ start, size, dataOffset };parsePhys(view, offset):解析pHYs(物理像素)块,得到{ ppux, ppuy, unit };setPhysChunk(view, dpr?, options?):向 PNG 写入新的pHYs块并返回新的 Blob——这是 tldraw 导出高 DPI PNG 截图的关键步骤。
四、文件与网络:FileHelpers、fetch、Image
4.1 FileHelpers
file.ts 中的FileHelpers(@public)封装了最常见的文件/Blob 操作,全部为静态方法:
urlToArrayBuffer(url):fetch后取response.arrayBuffer();urlToBlob(url):fetch后取response.blob();urlToDataUrl(url):若 URL 已是data:前缀则原样返回(避免重复编码),否则先取 Blob 再转 data URL;blobToDataUrl(blob):基于FileReader.readAsDataURL,返回 base64 data URL;blobToText(blob):基于FileReader.readAsText,读取 UTF-8 文本;rewriteMimeType(blob, newMimeType):重写 MIME 类型。有两个重载(Blob/File 各自返回对应类型),实现上若类型相同直接返回原对象;若是File则构造新File([blob], blob.name, { type })保留文件名,否则构造新Blob([blob], { type })。
const dataUrl = await FileHelpers.urlToDataUrl('https://example.com/image.png') const text = await FileHelpers.blobToText(userFile) const jsonFile = FileHelpers.rewriteMimeType(file, 'application/json') console.log(jsonFile.name) // 原文件名被保留4.2 fetch / Image 与存储
- 包导出
fetch与Image(@internal,实现于 network.ts),二者分别是 Web 标准fetch与new Image()的轻量封装(Image支持可选宽高参数); @internal的存储工具(storage.tsx)提供getFromLocalStorage、setInLocalStorage、deleteFromLocalStorage、clearLocalStorage及SessionStorage同款 4 个方法——这些内部封装统一了存储访问入口,便于未来替换实现;safeParseUrl(url, baseUrl?)(@public,url.ts)在无法解析时返回undefined而非抛异常。
五、控制流与错误处理:Result、assert、retry、ExecutionQueue
5.1 Result 判别联合
tldraw 在内部大量使用"不抛异常"的结果类型。Result<T, E>是OkResult<T> | ErrorResult<E>的判别联合,ok字段作为判别符:
function divide(a: number, b: number): Result<number, string> { if (b === 0) return Result.err('Division by zero') return Result.ok(a / b) } const result = divide(10, 2) if (result.ok) { console.log(`Result: ${result.value}`) // Result: 5 } else { console.error(`Error: ${result.error}`) }Result常量对象(control.ts)提供ok(value)、err(error)两个工厂,以及实用的all(results):对结果数组做"全成功"聚合——全部ok时返回所有值的数组,只要有一个失败就返回第一个错误,类似于Promise.all的同步版本。
5.2 assert 与 assertExists
两个断言函数(@internal)都经过omitFromStackTrace包装,抛错时不污染调用栈,便于调试定位真正的问题来源:
assert(value, message?):TypeScript 断言函数(asserts value),value 为 falsy 时抛Error(message || 'Assertion Error');assertExists(value, message?):value 为null/undefined时抛错,否则返回去掉空值后的值(NonNullable<T>),常用于document.getElementById之类可能返回 null 的取值场景。
5.3 retry 与 sleep
retry(fn, options?)(@internal,retry.ts)为异步操作提供可配置重试:
const data = await retry( async () => unreliableApiCall(), { attempts: 5, // 默认 3 waitDuration: 2000, // 每次失败后的等待毫秒数,默认 1000 matchError: (error) => error instanceof NetworkError, // 只有匹配的错误才重试 abortSignal, // 支持 AbortController 中途取消 } )回调会收到{ attempt, remaining, total }三个计数参数(从 0 开始计数)。注意matchError不匹配的错误会被直接抛出而不重试;abortSignal已中断时会抛出new Error('aborted')。
sleep(ms)(control.ts)是setTimeout的 Promise 封装,常与 retry、限流配合使用。
5.4 ExecutionQueue 与 promiseWithResolve
ExecutionQueue(timeout?)(@internal,ExecutionQueue.ts):一个串行任务队列,push(task)返回 Promise,保证任务按提交顺序逐个执行(前一个完成后才启动下一个),可设置超时;close()后不再接受新任务,isEmpty()查询队列状态。适合需要严格串行的场景(如顺序处理同步消息);promiseWithResolve<T>()(@internal,control.ts):返回一个额外挂载了resolve/reject方法的 Promise,允许在异步流程之外控制其状态(如上面 MediaHelpers 取视频帧的做法)。
六、缓存设施:WeakCache 与 LruCache
两个缓存类一弱一强,覆盖不同场景:
WeakCache<K extends object, V>(@public,cache.ts):基于WeakMap的微缓存。键必须是对象,值随键的 GC 自动回收,无需手动清缓存。核心方法get(item, cb)采用"懒计算 + 记忆化"模式——命中直接返回,未命中则调用cb(item)计算并存储:
const cache = new WeakCache<HTMLElement, DOMRect>() const rect1 = cache.get(element, (el) => el.getBoundingClientRect()) const rect2 = cache.get(element, (el) => el.getBoundingClientRect()) // rect1 === rect2,第二次调用不再触发重排计算LruCache<K, V>(maxSize)(@public,LruCache.ts):基于Map插入序迭代实现的简单 LRU(最近最少使用)缓存。get命中时先delete再set,把条目移到"最新"位置;set超过maxSize时淘汰map.keys().next().value(即最旧条目)。提供get/has/set/size,无手动清空方法,靠容量上限自动淘汰。配套测试见 LruCache.test.ts。
七、时间与帧率控制:Timers、FpsScheduler、debounce、throttle
7.1 Timers:带上下文的定时器管理器
Timers(@public,timers.ts)解决 React 组件/编辑器实例中定时器散落、清理困难的痛点:所有setTimeout/setInterval/requestAnimationFrame都按contextId分组登记,可一键清理:
const timers = new Timers() timers.setTimeout('autosave', () => save(), 5000) timers.setInterval('refresh', () => updateData(), 1000) timers.requestAnimationFrame('render', () => draw()) // 清理 'autosave' 上下文的所有定时器 timers.dispose('autosave') // 或拿到绑定好上下文的函数对象,少传一个参数 const uiTimers = timers.forContext('ui') uiTimers.setTimeout(() => console.log('timeout'), 1000) uiTimers.dispose()实现要点:三个内部Map<string, number[]>记录每个上下文注册的句柄;dispose(contextId)遍历清空对应上下文;disposeAll()遍历所有上下文;构造函数中对自身方法做了bind,保证方法作为回调传递时this不丢失。注意该类依赖浏览器window,属于 DOM 环境工具。
7.2 FpsScheduler 与帧节流
FpsScheduler(targetFps?)(@public):按目标帧率调度回调。fpsThrottle(fn)返回节流后的函数(保留原函数的cancel),throttleToNextFrame(fn)把调用合并到下一帧,updateTargetFps(n)可动态调整目标帧率——适合渲染循环、取色器实时预览等需要控制频率的场景;fpsThrottle(fn)与throttleToNextFrame(fn)(@internal版,throttle.ts)是同样的独立函数形态。
7.3 debounce 与 throttle
debounce(callback, wait)(@public,debounce.ts):返回带cancel()的防抖函数,返回值为Promise<U>,wait 毫秒内多次调用只执行最后一次;throttle直接转发自lodash.throttle(index.ts 中export { default as throttle } from 'lodash.throttle')。
八、哈希、ID 与字符串工具
8.1 FNV-1a 风格哈希
hash.ts 提供三个确定性哈希函数(相同输入必得相同输出,32 位有符号整数转字符串):
const hash = getHashForString('hello world') console.log(hash) // '-862545276' const hash1 = getHashForObject({ name: 'John', age: 30 }) const hash2 = getHashForObject({ name: 'John', age: 30 }) console.log(hash1 === hash2) // true const fileHash = getHashForBuffer(await file.arrayBuffer())getHashForString:对字符串逐字符执行hash = (hash << 5) - hash + charCodeAt(i)(即经典的 djb2 变体),每步hash |= 0强制转为 32 位整数;getHashForObject:先JSON.stringify再哈希——哈希结果依赖键的序列化顺序,等价键不同顺序会产生不同哈希;getHashForBuffer:用DataView.getUint8逐字节处理二进制数据,可用于为图片等文件内容生成一致标识。
lns(str)是一个自定义的字符串变换/混淆函数:把字符串按 1/5、1/4、1/3、1/2 的比例分段搬移、反转,并对数字字符做"绕 5 翻转"(1↔6、2↔7……5 保持)。它是确定性编码,并非加密算法,官方注释称之为"custom encoding/obfuscation"。
8.2 uniqueId 与字符串工具
uniqueId(size?)(@public,id.ts):生成唯一 ID(默认长度对应约 128 位随机数);mockUniqueId(fn)/restoreUniqueId()(@internal)用于测试中替换为确定性生成器(相关测试见 id.test.ts);getFirstCharacter(str)、iterateGraphemes(str)(@public,string.ts):后者返回 Unicode 字素簇(grapheme)迭代器,正确处理 emoji 等组合字符,不会按 UTF-16 码元切断——这正是 tldraw 文本工具正确统计光标位置的基础。
九、类型工具与数学函数
9.1 类型体操全家桶
API 报告中的类型导出(全部@public)为 tldraw 自身的泛型 API 提供支撑,也可独立复用:
Awaitable<T>:PromiseLike<T> | T,表示"值或值的 Promise";Expand<T>:把交叉/映射类型展开为可读的普通对象类型;RecursivePartial<T>:递归可选化;Required<T>(内部实现为Required_2):Expand<Omit<T, K> & { [P in K]-?: T[P] }>,强制必选;MakeUndefinedOptional<T>:按undefined extends T[K]条件拆键,自动把"可含 undefined"的属性转为可选;- JSON 类型体系:
JsonPrimitive(boolean | null | number | string)、JsonArray、JsonObject、JsonValue递归定义——是 tldraw 文档/存储序列化层的统一类型基石,定义见 json-value.ts。
9.2 数学与随机
number.ts 提供:
lerp(a, b, t):线性插值;invLerp(a, b, t):反插值(把 t 映射回 [0,1] 区间比例);modulate(value, rangeA, rangeB, clamp?):把一个数值从 rangeA 区间映射到 rangeB 区间,可选钳制;rng(seed?):可种子随机数生成器——相同 seed 产生相同序列,对可复现的测试与确定性模拟至关重要。
十、错误标注、对象工具与杂项
- 错误标注体系(
@public类型 +@internal函数,error.ts):ErrorAnnotations含tags: Record<string, bigint | boolean | null | number | string | symbol | undefined>与extras: Record<string, unknown>;annotateError(error, annotations)把结构化标注挂到错误上,getErrorAnnotations(error)读回。这是 tldraw 上报 Sentry 等监控系统时携带上下文信息的机制; - 对象工具(
@internal,object.ts):groupBy、omit、hasOwnProperty、getOwnProperty、filterEntries、mapObjectMapValues、objectMapKeys/Values/Entries/FromEntries(含可迭代版本)、getChangedKeys(返回两对象间变化的键数组)、areObjectsShallowEqual、isEqualAllowingForFloatingPointErrors(带容差阈值);数组侧还有areArraysShallowEqual; - 其他:
stringEnum(...values)(@internal,生成{K: K}结构的字符串枚举映射)、getFirstFromIterable(取 Map/Set 首项)、bind(属性/类方法装饰器双形态的方法绑定)、noop、omitFromStackTrace、warnOnce/warnDeprecatedGetter(去重告警)、性能探针measureDuration/measureAverageDuration/measureCbDuration(@internal,perf.ts)与PerformanceTracker(@public,PerformanceTracker.ts,提供start(name)/stop()/recordFrame()/isStarted()的帧率统计)、STRUCTURED_CLONE_OBJECT_PROTOTYPE、isNativeStructuredClone与转发的structuredClone(value.ts)、isDefined/isNonNull/isNonNullish窄化工具。
十一、测试保障:每个工具都有对应测试
@tldraw/utils的可靠性建立在覆盖完备的单测之上,源码目录中几乎每个模块都配有一个*.test.ts(src/lib):fractionalIndexing.test.ts、reordering.test.ts(排序 key 生成与校验)、LruCache.test.ts、PerformanceTracker.test.ts、ExecutionQueue.test.ts、debounce.test.ts、throttle.test.ts、retry.test.ts、hash.test.ts、timers.test.ts、file.test.ts、url.test.ts、storage.test.ts、version.test.ts、warn.test.ts、array.test.ts、object.test.ts、string.test.ts、id.test.ts、value.test.ts、control.test.ts、bind.test.ts、iterable.test.ts、sort.test.ts、number.test.ts等,以及 lib/media 下的apng.test.ts、avif.test.ts、gif.test.ts、webp.test.ts、media.test.ts五个媒体格式解析测试。运行方式为:
cd packages/utils yarn test # vitest 监视模式(--passWithNoTests) yarn test-ci # 一次性运行这解释了前文提到的设计取舍:reordering.ts通过NODE_ENV === 'test'切换无抖动实现,正是为了让这些测试的断言完全确定。
十二、结语:如何复用这套工具
@tldraw/utils是一个"小而全"的通用工具库——虽然官方定位为 SDK 内部私有包,但其@public导出(索引键、媒体常量与 MediaHelpers、FileHelpers、Result、WeakCache/LruCache、Timers、FpsScheduler、哈希、类型工具、数学函数等)本身就是一套经过真实画布产品打磨的工程基础设施,其设计可以直接借鉴到任何需要"可插入排序键""内存缓存""上下文化定时器"或"类型安全错误处理"的 React 应用中。
理解这份包的最佳路径是:以 api-report.api.md 为索引,对照 src/index.ts 看导出组织,再深入 src/lib 逐个模块阅读实现与测试——从 fractionalIndexing.ts 的 JITTER 优化、到 media.ts 的 PNG DPI 解析,每一处注释都记录了真实产品场景下的性能与兼容性取舍,这正是这套工具库最有价值的部分。
【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考