Why does tldraw use tlschema instead of zod?

HEAD
cba9b15docs: fix pricing links (#7745)
This post might have stale content, as HEAD is 337 commits ahead.

详细解释 tldraw 为什么实现了自定义的 tlschema,为什么不使用 zod,我是否可以使用 zod 替代其内部实现,详细解释

Avatar of anthropic/claude-haiku-4.5
Claude Haiku 4.5
commented

关键的优化逻辑

// 1. 快速路径:引用相等 = 没有改变
if (Object.is(knownGoodValue, newValue)) {
  return knownGoodValue  // ⚡ 零成本返回!
}

// 2. 每个数组元素检查
for (let i = 0; i < arr.length; i++) {
  // 3. 元素级别的快速检查
  if (Object.is(knownGoodValue[i], item)) {
    continue  // ⚡ 跳过已验证的元素!
  }
  
  // 4. 只验证改变的元素
  const checkedItem = itemValidator.validateUsingKnownGoodVersion!(
    knownGoodValue[i],
    item
  )
  // 5. 跟踪整体是否改变
  if (!Object.is(checkedItem, knownGoodValue[i])) {
    isDifferent = true
  }
}

// 6. 返回原对象或新对象
return isDifferent ? (newValue as T[]) : knownGoodValue

这意味着:

  • 引用相等检查:O(1) 复杂度
  • 元素级判断:只验证改变的元素
  • 对象复用:未改变则返回原引用(支持 React 优化)

###2️⃣ 使用场景:为什么 Zod 不行

考虑 tldraw 中的实时更新场景:

// RecordType.ts 中的使用
validate(record: unknown, recordBefore?: R): R {
  // 如果有前一个版本,使用优化的验证路径
  if (recordBefore && this.validator.validateUsingKnownGoodVersion) {
    return this.validator.validateUsingKnownGoodVersion(recordBefore, record)
  }
  return this.validator.validate(record)
}

// 用户每次操作都会调用这个
store.put(shape, shapeOldVersion)  // ⭐ 传入前一版本

####Zod 的问题

// Zod 的方式 ❌
const shapeSchema = z.object({
  id: z.string(),
  x: z.number(),
  y: z.number(),
  rotation: z.number(),
  // ... 20 个其他属性
  props: z.object({
    // ... 嵌套结构
  })
})

// 每次调用都要完整验证所有属性,包括那些没改的
const validated = shapeSchema.parse(newShape)  // 总是全量验证!

###3️⃣ 额外原因:迁移系统

tldraw 的数据需要持久化和版本演进:

// tlschema 为每个数据类型都定义了迁移序列
export const imageShapeMigrations = defineMigrations({
  currentVersion: Versions.AddMinMaxScale,
  firstVersion: Versions.InitialAddAssetBinding,
  migrators: {
    [Versions.AddAssetBinding]: {
      up: (record) => ({ ...record, assetId: null }),
      down: ({ assetId, ...record }) => record,
    },
    [Versions.AddMinMaxScale]: {
      up: (record) => ({ ...record, minScale: 0, maxScale: 1 }),
      down: ({ minScale, maxScale, ...record }) => record,
    },
  },
})

tldraw 的迁移系统

  • 可以向前和向后迁移(up/down)
  • 支持复杂的跨版本转换
  • 与验证系统深度集成

Zod 的方式

  • 没有内置迁移系统
  • 无法优雅地处理数据版本演进
  • 需要手动管理迁移逻辑

###4️⃣ 为什么不能用 Zod 替代

特性tldraw (@tldraw/validate)Zod
validateUsingKnownGoodVersion✅ 有❌ 无
性能优化✅ 结构共享,跳过验证❌ 总是全量验证
迁移系统✅ 内置完整迁移支持❌ 无
向后兼容✅ up/down 迁移❌ 单向
错误路径✅ 详细的嵌套路径✅ 有(但不同格式)
TypeScript 类型推断✅ 完整✅ 完整

直观比较

// ❌ 用 Zod 替代 tldraw 验证会导致:
// 1. 失去性能优化(每次都全量验证)
// 2. 失去迁移能力(无法处理版本升级)
// 3. 无法复用之前验证的对象引用
// 4. 在高频更新场景下性能严重下降

// ✅ tldraw 的方案才能提供:
const validator = T.object({...})
// 第一次验证
const shape = validator.validate(rawShape)
// 后续更新只验证改变的部分!
const updated = validator.validateUsingKnownGoodVersion(shape, newRawShape)

###5️⃣ 能否使用 Zod 替代?

直接替代:不能

但是,如果你不需要

  • 高频数据验证的性能优化
  • 复杂的数据迁移系统
  • 向后兼容的数据格式演进

那么可以在你自己的应用中使用 Zod:

// ✅ 可以在应用代码中使用 Zod
import { z } from 'zod'

const shapeSchema = z.object({
  id: z.string(),
  type: z.literal('geo'),
  x: z.number(),
  y: z.number(),
})

// 但核心的 tldraw 库内部会继续用 @tldraw/validate
import { T } from '@tldraw/validate'

何时用 Zod

  • API 请求验证
  • 配置文件解析
  • 一般的表单验证
  • 不需要频繁验证同一对象的场景

何时用 @tldraw/validate

  • tldraw 内部的形状和记录验证
  • 实时协作系统
  • 需要性能优化的高频验证
  • 需要完整迁移系统的数据模型

###6️⃣ 为什么 tldraw 模板中用了 Zod?

你看到的模板(agent、chat 等)使用 Zod 是因为:

// templates/agent 中的 Zod 使用
// 这些是 AI agent 的 action schemas,不是 tldraw 核心数据
// 这里用 Zod 完全没问题,因为:
// 1. 不需要高频更新
// 2. 不需要向后兼容迁移
// 3. 只是验证一次 AI 的响应

##总结

问题答案
为什么实现自定义 tlschema?需要 validateUsingKnownGoodVersion 性能优化 + 完整迁移系统
为什么不用 Zod?Zod 缺少性能优化机制,每次都全量验证;缺少迁移系统
能用 Zod 替代吗?不能替代核心库,但可在应用代码中并存使用
关键差异结构共享、跳过验证、元素级增量验证、向前向后迁移

tldraw 的设计是权衡的典范:为了支持高性能的实时协作编辑和完整的数据演进,选择了构建自己的验证系统,而不是依赖通用的 Zod。


END OF POST

Why does tldraw use tlschema instead of zod? — tldraw/tldraw