Why does tldraw implement its own packages/state?
详细解释 tldraw 中是如何实现 packages/state 框架的,这是一个类似 solidjs signal 的,详细解释为什么它不使用 jotai / zustand 而要实现,是因为要做到框架无关吗?详细解释其设计逻辑和实现原理,我看不懂为什么这里有很多 epoch 之类的设计
非常好!现在我有足够的信息。让我用中文为用户写一份详细的解析文档。
#tldraw packages/state 框架详细解析
我已经深入研究了 tldraw 的 packages/state 框架。这是一个非常精心设计的响应式状态管理库。让我为您详细解释其设计逻辑和实现原理。
##一、为什么需要自己实现而不用 Jotai/Zustand?
###核心原因:框架无关性 + 性能需求 + 时间旅行能力
-
框架无关性(Framework Agnostic)
- tldraw 的
@tldraw/state完全独立于任何 UI 框架 - Jotai 深度集成 React(hooks),Zustand 虽然有 vanilla 支持但设计中心还是 React
- tldraw 需要支持多个渲染层(React、HTML5 Canvas、其他框架)
- tldraw 的
-
细粒度响应性(Fine-Grained Reactivity)
- 像 SolidJS 的 signal 系统那样,只重新计算实际依赖变化的部分
- Zustand 基于全局 store 订阅,Jotai 虽然原子化但不如 signal 系统高效
- tldraw 的协作绘图应用需要极高的性能(需要追踪数千个对象的变化)
-
内置时间旅行(Built-in Time-Travel)
- RESET_VALUE、HistoryBuffer 和 Diff 系统支持 undo/redo
- 这对绘图应用至关重要
- Zustand 和 Jotai 需要额外的中间件才能实现
-
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"。让我指出关键区别:
| 特性 | SolidJS | tldraw/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 是最优选择,因为:
- 比较速度极快(数字比较)
- 内存节省(只需一个数字,而不是依赖集合)
- 支持时间旅行(可以查询"epoch 100 时的值")
- 支持事务批处理(一个 reactionEpoch 对应一批改变)
##总结
tldraw 的 packages/state 不是盲目地复制 SolidJS 或重新发明轮子,而是为了满足绘图应用的特定需求而精心设计的:
✅ 框架无关 - 可用于任何 JS 环境
✅ 高性能 - Epoch O(1) 比较,细粒度更新
✅ 时间旅行 - HistoryBuffer 和 Diff 系统支持 undo/redo
✅ 事务支持 - 原子更新 + 自动回滚
✅ 协作友好 - Diff 系统便于同步和冲突解决
这就是为什么不使用 Jotai/Zustand 而要自己实现的原因。
详细解释 packages/state-react 中提供的所有 API 的使用场景和各自不同的区别,有些 API 我从名字无法区分不同的区别
完美!现在我有足够的信息。让我为用户写一份详细的、对比清晰的完整解析文档。
#@tldraw/state-react 详细 API 解析
现在我为您详细解释 packages/state-react 中的所有 API,这些 API 虽然名字容易混淆,但其实各有特定的使用场景。
##核心分类和对比表
| Hook | 作用 | 何时使用 | 是否需要手动追踪依赖 |
|---|---|---|---|
useValue | 读取单个信号值,或创建临时计算值 | 需要获取信号当前值 | 需要(deps 数组) |
useAtom | 创建组件私有的反应式状态 | 组件内部状态 | 不需要 |
useComputed | 创建组件私有的派生值 | 基于其他信号计算值 | 不需要(deps 数组控制重建) |
useReactor | 副作用,节流到下一帧 | 视觉更新、DOM操作、Canvas | 需要(deps 数组) |
useQuickReactor | 副作用,立即执行 | 数据同步、网络请求 | 需要(deps 数组) |
track | HOC,自动追踪整个组件 | 大多数场景,最方便 | 都不需要 |
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 拦截组件函数调用
- 自动追踪组件中所有信号的访问
- 支持
forwardRef和memo组件
最佳实践:
// ✅ 使用 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 对象 | 依赖改变 | 派生值 |
useReactor | N/A | void | 信号改变(节流) | 视觉更新 |
useQuickReactor | N/A | void | 信号改变(立即) | 数据同步 |
track | 全组件 | HOC | 任何访问的信号改变 | 整个组件反应式 |
useStateTracking | 部分 | 返回值 | 访问的信号改变 | 混合反应式和普通 |
这样您就能清晰地理解和选择正确的 API 了!
END OF POST