Why does tldraw implement its own packages/state?

main
2b7ccd2feat(workspace): add Prettier as recommended VS Code extension (#7664)
This post might have stale content, as main is 558 commits ahead.

详细解释 tldraw 中是如何实现 packages/state 框架的,这是一个类似 solidjs signal 的,详细解释为什么它不使用 jotai / zustand 而要实现,是因为要做到框架无关吗?详细解释其设计逻辑和实现原理,我看不懂为什么这里有很多 epoch 之类的设计

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

非常好!现在我有足够的信息。让我用中文为用户写一份详细的解析文档。


#tldraw packages/state 框架详细解析

我已经深入研究了 tldraw 的 packages/state 框架。这是一个非常精心设计的响应式状态管理库。让我为您详细解释其设计逻辑和实现原理。

##一、为什么需要自己实现而不用 Jotai/Zustand?

###核心原因:框架无关性 + 性能需求 + 时间旅行能力

  1. 框架无关性(Framework Agnostic)

    • tldraw 的 @tldraw/state 完全独立于任何 UI 框架
    • Jotai 深度集成 React(hooks),Zustand 虽然有 vanilla 支持但设计中心还是 React
    • tldraw 需要支持多个渲染层(React、HTML5 Canvas、其他框架)
  2. 细粒度响应性(Fine-Grained Reactivity)

    • 像 SolidJS 的 signal 系统那样,只重新计算实际依赖变化的部分
    • Zustand 基于全局 store 订阅,Jotai 虽然原子化但不如 signal 系统高效
    • tldraw 的协作绘图应用需要极高的性能(需要追踪数千个对象的变化)
  3. 内置时间旅行(Built-in Time-Travel)

    • RESET_VALUE、HistoryBuffer 和 Diff 系统支持 undo/redo
    • 这对绘图应用至关重要
    • Zustand 和 Jotai 需要额外的中间件才能实现
  4. Epoch 系统(事务和变化追踪)

    • 支持原子性事务和自动回滚
    • 支持批量更新而不触发中间响应
    • 这是 Zustand/Jotai 难以高效实现的

##二、核心架构:Epoch 系统详解

###什么是 Epoch?

Epoch 是一个简单但强大的概念:全局的、不断递增的版本号

// packages/state/src/lib/transactions.ts
const inst = singleton('transactions', () => ({
  globalEpoch: GLOBAL_START_EPOCH + 1,  // 初始值为 0
  globalIsReacting: false,
  currentTransaction: null as Transaction | null,
  reactionEpoch: GLOBAL_START_EPOCH + 1,
}))

export function getGlobalEpoch() {
  return inst.globalEpoch
}

export function advanceGlobalEpoch() {
  inst.globalEpoch++  // 每次任何 atom 改变时递增
}

###Epoch 的三个用途

####1. 脏值检查(Dirty Checking)

每个 Signal(Atom 和 Computed)都有一个 lastChangedEpoch

// packages/state/src/lib/Atom.ts
class __Atom__<Value, Diff> {
  lastChangedEpoch = getGlobalEpoch()  // 初始化为当前 epoch
  
  set(value: Value, diff?: Diff): Value {
    // ... 检查是否真的改变
    
    advanceGlobalEpoch()  // 全局 epoch + 1
    this.lastChangedEpoch = getGlobalEpoch()  // 更新此 atom 的改变时间
    
    atomDidChange(this, oldValue)  // 通知依赖
  }
}

当 Computed 需要判断"我的依赖是否改变了"时:

// packages/state/src/lib/helpers.ts
export function haveParentsChanged(child: Child): boolean {
  for (let i = 0; i < child.parents.length; i++) {
    // 缓存的父节点 epoch
    const cachedEpoch = child.parentEpochs[i]
    // 父节点当前的 epoch
    const currentEpoch = child.parents[i].lastChangedEpoch
    
    if (currentEpoch !== cachedEpoch) {
      return true  // 父节点改变了
    }
  }
  return false
}

这就是为什么 Computed 的 __unsafe__getWithoutCapture() 方法会比较 epoch:

// packages/state/src/lib/Computed.ts
__unsafe__getWithoutCapture(ignoreErrors?: boolean): Value {
  // 如果还没有计算过,需要计算
  const isNew = this.lastChangedEpoch === GLOBAL_START_EPOCH
  
  const globalEpoch = getGlobalEpoch()
  
  // 关键的优化判断
  if (
    !isNew &&
    (this.lastCheckedEpoch === globalEpoch ||  // 已在本 epoch 检查过
     !haveParentsChanged(this))                 // 或者没有父节点改变
  ) {
    // 不需要重新计算,返回缓存的值
    this.lastCheckedEpoch = globalEpoch
    return this.state
  }
  
  // 否则重新计算...
  startCapturingParents(this)
  const result = this.derive(this.state)
  // ...
}

####2. 事务隔离(Transaction Isolation)

在事务中,所有改变都被延迟,直到事务提交:

// packages/state/src/lib/transactions.ts
export function transaction<T>(fn: (rollback: () => void) => T) {
  const txn = new Transaction(inst.currentTransaction, true)
  inst.currentTransaction = txn
  
  try {
    let rollback = false
    try {
      fn(() => (rollback = true))
    } catch (e) {
      // 错误时自动回滚
      txn.abort()  // 还原所有改变
      throw e
    }
    
    if (rollback) {
      txn.abort()
    } else {
      txn.commit()  // 一次性通知所有依赖
    }
  } finally {
    inst.currentTransaction = txn.parent
  }
}

当在事务中改变 atom 时:

// packages/state/src/lib/transactions.ts
export function atomDidChange(atom: _Atom, previousValue: any) {
  if (inst.currentTransaction) {
    // 保存原始值用于回滚
    if (!inst.currentTransaction.initialAtomValues.has(atom)) {
      inst.currentTransaction.initialAtomValues.set(atom, previousValue)
    }
  } else {
    // 不在事务中,立即通知
    flushChanges([atom])
  }
}

####3. 反应调度(Reaction Scheduling)

当事务提交时,flushChanges 会:

function flushChanges(atoms: Iterable<_Atom>) {
  inst.globalIsReacting = true
  inst.reactionEpoch = inst.globalEpoch  // 标记反应开始的 epoch
  
  // 收集所有受影响的 reactors
  const reactors = new Set<EffectScheduler>()
  for (const atom of atoms) {
    atom.children.visit((child) => traverse(reactors, child))
  }
  
  // 运行所有 reactors
  for (const r of reactors) {
    r.maybeScheduleEffect()
  }
}

##三、完整的响应流程示例

让我用一个实例演示整个系统如何工作:

import { atom, computed, react, transact } from '@tldraw/state'

// === 初始化阶段 ===
// globalEpoch = 0

const name = atom('name', 'Alice')
// name.lastChangedEpoch = 0

const greeting = computed('greeting', () => {
  return `Hello, ${name.get()}!`
})
// greeting.lastChangedEpoch = GLOBAL_START_EPOCH (-1),标记为"未初始化"

react('logger', () => {
  console.log(greeting.get())
})
// 立即运行一次:
// 1. startCapturingParents(logger) - 开始捕获依赖
// 2. greeting.get() 被调用
//    - 检查:isNew = true (lastChangedEpoch === -1)
//    - 需要计算!
//    - startCapturingParents(greeting) 
//    - 调用 derive 函数,其中 name.get() 被调用
//    - maybeCaptureParent(name) - name 被捕获为 greeting 的父节点
//    - greeting.lastChangedEpoch = 0(第一次计算)
// 3. console.log 输出:'Hello, Alice!'
// 4. stopCapturingParents(logger) - 完成依赖捕获
//    logger.parents = [greeting]
//    greeting.children = [logger]

// === 更新阶段 ===
name.set('Bob')
// 1. advanceGlobalEpoch() → globalEpoch = 1
// 2. name.lastChangedEpoch = 1
// 3. atomDidChange(name, 'Alice')
//    - 不在事务中,调用 flushChanges([name])
// 4. flushChanges 开始:
//    - globalIsReacting = true
//    - reactionEpoch = 1
//    - 收集 name 的所有依赖(直接和间接)
//    - 发现 greeting 依赖 name
//    - 发现 logger 依赖 greeting
//    - 执行 logger 的 maybeScheduleEffect()
// 5. logger 反应:
//    - startCapturingParents(logger)
//    - logger.lastTraversedEpoch < reactionEpoch,需要重新运行
//    - 调用 runEffect()
//      - greeting.get() 被调用
//      - haveParentsChanged(greeting) 检查:
//        - name.lastChangedEpoch (1) !== greeting.parentEpochs[0] (0)
//        - 返回 true - 需要重新计算!
//      - 重新计算 greeting:
//        - 输出 'Hello, Bob!'
//      - greeting.lastChangedEpoch = 1
//      - greeting.parentEpochs[0] = 1
//      - greeting.state = 'Hello, Bob!'
//    - console.log('Hello, Bob!')

// === 事务阶段 ===
const count = atom('count', 0)
// count.lastChangedEpoch = 1

react('logger2', () => {
  console.log(`${name.get()}: ${count.get()}`)
})
// 输出:'Bob: 0'

transact(() => {
  // 1. txn = new Transaction(null, true)
  // 2. inst.currentTransaction = txn
  
  name.set('Charlie')
  // - advanceGlobalEpoch() → globalEpoch = 2
  // - name.lastChangedEpoch = 2
  // - atomDidChange(name, 'Bob')
  //   - 在事务中!
  //   - txn.initialAtomValues.set(name, 'Bob')
  //   - 返回(不调用 flushChanges)
  
  count.set(5)
  // - advanceGlobalEpoch() → globalEpoch = 3
  // - count.lastChangedEpoch = 3
  // - atomDidChange(count, 0)
  //   - 在事务中!
  //   - txn.initialAtomValues.set(count, 0)
  //   - 返回
  
  // 此时 logger2 还没有被触发!
})
// 3. txn.commit()
// 4. flushChanges([name, count])
//    - globalIsReacting = true
//    - reactionEpoch = 3
//    - 收集依赖,执行 logger2
//    - logger2 运行一次(不是两次!)
//    - 输出:'Charlie: 5'

##四、HistoryBuffer 和 RESET_VALUE

###为什么需要 HistoryBuffer?

对于时间旅行/undo 功能,系统需要知道"从 epoch X 到现在发生了什么变化":

// packages/state/src/lib/HistoryBuffer.ts
export class HistoryBuffer<Diff> {
  private index = 0
  buffer: Array<RangeTuple<Diff> | undefined>  // [fromEpoch, toEpoch, diff]
  
  pushEntry(lastComputedEpoch: number, currentEpoch: number, diff: Diff) {
    // 保存 diff
    this.buffer[this.index] = [lastComputedEpoch, currentEpoch, diff]
    // 循环缓冲区
    this.index = (this.index + 1) % this.capacity
  }
  
  getChangesSince(sinceEpoch: number): RESET_VALUE | Diff[] {
    // 查找从 sinceEpoch 之后的所有 diffs
    // 如果历史不足(太久前),返回 RESET_VALUE
  }
}

###RESET_VALUE 符号

// packages/state/src/lib/types.ts
export const RESET_VALUE = Symbol.for('com.tldraw.state/RESET_VALUE')

当历史缓冲区无法提供完整的 diffs(因为太久以前),会返回 RESET_VALUE,表示需要重新初始化该信号的值:

// packages/state/src/lib/Atom.ts
getDiffSince(epoch: number): RESET_VALUE | Diff[] {
  maybeCaptureParent(this)
  
  if (epoch >= this.lastChangedEpoch) {
    return EMPTY_ARRAY
  }
  
  return this.historyBuffer?.getChangesSince(epoch) ?? RESET_VALUE
}

这对于 tldraw 的同步系统非常重要——它可以检测到"你的历史太久远了,需要完整的状态重新开始"。


##五、ArraySet:优化的数据结构

由于 Atom 和 Computed 需要频繁维护大量的父子关系,tldraw 实现了 ArraySet——混合数组/Set 的数据结构:

// packages/state/src/lib/ArraySet.ts
export class ArraySet<T> {
  private items: T[] = []
  private itemSet?: Set<T>  // 只在元素超过 8 个时创建
  
  add(item: T) {
    if (this.items.length < 8) {
      if (!this.items.includes(item)) {
        this.items.push(item)
      }
    } else {
      if (!this.itemSet) {
        this.itemSet = new Set(this.items)
      }
      this.itemSet.add(item)
    }
  }
}

为什么? 因为大多数信号只有很少的依赖(< 8)。数组比 Set 更快,更节省内存。


##六、Capture 系统:依赖自动追踪

这是最聪明的部分。当计算 Computed 值时,系统需要知道它访问了哪些 signals。

// packages/state/src/lib/capture.ts
class CaptureStackFrame {
  offset = 0
  maybeRemoved?: Signal<any>[]
  
  constructor(
    public readonly below: CaptureStackFrame | null,
    public readonly child: Child
  ) {}
}

const inst = singleton('capture', () => ({ stack: null as null | CaptureStackFrame }))

export function startCapturingParents(child: Child) {
  inst.stack = new CaptureStackFrame(inst.stack, child)
  child.parentSet.clear()  // 重置,准备捕获新依赖
}

export function stopCapturingParents() {
  // 处理新增的依赖、移除的依赖
  // 更新 parent 和 parentEpochs 数组
  inst.stack = inst.stack!.below
}

export function maybeCaptureParent(parent: Signal<any>) {
  if (!inst.stack) return  // 不在捕获上下文中
  
  const child = inst.stack.child
  // 记录父子关系
  attach(parent, child)
  child.parentSet.add(parent)
}

使用 Capture Stack 的原因:支持嵌套的计算。当 Computed A 依赖 Computed B,而 B 依赖 Atom C 时:

const c = atom('c', 1)
const b = computed('b', () => c.get() * 2)
const a = computed('a', () => b.get() + 10)

// 当访问 a.get() 时:
// 1. startCapturingParents(a)  → stack.child = a
// 2. a.derive() 调用 b.get()
// 3. startCapturingParents(b)  → stack.child = b, stack.below 指向 a 的 frame
// 4. b.derive() 调用 c.get()
// 5. maybeCaptureParent(c) → 将 c 添加到 b.parents(而不是 a)
// 6. stopCapturingParents() → 回到 a 的 frame
// 7. b.get() 返回,a.derive() 继续
// 8. maybeCaptureParent(b) → 将 b 添加到 a.parents
// 9. stopCapturingParents() → 完成

##七、EffectScheduler 和 Reactor

Effects(通过 react()reactor() 创建)需要智能调度,避免多次运行:

// packages/state/src/lib/EffectScheduler.ts
export function react<Result>(
  name: string,
  runEffect: (lastReactedEpoch: number) => Result,
  options?: EffectSchedulerOptions
): () => void {
  const scheduler = new EffectScheduler(name, runEffect, options)
  scheduler.start()
  return () => scheduler.stop()
}

class EffectScheduler<Result> {
  lastReactedEpoch = GLOBAL_START_EPOCH
  lastTraversedEpoch = GLOBAL_START_EPOCH
  
  maybeScheduleEffect() {
    // 关键优化:避免重复运行
    if (!this._isActivelyListening) return
    if (this.lastReactedEpoch === getGlobalEpoch()) return  // 已在此 epoch 运行
    if (this.parents.length && !haveParentsChanged(this)) {
      this.lastReactedEpoch = getGlobalEpoch()
      return  // 父节点未变,无需运行
    }
    
    // 安排运行
    this._scheduleCount++
    const execute = () => {
      startCapturingParents(this)
      this.runEffect(this.lastReactedEpoch)
      stopCapturingParents()
      this.lastReactedEpoch = getGlobalEpoch()
    }
    
    if (this._scheduleEffect) {
      this._scheduleEffect(execute)  // 自定义调度(如 RAF)
    } else {
      execute()  // 立即运行
    }
  }
}

###自定义调度示例

let isScheduled = false
const effects: Array<() => void> = []

const stop = react('update-dom', () => {
  // DOM 更新逻辑
}, {
  scheduleEffect: (execute) => {
    effects.push(execute)
    if (!isScheduled) {
      isScheduled = true
      requestAnimationFrame(() => {
        isScheduled = false
        effects.forEach(f => f())
        effects.length = 0
      })
    }
  }
})

##八、与 SolidJS 的对比

CONTEXT.md 提到"similar to MobX or SolidJS reactivity"。让我指出关键区别:

特性SolidJStldraw/state
Epoch/Version tracking✓ 隐式✓ 显式(便于调试)
事务 + 回滚✓ 有限✓ 完整支持
HistoryBuffer✓ 内置(undo/redo)
框架绑定与 Solid 紧耦合完全独立
自定义效果调度✓ 支持 scheduleEffect
时间旅行/Diff追踪需要插件✓ 核心功能

##九、完整的依赖图示

Atom ('name')
├─ lastChangedEpoch: 当前值改变的全局时刻
├─ children: ArraySet<[Computed('greeting'), Effect('logger')]>
├─ historyBuffer?: 存储 diffs,用于 undo/redo
└─ set()/update(): 触发 advanceGlobalEpoch 和 atomDidChange

  ↓ (通过 get() 创建依赖)

Computed ('greeting')
├─ lastChangedEpoch: 上次计算的全局时刻
├─ parents: [Atom('name')]
├─ parentEpochs: [上次看到的 name 的 epoch]
├─ children: ArraySet<[Effect('logger')]>
└─ get(): 
   - 检查 haveParentsChanged?
   - 若是,调用 derive()
   - 自动捕获新的依赖

  ↓ (通过 get() 创建依赖)

Effect ('logger')
├─ lastReactedEpoch: 上次执行的全局时刻
├─ lastTraversedEpoch: 上次遍历的全局时刻
├─ parents: [Computed('greeting')]
├─ parentEpochs: [上次看到的 greeting 的 epoch]
└─ maybeScheduleEffect():
   - 检查 haveParentsChanged?
   - 若是,调用 runEffect()

全局状态 (singleton)
├─ globalEpoch: 所有改变的全局版本号
├─ reactionEpoch: 最后一次反应开始时的 epoch
├─ currentTransaction: 当前事务(支持嵌套)
├─ globalIsReacting: 防止反应中修改 atom
└─ cleanupReactors: 需要在反应中重新运行的 effects

##十、为什么使用 Epoch 而不是其他方式?

###方案对比

方案优点缺点
Epoch(tldraw选择)O(1) 比较, 内存小, 易于时间旅行需要全局状态
递归脏检查无需全局状态O(n) 性能, 易重复计算
发布-订阅+Set清晰的订阅关系内存开销大, 不支持时间旅行
Proxies自动追踪性能开销, 浏览器兼容性

对于绘图应用,epoch 是最优选择,因为:

  1. 比较速度极快(数字比较)
  2. 内存节省(只需一个数字,而不是依赖集合)
  3. 支持时间旅行(可以查询"epoch 100 时的值")
  4. 支持事务批处理(一个 reactionEpoch 对应一批改变)

##总结

tldraw 的 packages/state 不是盲目地复制 SolidJS 或重新发明轮子,而是为了满足绘图应用的特定需求而精心设计的:

框架无关 - 可用于任何 JS 环境
高性能 - Epoch O(1) 比较,细粒度更新
时间旅行 - HistoryBuffer 和 Diff 系统支持 undo/redo
事务支持 - 原子更新 + 自动回滚
协作友好 - Diff 系统便于同步和冲突解决

这就是为什么不使用 Jotai/Zustand 而要自己实现的原因。

Avatar of UnikeyServices
Unikey Services
commented

详细解释 packages/state-react 中提供的所有 API 的使用场景和各自不同的区别,有些 API 我从名字无法区分不同的区别

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

完美!现在我有足够的信息。让我为用户写一份详细的、对比清晰的完整解析文档。


#@tldraw/state-react 详细 API 解析

现在我为您详细解释 packages/state-react 中的所有 API,这些 API 虽然名字容易混淆,但其实各有特定的使用场景。

##核心分类和对比表

Hook作用何时使用是否需要手动追踪依赖
useValue读取单个信号值,或创建临时计算值需要获取信号当前值需要(deps 数组)
useAtom创建组件私有的反应式状态组件内部状态不需要
useComputed创建组件私有的派生值基于其他信号计算值不需要(deps 数组控制重建)
useReactor副作用,节流到下一帧视觉更新、DOM操作、Canvas需要(deps 数组)
useQuickReactor副作用,立即执行数据同步、网络请求需要(deps 数组)
trackHOC,自动追踪整个组件大多数场景,最方便都不需要
useStateTracking手动追踪部分渲染逻辑混合反应和非反应部分不需要(deps 数组可选)

##一、状态读取类 API(useValue、useAtom、useComputed)

###1. useValue - 读取外部信号

用途:订阅并读取由 atom()computed() 创建的全局或共享信号。

两种用法

// 用法 1:直接读取一个信号
const count = atom('count', 0)

function Counter() {
  const currentCount = useValue(count)  // 每次 count 改变时重新渲染
  return <div>{currentCount}</div>
}

// 用法 2:在组件中创建临时计算值
function UserProfile({ userId }: { userId: string }) {
  const user = useValue(
    'user-profile',
    () => {
      // 这个函数只在依赖改变时重新执行
      return fetchUser(userId)  // 使用 userId
    },
    [userId]  // 当 userId 改变时,重新创建计算
  )
  return <div>{user.name}</div>
}

实现原理

// packages/state-react/src/lib/useValue.ts
export function useValue() {
  const args = arguments
  const deps = args.length === 3 ? args[2] : [args[0]]
  
  const { $val, subscribe, getSnapshot } = useMemo(() => {
    // 如果是单参数,直接使用 args[0](信号)
    // 如果是三参数,创建一个新的 computed 信号
    const $val = args.length === 1 
      ? (args[0] as Signal<any>) 
      : computed(name, args[1])
    
    return {
      $val,
      subscribe: (notify: () => void) => {
        // 通过 react() 订阅信号变化
        return react(`useValue(${name})`, () => {
          try {
            $val.get()  // 访问信号,它会追踪依赖
          } catch {
            // 错误处理
          }
          notify()  // 通知 React 组件需要重新渲染
        })
      },
      getSnapshot: () => $val.lastChangedEpoch,  // 用于 useSyncExternalStore
    }
  }, deps)
  
  // 使用 React 18 的 useSyncExternalStore 进行订阅
  useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
  
  // 返回实际值(不是信号),使用 __unsafe__getWithoutCapture 避免依赖追踪
  return $val.__unsafe__getWithoutCapture()
}

关键特点

  • 必须是全局或共享的信号(在组件外创建)
  • 每次信号改变都会导致组件重新渲染
  • 使用 useSyncExternalStore 确保 React 18 兼容性

示例场景

// ✅ 全局主题
const theme = atom('theme', 'light')

function App() {
  const currentTheme = useValue(theme)
  return <div style={{ background: currentTheme === 'dark' ? '#000' : '#fff' }} />
}

// ✅ 从共享状态计算
const selectedUserId = atom('selectedUserId', null)
const selectedUser = computed('selectedUser', () => {
  const id = selectedUserId.get()
  return id ? users.get().find(u => u.id === id) : null
})

function UserDetail() {
  const user = useValue(selectedUser)
  return <div>{user?.name}</div>
}

###2. useAtom - 创建组件私有状态

用途:在组件内部创建反应式状态,类似于 useState 但带有完整的反应式系统。

使用方式

function TodoItem() {
  // 最简单的用法
  const completed = useAtom('completed', false)
  
  // 支持函数初始化
  const complexState = useAtom('data', () => {
    return expensiveComputation()  // 只调用一次
  })
  
  // 支持选项
  const user = useAtom(
    'user',
    { id: 1, name: 'Alice' },
    {
      isEqual: (a, b) => a.id === b.id  // 自定义相等性检查
    }
  )
  
  return (
    <input
      type="checkbox"
      checked={completed.get()}
      onChange={(e) => completed.set(e.target.checked)}
    />
  )
}

实现原理

// packages/state-react/src/lib/useAtom.ts
export function useAtom<Value, Diff = unknown>(
  name: string,
  valueOrInitialiser: Value | (() => Value),
  options?: AtomOptions<Value, Diff>
): Atom<Value, Diff> {
  // 使用 useState 确保 atom 只创建一次,且在组件卸载时清理
  return useState(() => {
    const initialValue =
      typeof valueOrInitialiser === 'function' 
        ? (valueOrInitialiser as any)() 
        : valueOrInitialiser
    
    // 创建真实的 atom,但作用域限制在组件内
    return atom(`useAtom(${name})`, initialValue, options)
  })[0]  // 只返回 atom,不返回 setter(因为 atom.set() 就是 setter)
}

关键特点

  • 返回完整的 Atom 对象(不是值),可以调用 .get().set()
  • 每个组件实例有自己的 atom 副本(私有状态)
  • 自动清理:组件卸载时 atom 被垃圾回收
  • 支持与其他信号组合

与 useState 的对比

// ❌ useState:需要手动追踪和更新
function Counter1() {
  const [count, setCount] = useState(0)
  
  // 如果要用 count 更新其他内容,需要另一个 useEffect
  useEffect(() => {
    document.title = `Count: ${count}`
  }, [count])
  
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

// ✅ useAtom + track:自动反应
const Counter2 = track(function Counter2() {
  const count = useAtom('count', 0)
  
  // 在 tracked 组件中,对 count 的修改会自动触发相关反应
  useQuickReactor('update-title', () => {
    document.title = `Count: ${count.get()}`
  }, [count])
  
  return <button onClick={() => count.set(count.get() + 1)}>{count.get()}</button>
})

###3. useComputed - 创建组件私有派生值

用途:在组件内创建从其他信号自动派生的值,类似于 useMemo 但自动追踪依赖。

使用方式

function ShoppingCart() {
  const items = useAtom('items', [])
  
  // 基础用法:自动追踪 items
  const total = useComputed(
    'total',
    () => {
      return items.get().reduce((sum, item) => sum + item.price, 0)
    },
    [items]  // 当 items 变化时重新计算
  )
  
  // 高级用法:带自定义选项
  const sortedItems = useComputed(
    'sortedItems',
    () => {
      return items.get().sort((a, b) => a.name.localeCompare(b.name))
    },
    {
      // 自定义相等检查,防止不必要的更新
      isEqual: (a, b) => 
        a.length === b.length && 
        a.every((item, i) => item.id === b[i].id)
    },
    [items]
  )
  
  return <div>Total: ${total.get()}</div>
}

实现原理

// packages/state-react/src/lib/useComputed.ts
export function useComputed() {
  const name = arguments[0]
  const compute = arguments[1]
  const opts = arguments.length === 3 ? undefined : arguments[2]
  const deps = arguments.length === 3 ? arguments[2] : arguments[3]
  
  // 使用 useMemo 确保只在依赖变化时重建 computed
  return useMemo(() => {
    return computed(`useComputed(${name})`, compute, opts)
  }, deps)
}

关键特点

  • 返回 Computed 对象,调用 .get() 获取值
  • deps 数组不是指"什么时候重新计算",而是"什么时候重建 computed 对象"
  • computed 内部自动追踪实际依赖的信号(不需要手动指定)
  • 支持自定义 isEqual 来优化不必要的更新

useComputed vs useMemo

// ❌ useMemo:值改变时必须重新计算
function List({ items }) {
  const total = useMemo(() => {
    console.log('Computing total...')
    return items.reduce((sum, item) => sum + item.price, 0)
  }, [items])
  
  // 即使 items 没变,函数重新运行也会重新计算
  return <div>Total: {total}</div>
}

// ✅ useComputed:自动追踪,智能更新
function List2({ items }) {
  const itemsAtom = useAtom('items', items)
  
  const total = useComputed('total', () => {
    console.log('Computing total...')
    return itemsAtom.get().reduce((sum, item) => sum + item.price, 0)
  }, [itemsAtom])
  
  // 组件重新渲染时,computed 会自动检查 itemsAtom 是否真的改变
  // 如果没改变,不会重新计算
  return <div>Total: {total.get()}</div>
}

##二、副作用类 API(useReactor vs useQuickReactor)

###useReactor - 节流副作用

用途:运行副作用,自动节流到下一个动画帧。适合视觉更新。

function Canvas() {
  const shapes = useAtom('shapes', [])
  const camera = useAtom('camera', { x: 0, y: 0, z: 1 })
  
  // 当 shapes 或 camera 改变时,更新 canvas
  // 但最多每帧更新一次
  useReactor('render-canvas', () => {
    console.log('Rendering canvas...')
    redrawCanvas(shapes.get(), camera.get())
  }, [shapes, camera])
  
  return <canvas />
}

实现原理

// packages/state-react/src/lib/useReactor.ts
export function useReactor(name: string, reactFn: () => void, deps: any[] = []) {
  useEffect(() => {
    let cancelFn: () => void | undefined
    
    const scheduler = new EffectScheduler(name, reactFn, {
      // 关键:自定义调度,使用 throttleToNextFrame
      scheduleEffect: (cb) => {
        cancelFn = throttleToNextFrame(cb)  // 节流到下一帧
      },
    })
    
    scheduler.attach()
    scheduler.execute()  // 立即执行一次
    
    return () => {
      scheduler.detach()
      cancelFn?.()  // 清理节流
    }
  }, deps)
}

关键特点

  • 副作用会被节流到 requestAnimationFrame
  • 如果状态快速改变多次,副作用最多执行一次(在下一帧)
  • 适合频繁改变的状态(如鼠标移动、拖拽)

何时使用

// ✅ Canvas/WebGL 渲染
useReactor('canvas-render', () => {
  renderScene(scene.get())
}, [scene])

// ✅ DOM 类名更新
useReactor('update-classes', () => {
  document.body.className = getThemeClasses(theme.get())
}, [theme])

// ✅ 动画状态
useReactor('animate', () => {
  animationRef.current?.update(animationState.get())
}, [animationState])

###useQuickReactor - 立即副作用

用途:立即运行副作用,不节流。适合需要立即响应的场景。

function DataSync() {
  const unsavedData = useAtom('unsavedData', {})
  const isOnline = useAtom('isOnline', true)
  
  // 立即发送到服务器,不能等待下一帧
  useQuickReactor('sync-data', () => {
    if (isOnline.get()) {
      sendToServer(unsavedData.get())
    }
  }, [unsavedData, isOnline])
  
  return <div>Syncing...</div>
}

实现原理

// packages/state-react/src/lib/useQuickReactor.ts
export function useQuickReactor(name: string, reactFn: () => void, deps: any[] = []) {
  useEffect(() => {
    // 注意:没有 scheduleEffect 选项,所以立即执行
    const scheduler = new EffectScheduler(name, reactFn)  // 没有第三个参数
    
    scheduler.attach()
    scheduler.execute()  // 立即执行
    
    return () => {
      scheduler.detach()
    }
  }, deps)
}

关键特点

  • 副作用同步执行(在信号改变的同一时刻)
  • 没有 RAF 节流
  • 多个快速改变会触发多次副作用

何时使用

// ✅ 网络请求
useQuickReactor('fetch-user', () => {
  fetchUser(userId.get()).then(data => {
    userData.set(data)
  })
}, [userId])

// ✅ 日志记录
useQuickReactor('log-action', () => {
  logger.info('Action:', currentAction.get())
}, [currentAction])

// ✅ 生命周期通知
useQuickReactor('on-ready', () => {
  if (isReady.get()) {
    onReadyCallback()
  }
}, [isReady])

// ❌ NOT Canvas 渲染(应该用 useReactor)
// ❌ NOT 频繁的 DOM 更新(应该用 useReactor)

##三、组件级 API(track vs useStateTracking)

###track - 最方便的自动追踪

用途:自动追踪组件中访问的所有信号,当任何信号改变时重新渲染。

const counter = atom('counter', 0)
const doubled = computed('doubled', () => counter.get() * 2)

// ✅ track 自动追踪 counter 和 doubled
const Counter = track(function Counter() {
  return (
    <div>
      <div>Count: {counter.get()}</div>
      <div>Doubled: {doubled.get()}</div>
      <button onClick={() => counter.set(counter.get() + 1)}>
        Increment
      </button>
    </div>
  )
})

实现原理

// packages/state-react/src/lib/track.ts
export function track<T extends FunctionComponent<any>>(
  baseComponent: T
): React.NamedExoticComponent<React.ComponentProps<T>> {
  // 处理已经 memo 的组件
  let compare = null
  const $$typeof = baseComponent['$$typeof']
  if ($$typeof === ReactMemoSymbol) {
    baseComponent = (baseComponent as any).type
    compare = (baseComponent as any).compare
  }
  
  // 处理 forwardRef 组件
  if ($$typeof === ReactForwardRefSymbol) {
    return memo(
      forwardRef(new Proxy((baseComponent as any).render, ProxyHandlers) as any)
    ) as any
  }
  
  // 用 Proxy 拦截函数调用,在其中使用 useStateTracking
  return memo(
    new Proxy(baseComponent, ProxyHandlers) as any, 
    compare
  ) as any
}

// ProxyHandlers 拦截函数调用
export const ProxyHandlers = {
  apply(Component: FunctionComponent, thisArg: any, argumentsList: any) {
    // 关键:在 useStateTracking 中运行组件
    return useStateTracking(
      Component.displayName ?? Component.name ?? 'tracked(???)',
      () => Component.apply(thisArg, argumentsList)
    )
  },
}

关键特点

  • 自动用 React.memo 包装,避免不必要的重渲染
  • 通过 JavaScript Proxy 拦截组件函数调用
  • 自动追踪组件中所有信号的访问
  • 支持 forwardRefmemo 组件

最佳实践

// ✅ 使用 track 的标准模式
const MyComponent = track(function MyComponent({ userId }) {
  const user = useValue('user', () => fetchUser(userId), [userId])
  const theme = useValue(themeSignal)
  
  return <div>User: {user.name}, Theme: {theme}</div>
})

// ✅ 与 useAtom 组合
const Editor = track(function Editor() {
  const content = useAtom('content', '')
  const wordCount = useComputed('wordCount', () => {
    return content.get().split(/\s+/).length
  }, [content])
  
  return (
    <textarea
      value={content.get()}
      onChange={(e) => content.set(e.target.value)}
    />
  )
})

// ❌ 不需要在 track 内部再用 useValue(重复)
// ❌ 不需要在 track 内部用 useMemo(track 已经优化了)

###useStateTracking - 手动追踪部分逻辑

用途:在组件的特定部分启用信号追踪,而不是整个组件。

function MixedComponent() {
  // 普通 React 状态
  const [regularState, setRegularState] = useState(0)
  
  // 只有这部分追踪信号
  const reactiveContent = useStateTracking('reactive-section', () => {
    return <div>Theme: {themeSignal.get()}</div>
  }, [])
  
  return (
    <div>
      <button onClick={() => setRegularState(s => s + 1)}>
        Regular: {regularState}
      </button>
      {reactiveContent}
    </div>
  )
}

实现原理

// packages/state-react/src/lib/useStateTracking.ts
export function useStateTracking<T>(
  name: string,
  render: () => T,
  deps: unknown[] = []
): T {
  // 保存最新的 render 函数
  const renderRef = React.useRef(render)
  renderRef.current = render
  
  // 创建 EffectScheduler 来追踪依赖
  const [scheduler, subscribe, getSnapshot] = React.useMemo(() => {
    let scheduleUpdate = null as null | (() => void)
    
    const subscribe = (cb: () => void) => {
      scheduleUpdate = cb
      return () => { scheduleUpdate = null }
    }
    
    const scheduler = new EffectScheduler(
      `useStateTracking(${name})`,
      () => renderRef.current?.(),
      {
        scheduleEffect() {
          scheduleUpdate?.()  // 触发重新渲染
        },
      }
    )
    
    // 用 scheduleCount 作为快照版本
    const getSnapshot = () => scheduler.scheduleCount
    
    return [scheduler, subscribe, getSnapshot]
  }, [name, ...deps])
  
  // 使用 useSyncExternalStore 进行订阅
  React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
  
  // 在 useEffect 中附加 scheduler
  React.useEffect(() => {
    scheduler.attach()
    scheduler.maybeScheduleEffect()
    return () => {
      scheduler.detach()
    }
  }, [scheduler])
  
  // 立即执行并返回结果
  return scheduler.execute()
}

关键特点

  • 粒度更细,可以只追踪部分渲染逻辑
  • 允许混合使用 React hooks 和信号
  • 支持 Suspense(可以 throw promises)
  • 自动清理

何时使用

// ✅ 混合 React 状态和信号
function SearchableList() {
  const [searchInput, setSearchInput] = useState('')
  
  // 只有这部分需要追踪 filteredItems 信号
  const listContent = useStateTracking('list', () => {
    const items = filteredItems.get()
    return (
      <ul>
        {items.map(item => <li key={item.id}>{item.name}</li>)}
      </ul>
    )
  }, [])
  
  return (
    <div>
      <input
        value={searchInput}
        onChange={(e) => setSearchInput(e.target.value)}
      />
      {listContent}
    </div>
  )
}

// ✅ 支持 Suspense
function UserProfile() {
  const userId = useStateTracking('user-fetch', () => {
    const id = userIdSignal.get()
    if (id === null) {
      throw new Promise(resolve => {
        fetchUser(id).then(() => resolve(null))
      })
    }
    return id
  }, [])
  
  return <div>User ID: {userId}</div>
}

// ❌ 如果整个组件都是反应式的,用 track 更简单
// ❌ 不需要在 useStateTracking 内使用 useEffect(脱离追踪上下文)

##四、完整使用示例对比

###场景 1:简单计数器

// ❌ 没必要用 useValue,因为 atom 是局部的
function Counter1() {
  const count = useAtom('count', 0)
  return useValue(count)  // 冗余
}

// ✅ 直接用 track
const Counter2 = track(function Counter2() {
  const count = useAtom('count', 0)
  return (
    <button onClick={() => count.set(count.get() + 1)}>
      Count: {count.get()}
    </button>
  )
})

###场景 2:全局主题切换

// 全局信号
const theme = atom('theme', 'light')

// ✅ useValue 读取全局信号
function ThemeSwitch() {
  const currentTheme = useValue(theme)
  
  return (
    <button
      onClick={() => theme.set(currentTheme === 'light' ? 'dark' : 'light')}
    >
      Current: {currentTheme}
    </button>
  )
}

// ✅ 或者用 track
const ThemedApp = track(function ThemedApp() {
  return (
    <div style={{ background: theme.get() === 'dark' ? '#000' : '#fff' }}>
      <ThemeSwitch />
    </div>
  )
})

###场景 3:复杂的购物车

function ShoppingCart() {
  // 创建组件私有状态
  const items = useAtom('items', [] as CartItem[])
  const couponCode = useAtom('couponCode', '')
  
  // 创建派生值:总价
  const subtotal = useComputed('subtotal', () => {
    return items.get().reduce((sum, item) => sum + item.price * item.qty, 0)
  }, [items])
  
  // 创建派生值:折扣
  const discount = useComputed('discount', () => {
    const code = couponCode.get()
    const sub = subtotal.get()
    return code === 'SAVE10' ? sub * 0.1 : 0
  }, [couponCode, subtotal])
  
  // 创建派生值:最终价格
  const total = useComputed('total', () => {
    return subtotal.get() - discount.get()
  }, [subtotal, discount])
  
  // 副作用:保存到 localStorage(立即)
  useQuickReactor('save-cart', () => {
    localStorage.setItem('cart', JSON.stringify(items.get()))
  }, [items])
  
  // 副作用:更新订单摘要(节流)
  useReactor('update-summary', () => {
    updateOrderSummary({
      subtotal: subtotal.get(),
      discount: discount.get(),
      total: total.get(),
    })
  }, [subtotal, discount, total])
  
  return (
    <div>
      <h2>Shopping Cart</h2>
      <CartItemList items={items} />
      <CouponInput coupon={couponCode} />
      <OrderSummary
        subtotal={subtotal.get()}
        discount={discount.get()}
        total={total.get()}
      />
    </div>
  )
}

// 用 track 包装以优化性能
const ShoppingCartPage = track(ShoppingCart)

###场景 4:Canvas 绘图应用

const CanvasApp = track(function CanvasApp() {
  const shapes = useAtom('shapes', [] as Shape[])
  const camera = useAtom('camera', { x: 0, y: 0, z: 1 })
  const selectedId = useAtom('selectedId', null as string | null)
  
  // 计算派生值:当前选中的形状
  const selectedShape = useComputed('selectedShape', () => {
    const id = selectedId.get()
    return id ? shapes.get().find(s => s.id === id) : null
  }, [shapes, selectedId])
  
  const canvasRef = useRef<HTMLCanvasElement>(null)
  
  // 视觉更新:渲染 canvas(使用 useReactor 节流)
  useReactor('render-canvas', () => {
    const ctx = canvasRef.current?.getContext('2d')
    if (!ctx) return
    
    // 清空
    ctx.clearRect(0, 0, 800, 600)
    
    // 应用相机
    ctx.save()
    ctx.translate(camera.get().x, camera.get().y)
    ctx.scale(camera.get().z, camera.get().z)
    
    // 绘制形状
    shapes.get().forEach(shape => {
      if (shape.id === selectedId.get()) {
        ctx.strokeStyle = 'blue'
      } else {
        ctx.strokeStyle = 'black'
      }
      drawShape(ctx, shape)
    })
    
    ctx.restore()
  }, [shapes, camera, selectedId])
  
  // 数据持久化:保存到服务器(使用 useQuickReactor)
  useQuickReactor('persist-shapes', () => {
    saveShapesToServer(shapes.get())
  }, [shapes])
  
  const handleCanvasClick = (e: MouseEvent) => {
    const [x, y] = getCanvasCoordinates(e, camera.get())
    const clickedShape = shapes.get().find(s => isPointInShape(x, y, s))
    selectedId.set(clickedShape?.id ?? null)
  }
  
  return (
    <div>
      <canvas
        ref={canvasRef}
        width={800}
        height={600}
        onClick={handleCanvasClick}
      />
      {selectedShape.get() && (
        <ShapeProperties shape={selectedShape.get()} />
      )}
    </div>
  )
})

##五、性能最佳实践

###1. 选择正确的 hook

// ❌ 过度使用 useValue(每个都订阅一次)
function Heavy() {
  const a = useValue(signalA)
  const b = useValue(signalB)
  const c = useValue(signalC)
  return <div>{a}{b}{c}</div>
}

// ✅ 用 track 一次性追踪所有
const Heavy = track(function Heavy() {
  return <div>{signalA.get()}{signalB.get()}{signalC.get()}</div>
})

###2. 合理使用 deps 数组

// ❌ deps 太多,频繁重建 computed
const filtered = useComputed(
  'filtered',
  () => items.get().filter(x => x.active),
  [items, theme, user, settings]  // 太多了
)

// ✅ 只依赖真正使用的信号
const filtered = useComputed(
  'filtered',
  () => items.get().filter(x => x.active),
  [items]  // 只依赖 items
)

###3. 分离视觉更新和数据同步

const Editor = track(function Editor() {
  const content = useAtom('content', '')
  
  // 视觉更新:节流
  useReactor('highlight-syntax', () => {
    highlightCode(editorRef.current, content.get())
  }, [content])
  
  // 数据同步:立即
  useQuickReactor('auto-save', () => {
    autosave(content.get())
  }, [content])
})

##总结表格

API状态范围返回类型何时重渲染何时使用
useValue全局信号改变读取全局信号
useAtom组件私有Atom 对象调用 .set() 时组件内部状态
useComputed组件私有Computed 对象依赖改变派生值
useReactorN/Avoid信号改变(节流)视觉更新
useQuickReactorN/Avoid信号改变(立即)数据同步
track全组件HOC任何访问的信号改变整个组件反应式
useStateTracking部分返回值访问的信号改变混合反应式和普通

这样您就能清晰地理解和选择正确的 API 了!


END OF POST

Why does tldraw implement its own packages/state? — tldraw/tldraw