RFC: lockData — 受控只读数据锁
status: accepted
author: cmtlyt
create time: 2026/04/27 11:50:00
rfc version: 0.1.5
scope: src/shared/lock-data
版本历史
RFC 版本独立维护,不跟随包版本。语义:
- 0.x.y(
draft / review 阶段):
x → 重大设计变更(新增 / 删除 / 语义翻转一级字段或协议)
y → 澄清、措辞、示例调整
status 迁移(draft → review → accepted)本身不触发版本递增;只有对应分支已推送 / 已被外部引用后的再次变更才需递增
- 1.0.0:评审通过(
status: accepted)后一次性升级
- 1.x.y 及以后:仅在已 accepted 的 RFC 再做追加或修订时递增
背景与动机
在构建工具库、状态管理、配置中心等场景时,经常会遇到这样的诉求:
- 某份数据在"持有方"视角下只读,防止被外部(插件、业务代码)直接篡改
- 修改行为必须收敛到一组受控 API(鉴权 / 埋点 / 审计 / 校验)
- 在多 Worker、多 Tab 同时持有"同一份逻辑数据"时,需要一把互斥锁来保证修改的串行化
现有的 Object.freeze、Proxy 读写拦截、immer 都只能解决同线程同文档内的只读语义,对跨线程 / 跨标签页场景无能为力。
WebAPI 里的 navigator.locks 天然提供了跨 Worker / 跨同源 Tab 的互斥锁原语,但它只管调度不管数据;而 BroadcastChannel、storage 事件提供了跨上下文广播能力。
本 RFC 的目标:将"数据只读代理 + 受控写入 actions + 跨上下文互斥锁"三者聚合成一个简洁的单入口 API lockData,并对不同浏览器能力做好降级。
目标与非目标
目标
- 提供
lockData(options) 单入口(单参数:getValue 必传,无独立 initial 入参),初始化恒同步(除非 getValue 返回 Promise),返回 [readonly, actions] 元组
- 初始化阶段只构建只读视图,不抢锁;只有
actions 的写入类方法才会去抢锁
readonly 是一个强制深只读视图,任何写入操作直接抛错(无开关);底层基于 wrapper Proxy 跟随 entry.dataRef.current 重新赋值(详见「readonly view 的引用稳定契约」章节)
actions.update(recipe, opts?)、actions.replace(next, opts?)、actions.getLock(opts?)、actions.snapshot()、actions.dispose() 作为唯一合法修改通道
- 顶层数组禁止:
getValue 返回类型不允许为数组(T extends unknown[] 在类型层被排除为 never,运行时双重 fail-fast 拒绝并抛 InvalidOptionsError),详见「顶层数组禁止」章节
- 当传入
id 时启用跨上下文锁,支持排队等待、超时、强制抢占(force 由 action 调用侧传入)
- 支持可选的数据同步
syncMode,基于 localStorage 维护权威副本(storage-authority)+ 单调 rev,保证跨 Tab 同源场景下"拿到锁 = 拿到最新值",并覆盖后台 Tab / bfcache / freeze 唤醒等边缘时序
- 可配置
persistence(默认 'session')控制权威副本的生命周期:会话级(所有 Tab 关闭即重置)或长期持久化
- 支持
getValue 自定义初始化(可同步、可异步),返回值类型决定 lockData 是否为 Promise
- 能力检测 + 多级降级:
navigator.locks → BroadcastChannel + token → localStorage + token
- 遵循项目既有风格:
throw-error 报错、dataHandler 校验、logger 日志、无实现细节外泄
非目标
- 不实现 CRDT 级别的冲突合并(
syncMode 基于锁序列化的权威副本 + 单调 rev 单向覆盖,CRDT 留给未来)
- 不实现 SharedArrayBuffer / Atomics 级别的共享内存互斥
- 不实现持久化存储(宿主进程退出锁自动释放)
- 不替代
immer / mobx / pinia,只做"锁 + 只读视图 + 受控写入"这三件事
名词约定
API 设计
总览
import { lockData, NEVER_TIMEOUT } from '@cmtlyt/lingshu-toolkit/shared'
// 同步初始化(getValue 同步返回值,且 syncMode !== 'storage-authority')
const [readonly, actions] = lockData({ getValue: () => initialData, /* ... */ })
// 异步初始化 —— 触发条件任一:
// 1. getValue 返回 Promise
// 2. syncMode === 'storage-authority'(需要在 resolve 前完成 localStorage 权威副本的首次 pull)
const [readonly, actions] = await lockData({ getValue: () => fetch(...).then(r => r.json()) })
const [readonly, actions] = await lockData({ id: 'x', syncMode: 'storage-authority', getValue: () => initialData })
核心语义:
lockData 的初始化阶段永远不抢锁,仅构建只读视图 + 预注册锁驱动
getValue 必传(不再有独立 initial 入参):返回 T 走同步路径,返回 Promise<T> 走异步路径
- 是否异步由
getValue 的返回值 与 syncMode 共同决定:
getValue 返回 Promise → Promise
syncMode === 'storage-authority' → Promise(resolve 前完成 localStorage 权威副本的首次 pull,让 readonly 拿到跨 Tab 最新值)
- 其他 → 同步
- 抢锁发生在
actions.update / actions.replace / actions.getLock 被调用时
- 同
id 在同进程内自动共享同一份 dataRef 引用:多次 lockData({ id, ... }) 返回独立的 actions 和 readonly 代理,但底层 entry.dataRef wrapper 引用是同一个;任一实例 commit 后 entry.dataRef.current 重新赋值,其他实例的 readonly(基于 wrapper Proxy 跟随 dataRef.current)读到的就是最新值,无需开启 syncMode
- 顶层数组禁止:
getValue 返回类型在类型层(T extends unknown[] ? never : T)+ 运行时(Array.isArray(awaited) fail-fast)双重排除,详见「顶层数组禁止」章节
AbortSignal 生命周期管理:
options.signal.aborted 后整个实例等价于 dispose(),后续所有 action 调用直接 reject LockDisposedError
actionCallOptions.signal.aborted 只影响本次调用:acquiring 阶段中止等价于 LockAbortedError;holding 阶段中止会丢弃本次 working copy 并 release 锁
签名
// 单签名 + 条件类型推断(T 自动从 O['getValue'] 反推,调用方无需显式传任何泛型)
function lockData<const O extends LockDataOptions<unknown>>(
options: O,
): LockDataResolveReturn<O>
// 从 options 中的 getValue 反推数据类型 T(同步 / 异步统一 Awaited)
type LockDataInfer<O> = O extends { getValue: () => infer R }
? Awaited<R> extends infer T extends object
? T
: never
: never
// 顶层数组禁止 + 条件类型分支判定的总入口
type LockDataResolveReturn<O extends object> =
LockDataValueShape<LockDataInfer<O>> extends infer T extends object
? LockDataReturn<T, O>
: never
// 三层条件分支(与 core/entry.ts 运行时判定优先级严格对齐)
type LockDataReturn<T extends object, O extends object> =
O extends { syncMode: 'storage-authority' }
? O extends { id: string }
? Promise<LockDataTuple<T>>
: never // syncMode='storage-authority' 缺 id → 编译期 fail-fast
: O extends { getValue: () => infer R }
? R extends Promise<unknown>
? Promise<LockDataTuple<T>>
: LockDataTuple<T>
: LockDataTuple<T>
type LockDataTuple<T extends object> = readonly [ReadonlyView<T>, LockDataActions<T>]
// LockDataOptions 中 getValue 必传,且类型禁止顶层数组:
interface LockDataOptions<T> {
readonly getValue: () => LockDataValueShape<T> | Promise<LockDataValueShape<T>>
// ... 其他字段(id / timeout / syncMode / persistence / adapters / signal / listeners 等)
}
// 顶层数组类型禁止(type-level fail-fast,覆盖 string[] / ReadonlyArray<X> / 元组等)
type LockDataValueShape<T> = T extends readonly unknown[] ? never : T
说明:
getValue 必传:getValue 是数据来源的唯一入口,不再有独立 initial 入参;getValue 返回值的 Promise/同步状态在运行时通过 result instanceof Promise 判定
- 条件类型自动推断:单签名 +
LockDataResolveReturn<O> 让 TypeScript 自动按 syncMode / id / getValue 返回值类型推断 lockData 返回 LockDataTuple<T> 还是 Promise<LockDataTuple<T>>,调用方无需显式传任何泛型也无需 as 断言
syncMode='storage-authority' 强制 id:缺 id 字段时 LockDataReturn 推断为 never,编译期直接拒绝该非法组合(authority 没有 id 无法绑定作用域),避免运行时静默 fallback 到 'none'
- 顶层数组类型层禁止:
LockDataValueShape<T> 把 T extends readonly unknown[] 排除为 never,编译期直接拦截 getValue: () => string[] / () => ReadonlyArray<X> / 元组等用法
- 约束放宽规避循环推断:
O extends LockDataOptions<unknown> 仅做最弱形状约束(避免「O 的约束依赖 LockDataInfer<O>、LockDataInfer<O> 又依赖 O」的循环),具体 T 反推与顶层数组校验都在返回值类型层完成
- 当
syncMode === 'storage-authority' 时,Promise 会在以下三个条件都满足时 resolve:
- localStorage 权威副本的
storage 事件订阅已就绪,BroadcastChannel 的 session-probe 订阅已就绪
- 会话 epoch 解析完成(仅
persistence === 'session' 阶段):
- sessionStorage 已有
${LOCK_PREFIX}:${id}:epoch → 直接继承(刷新 / bfcache 恢复 / 单 Tab 刷新)
- 否则广播
session-probe 并等待最多 sessionProbeTimeout(默认 100ms):
· 收到 session-reply → 继承响应方的 epoch,视作"同会话组新开 Tab"
· 探测超时 → 视作"首个 Tab / 所有 Tab 关闭后重启",清空 localStorage 权威副本并生成新 epoch
persistence === 'persistent' 时:epoch 固定为常量 'persistent',不做探测
- 对
localStorage 权威 key 同步 getItem + lazy parse + epoch 校验:
- 命中且
snapshot.epoch === entry.epoch → entry.dataRef.current = JSON.parse(JSON.stringify(snapshot)) 重新赋值(wrapper 方案下不再走 applyInPlace 原地覆写,详见「readonly view 的引用稳定契约」章节)
- 命中但 epoch 不一致 → 丢弃(视为上一会话组残留,已被步骤 2 的"清空"清理)
- 未命中(首次启动 / localStorage 不可用)→ 以本地
data / getValue 为准
LockDataOptions
字段总览(完整类型签名见「附录 A:完整接口索引」):
关键字段补充:
id 的双重语义:① 进程内单例键(同 id 共享 data 引用 + driver);② 跨进程唯一标识(自动拼接为 lock name lingshu:lock-data:<id>)
timeout 作用对象:① 抢锁排队超时;② 拿到锁后的持有期(recipe 执行 + 持锁)的最长时长;NEVER_TIMEOUT(导出的 unique symbol)表示永不超时
syncMode: 'storage-authority' 语义:commit 后写入 ${LOCK_PREFIX}:${id}:latest;其他 Tab 通过 authority.subscribe 按 rev 去重后原地更新;acquire / pageshow / visibilitychange 时主动 pull,保证"拿到锁 = 拿到最新值"
persistence: 'session' 语义:同会话组所有 Tab 关闭即重置;sessionStorage 维护 Tab 级 epoch,启动时通过 session-probe 探测同会话组;刷新 / bfcache 恢复直接继承 epoch 不走探测
signal 语义:aborted 后所有在途 action reject LockAbortedError,后续调用 reject LockDisposedError,并从 InstanceRegistry -1 引用计数
关键调整:
- ✅
adapters 聚合所有依赖倒置注入点(getLock / getAuthority / getChannel / getSessionStore / logger / clone),详见「依赖倒置与适配器」章节
- ✅ 锁状态观察迁移至
listeners.onLockStateChange;onRevoked 收敛进 listeners;新增 listeners.onSync / listeners.onCommit(审计钩子,携带 mutation log + snapshot)
- ✅
NEVER_TIMEOUT 为导出的 unique symbol,可用于 timeout / acquireTimeout / holdTimeout 任意位置
- ❌
force 从 options 移除(改为 action 调用侧参数,见下)
- ❌
deepReadonly 移除(深只读强制执行,无开关)
- ❌ 顶层
getLock 移除(收敛进 adapters.getLock,RFC 未发布非 breaking,见决策 #30)
ReadonlyView<T>
type ReadonlyView<T> = T extends (...args: any[]) => any
? T
: T extends object
? { readonly [K in keyof T]: ReadonlyView<T[K]> }
: T
实现语义:
- 基于 wrapper Proxy 实现:内部对
entry.dataRef: { current: T } 包一层 Proxy,所有 trap(get / has / ownKeys / getOwnPropertyDescriptor / getPrototypeOf 等)重定向到 dataRef.current
- 写 trap(
set / deleteProperty / defineProperty)统一抛 ReadonlyMutationError
get 命中对象类型时惰性包一层子 Proxy,避免一次性深拷贝;子 Proxy 走传统 target-based 形态
- 引用稳定:
readonly view 引用本身在 Entry 生命周期内永不变更;底层 entry.dataRef.current 在每次 commit / host.applyRemote(authority 远程同步)时被重新赋值,view 通过 wrapper trap 自动跟随 —— 详见「readonly view 的引用稳定契约」章节
structuredClone(view) 限制:Web 标准对 Proxy 的硬限制使 wrapper view 无法被 structuredClone 直接克隆;如需脱离 Proxy 拿快照,使用 actions.snapshot()(详见「actions.snapshot()」章节)
actions.snapshot()
提供"独立于 readonly view 的数据快照"语义,配合 wrapper Proxy 解决 structuredClone(view) 在浏览器抛 DOMException 的硬限制:
const [view, actions] = lockData({ getValue: () => ({ count: 0 }) })
const data = actions.snapshot() // ✅ 拿到一份 JSON 拷贝隔离的全新对象
data.count = 999 // 不影响内部 dataRef.current
view.count // 仍为 0(snapshot 与 view 完全解耦)
实现语义:
- 内部走
JSON.parse(JSON.stringify(entry.dataRef.current)) 产出全新对象(与「JSON 拷贝隔离契约」一致)
- 不抢锁、不触发 authority pull;只读取本 Tab 当前视角
syncMode: 'storage-authority' 下,snapshot() 可能返回本 Tab 视角的过时数据;需要跨 Tab 最新值请先 await actions.getLock() 再 snapshot()
readonly view 的引用稳定契约
旧契约(已废弃):「entry.data 引用在整个 Entry 生命周期内稳定,所有变更通过 applyInPlace 原地覆写」。
新契约(wrapper 方案):
用户面 view 引用稳定,但 view 内字段值会跟随 dataRef.current 重新赋值变化(通过 Proxy trap 解引用 dataRef.current 实现)。
已知语义瑕疵(用户决策接受):Object.isFrozen(view) 永远返回 false(wrapper Proxy 不是冻结对象),但任何写入 view 的操作仍被 trap 拒绝。
顶层数组禁止
getValue 返回类型不允许为顶层数组。双重拦截:
- 类型层 fail-fast(编译期):
LockDataValueShape<T> = T extends unknown[] ? never : T,把 getValue: () => string[] 这类用法在 TypeScript 编译期排除为 never
- 运行时 fail-fast:在
lockData() 入口处 if (Array.isArray(awaited)) throwError('lockData', '...', InvalidOptionsError),覆盖类型层被 as unknown / as any 绕过的场景;同步路径在 getValue() 调用后立即校验,异步路径在 await getValue() 之后立即校验
为什么禁止:wrapper Proxy 方案下,顶层数组会触发 Object.keys(view) / JSON.stringify(view) 的 length invariant TypeError、Array.isArray(view) 永远返回 false 等不可调和的不变量冲突;改为禁止后用户面错误信息明确(InvalidOptionsError: getValue must not return an array, ...),无歧义。
绕过方式:用户如确实需要数组语义,包装为对象 { items: [...] },对 view 而言 view.items 是数组、view.items[0] 走子 Proxy 自然工作。
LockDataActions<T>
interface ActionCallOptions {
/** 抢锁超时(ms / NEVER_TIMEOUT),覆盖 options.timeout。默认 5000 */
acquireTimeout?: number | typeof NEVER_TIMEOUT
/** 持有超时(ms / NEVER_TIMEOUT),覆盖 options.timeout;超时后锁自动释放并 reject recipe promise。默认 5000 */
holdTimeout?: number | typeof NEVER_TIMEOUT
/** 是否强制抢占当前持有者;原持有者收到 LockRevokedError */
force?: boolean
/**
* 本次调用专用 abort 信号(不影响 actions 整体生命周期)
* - acquiring 阶段 abort → reject LockAbortedError
* - holding 阶段 abort → 丢弃本次 working copy、释放锁、reject recipe Promise
* 与 options.signal 是"与"的关系:任一 abort 都会中止本次调用
*/
signal?: AbortSignal
}
interface LockDataActions<T extends object> {
/**
* 以 recipe 形式修改数据;draft 是可写草稿,返回值会被忽略
* 返回类型(签名统一为 `void | Promise<void>`;任一异步条件成立即为 Promise):
* - 异步条件(任一满足即返回 Promise):
* · 有 id(需抢跨进程锁)
* · `getValue` 返回 Promise 且 `entry.dataReadyPromise !== null`(异步初始化未就绪:同 Tab 二次调用方命中 / authority.init 等待 / 异步初始化期间提前注册)
* · 传入的 recipe 本身返回 Promise
* · `syncMode === 'storage-authority'` 且 acquire 需要 pull
* - 全部异步条件都不满足(无 id + 同步 recipe + 已 ready + syncMode 'none')→ 同步执行,返回 void
* holdTimeout 超时会自动释放锁,recipe 的剩余逻辑视作"已被 revoked"
*/
update(
recipe: (draft: T) => void | Promise<void>,
opts?: ActionCallOptions,
): void | Promise<void>
/** 直接替换整份数据;同样走抢锁流程,返回类型规则同 `update` */
replace(next: T, opts?: ActionCallOptions): void | Promise<void>
/**
* 主动抢锁并持有(直到调用 release / dispose 或 holdTimeout 到期)
* 用于"多次修改事务":手动抢一次锁,中间连续调用 update/replace 时复用该锁
* 返回类型规则同 `update`
*/
getLock(opts?: ActionCallOptions): void | Promise<void>
/**
* 读取一份 JSON 拷贝隔离的数据快照(与 readonly view 解耦,可随意修改而不影响锁;不抢锁、不触发 authority pull)
*
* 实现:`JSON.parse(JSON.stringify(entry.dataRef.current))`
*
* 用途:
* - wrapper Proxy 方案下 `structuredClone(view)` 会抛 `DOMException`;需要脱离 Proxy 拿快照时用此方法
* - 把 lock-data 内部数据传递给不接受 Proxy 的 API(持久化 / 序列化 / postMessage)
*
* 注意:`syncMode: 'storage-authority'` 下,`snapshot()` 可能返回本 Tab 视角的过时数据;
* 需要跨 Tab 最新值请先 `await actions.getLock()` 再 `snapshot()`(acquire 会主动 pull authority)
*/
snapshot(): T
/**
* 仅释放当前持有的锁(由 `getLock` 抢到的),不销毁 actions 实例
* - 未持有锁时为 no-op(不报错)
* - 清理 `holdTimeout` 定时器、release 锁、state 回到 `idle`
* - 不影响 InstanceRegistry 引用计数、不解绑订阅;actions 仍可继续 `update` / `replace` / `getLock`
* - `update` / `replace` 自己抢的锁在 recipe 结束时已自动 release,无需再调用 release
* 返回类型:始终同步 `void`(release 路径不涉及任何异步 I/O;底层 driver 的 release 可能返回 Promise,
* 内部以 fire-and-forget 方式处理,用户无需等待)
*/
release(): void
/**
* 销毁 actions 实例:释放当前持有的锁 + 从 InstanceRegistry 释放一次引用计数 + 解绑订阅
* 未持有时仅释放引用计数
* 返回类型:
* - 首次调用:`Promise<void>`(有锁需释放)或 `void`(无锁可释)
* - 第二次起(已 disposed):恒为同步 `void`,不再返回 Promise
* - `await actions.dispose()` 在两种情况下都安全
* dispose 后本 actions 进入 `disposed` 终态,后续任何 action 调用(包含 `release` / `getLock`)reject `LockDisposedError`
* 注意与 `release` 的区别:`release` 只还锁、actions 可继续使用;`dispose` 还锁 + 销毁实例
*/
dispose(): Promise<void> | void
/** 当前是否仍然持有锁 */
readonly isHolding: boolean
/**
* 当前 actions 实例的唯一 token
* - 在 actions 构造时一次性生成,整个实例生命周期内保持不变
* - 多次 acquire / release 复用同一 token
* - `force` 抢占时作为持有者身份标识
*/
readonly token: string
}
调用语义要点:
-
update / replace 如果当前已持锁(通过 getLock 或上一个未 release / dispose 的事务),直接在锁上执行,不重新抢锁
-
update 若传入异步 recipe,holdTimeout 会对整个 recipe 的完成时间计时
-
release 与 dispose 的职责分离:
release:只处理"还锁"语义(release 锁 + 清理 holdTimeout、state 回 idle),actions 仍可继续使用;适合长生命周期实例(如 React 组件内的持续交互)
dispose:处理"销毁实例"语义(包含 release 的所有工作 + 引用计数-1 + 订阅解绑 + 进入 disposed 终态);适合短生命周期事务
- 只要能同步走完 release 路径,
release 始终为同步 void;dispose 则按是否持锁返回 Promise<void> | void
-
getLock 的典型用法(短事务,dispose 收尾):
await actions.getLock({ holdTimeout: 10_000 })
try {
await actions.update((d) => { d.a = 1 })
await actions.update((d) => { d.b = 2 })
} finally {
await actions.dispose() // 事务完即销毁实例
}
-
getLock 的长生命周期用法(release 只还锁、actions 复用):
// 例如 React 组件内的多次交互:每次交互抢锁→改→还锁,组件卸载才 dispose
await actions.getLock({ holdTimeout: 10_000 })
try {
await actions.update((d) => { d.a = 1 })
await actions.update((d) => { d.b = 2 })
} finally {
actions.release() // 还锁,actions 仍可用
}
// ... 稍后:
await actions.update((d) => { d.c = 3 }) // 重新抢锁、commit、自动 release
// ... 组件卸载:
await actions.dispose() // 最终销毁
错误类型
所有错误经由 shared/throw-error#throwError / throwType 抛出,错误消息统一带 [@cmtlyt/lingshu-toolkit#lockData] 前缀。
基础设施约定:shared/throw-error 已扩展为支持 options.cause 参数(基于 ES2022 new Error(msg, { cause })),签名兼容既有调用点。本 RFC 的 LockDisposedError 在由 getValue Promise reject 间接触发时,通过 throwError('lockData', 'init failed', LockDisposedError, { cause: originalReason }) 统一传递原始错误;业务侧 catch (err) { err.cause } 即可读到。
签名(摘录):
throwError(fnName, message, ErrorClass?, options?: { cause?: unknown }): never
throwError(fnName, message, options?: { cause?: unknown }): never // 重载:省略 ErrorClass
throwType(fnName, message, options?: { cause?: unknown }): never
createError(fnName, message, ErrorClass?, options?: { cause?: unknown }): Error
使用示例
本地只读锁(不传 id,初始化 & 写入均同步)
const [user, actions] = lockData({ getValue: () => ({ name: 'cmt', age: 18 }) })
user.name // 'cmt'
user.name = 'x'
// ❌ TypeError: [@cmtlyt/lingshu-toolkit#lockData]: cannot mutate readonly view
actions.update((draft) => {
draft.age = 19
})
user.age // 19
异步初始化(getValue 返回 Promise)
const [config, actions] = await lockData<Config>({
getValue: () => fetch('/api/config').then((r) => r.json()),
})
config.theme // 从接口拿到的值
跨标签页互斥锁(传 id)
lockData 本身仍是同步返回(getValue 同步路径下),仅 actions 写入时抢锁:
// Tab A —— 初始化同步,无 await
const [configA, actionsA] = lockData({
id: 'app:config',
timeout: 3000, // 默认 5000 的基础上调低
getValue: () => ({ theme: 'dark' }),
})
// 真正抢锁发生在这里
await actionsA.update((draft) => { draft.theme = 'light' })
// Tab B
const [configB, actionsB] = lockData({ id: 'app:config', getValue: () => ({ theme: 'dark' }) })
try {
await actionsB.update((d) => { d.theme = 'auto' }, { acquireTimeout: 1000 })
} catch (err) {
// LockTimeoutError: 1s 内没抢到锁
}
强制抢占(force 由 action 调用传)
// Tab A 已通过 getLock / 正在 update 中持有锁
// Tab B
const [, actionsB] = lockData({ id: 'app:config', getValue: () => initial })
await actionsB.update((d) => { d.hot = true }, { force: true })
// Tab A 侧
actionsA.update(() => {})
// ❌ LockRevokedError: lock has been forcibly acquired by another holder
// actionsA.isHolding === false;listeners.onRevoked('force') 被触发
多步事务(getLock + 连续 update)
const [, actions] = lockData({ id: 'tx', timeout: 5000, getValue: () => data })
// 场景 A:短事务(一次性用完就销毁实例)
await actions.getLock({ holdTimeout: 10_000 })
try {
await actions.update((d) => { d.step1 = true })
await actions.update((d) => { d.step2 = true })
await actions.replace({ ...snapshot, committed: true })
} finally {
await actions.dispose() // 销毁实例
}
// 场景 B:长生命周期(还锁但保留实例,后续仍可复用)
await actions.getLock({ holdTimeout: 10_000 })
try {
await actions.update((d) => { d.step1 = true })
await actions.update((d) => { d.step2 = true })
} finally {
actions.release() // ✅ 只还锁,actions 仍可继续使用
}
// 稍后:
await actions.update((d) => { d.step3 = true }) // 自动重新抢锁、commit、释放
// 最终销毁:
await actions.dispose()
同进程同 id 自动共享数据(无需 syncMode)
// 同一 Tab 内,两个不同模块各自调用 lockData
// 模块 A
const [userA, actA] = lockData({ id: 'user', getValue: () => ({ name: 'cmt', age: 18 }) })
// 模块 B(同进程,同 id)
const [userB, actB] = lockData({ id: 'user', getValue: () => ({ name: 'fallback', age: 0 }) })
// 注意:模块 B 的 getValue 不会被调用(getOrCreateEntry 命中已存在 Entry,dataRef 引用直接取首次注册的那份)
// 若显式字段冲突(如 timeout / mode),logger.warn 并以首次注册为准
// A 修改 → B 立刻读到
await actA.update((d) => { d.age = 19 })
userB.age // 19(wrapper Proxy 跟随 entry.dataRef.current 重新赋值,非广播)
跨进程数据同步(syncMode: 'storage-authority',lockData 返回 Promise)
// Tab A(默认 persistence: 'session')
const [viewA, actA] = await lockData({
id: 'shared',
syncMode: 'storage-authority',
getValue: () => ({ count: 0 }),
})
await actA.update((d) => { d.count = 1 })
// commit 后 localStorage 权威副本被写入 `{"rev":1,"ts":...,"epoch":"<uuid>","snapshot":{"count":1}}`
// Tab B(与 A 同源,同 id;默认 persistence: 'session')
const [viewB] = await lockData({
id: 'shared',
syncMode: 'storage-authority',
getValue: () => ({ count: 0 }),
})
// 首次初始化:Promise 在 sessionStorage epoch 解析 + 订阅 storage 事件 + 首次 getItem + lazy parse 完成后 resolve
// B 广播 session-probe,A 回复 session-reply 携带自己的 epoch → B 继承同一 epoch
// resolve 时 viewB.count 已经同步到 1(权威副本 rev=1 大于 entry.lastAppliedRev=0,且 epoch 匹配)
// A 的后续 commit 会通过 storage 事件实时 push 给 B,viewB 原地更新
// B 被切到后台 / 进入 bfcache 期间 A 的多次 commit 会错过 storage 事件,
// 但 B 重新被 visible / pageshow 时会主动 pull 一次,自动补齐为最新值
// ⚠️ A、B 全部关闭后第二天重新打开:
// - sessionStorage.epoch 已被浏览器清空
// - 新 Tab 广播 session-probe 无响应(超时 100ms)
// - 视为"所有 Tab 关闭后重启":主动 removeItem 清空 localStorage 权威副本,生成全新 epoch
// - readonly 回到 getValue 返回的初始值 { count: 0 }
跨会话长期持久化(persistence: 'persistent')
// 适用场景:用户草稿、个人偏好、需要跨日 / 跨浏览器重启保留的协作数据
const [view, actions] = await lockData({
id: 'user-pref',
syncMode: 'storage-authority',
persistence: 'persistent', // 关闭会话级重置
getValue: () => ({ theme: 'light', draft: '' }),
})
await actions.update((d) => { d.theme = 'dark' })
// 第二天重开:localStorage 权威副本仍在(epoch 固定常量 'persistent')
// view.theme === 'dark',自动恢复到最后 commit 状态
同会话组刷新页面(epoch 继承)
// 用户正在 Tab A 内操作,此时刷新页面(F5):
// - sessionStorage.epoch 不会被清空(浏览器规范:刷新保留同 Tab 的 sessionStorage)
// - resolveEpoch 走快路径:直接继承 sessionStorage 中的 epoch,跳过 session-probe 探测
// - readonly 初始化 pull localStorage,epoch 匹配,同步到最新 snapshot
// - 用户刷新后看到的是刷新前的协作状态,无感恢复
审计 commit + 跨 Tab 同步事件(携带 rev)
const [, actions] = await lockData({
id: 'form',
syncMode: 'storage-authority',
getValue: () => ({ form: {} }),
listeners: {
onCommit: ({ source, token, rev, mutations, snapshot }) => {
console.log(`commit rev=${rev} by ${token} via ${source}`, mutations)
},
onSync: ({ source, rev, snapshot }) => {
// source: 'pull-on-acquire' | 'storage-event' | 'pageshow' | 'visibilitychange'
console.log(`synced rev=${rev} from ${source}`, snapshot)
},
},
})
AbortSignal 控制生命周期
// 1. 实例级:options.signal 负责整个 lockData 实例的卸载
const controller = new AbortController()
const [, actions] = lockData({ id: 'k', signal: controller.signal, getValue: () => data })
// 业务触发销毁(如组件卸载):一次 abort 等价于 dispose,所有在途 action 都会 reject
controller.abort()
await actions.update(() => {}) // ❌ LockDisposedError
// 2. 调用级:ActionCallOptions.signal 只影响本次调用
const callController = new AbortController()
setTimeout(() => callController.abort(), 500)
try {
await actions.update(
async (d) => { await syncToRemote(d) },
{ signal: callController.signal },
)
} catch (err) {
// err instanceof LockAbortedError: 500ms 内没完成就中止本次 recipe
// 本次 working copy 被丢弃,底层 data 保持不变;actions 整体仍然可用
}
自定义锁驱动(adapters.getLock)
import { lockData, NEVER_TIMEOUT } from '@cmtlyt/lingshu-toolkit/shared'
const [readonly, actions] = lockData({
id: 'redis:user:1',
timeout: NEVER_TIMEOUT, // 交给业务自行控制超时
getValue: () => initial,
adapters: {
getLock: async (ctx) => {
const leaseId = await myRedisLock.acquire(ctx.name, {
owner: ctx.token,
force: ctx.force,
ttlMs: ctx.holdTimeout === NEVER_TIMEOUT ? undefined : ctx.holdTimeout,
signal: ctx.signal,
})
return {
release: () => myRedisLock.release(ctx.name, leaseId),
onRevokedByDriver: (cb) => {
myRedisLock.on('evicted', (id) => id === leaseId && cb('force'))
},
}
},
},
})
更多适配器示例(adapters.logger 接入 Sentry、Electron 主进程 IPC 适配、单元测试内存适配器、listeners.onLockStateChange 状态观察、listeners.onCommit 审计)见「附录 B:完整示例集」。
永不超时(NEVER_TIMEOUT)
import { lockData, NEVER_TIMEOUT } from '@cmtlyt/lingshu-toolkit/shared'
// 全局默认永不超时
const [, actions] = lockData({ id: 'never', timeout: NEVER_TIMEOUT, getValue: () => data })
// 仅某一次 action 调用永不超时
await actions.getLock({ acquireTimeout: NEVER_TIMEOUT, holdTimeout: NEVER_TIMEOUT })
实现思路
架构分层
┌───────────────────────────────────────────────────────────┐
│ lockData(options) │ 入口 & 参数校验
├───────────────────────────────────────────────────────────┤
│ InstanceRegistry (Map<id, Entry>) │ 同 id 进程内单例池
│ · data 引用共享 │
│ · driver 共享 │
│ · listeners fanout │
│ · 引用计数 (new instance +1 / dispose -1,归零销毁) │
├───────────────────────────────────────────────────────────┤
│ createReadonlyView<T>(data) createActions<T>(...) │ 只读代理 / 受控 actions
│ · 事务式 Draft │
│ · Mutation Log │
│ · validityRef │
├───────────────────────────────────────────────────────────┤
│ LockDriver 接口 │ 锁调度抽象
│ ├─ CustomDriver (adapters.getLock 自定义) │
│ ├─ LocalLockDriver (无 id,进程内互斥) │
│ ├─ WebLocksDriver (navigator.locks) │
│ ├─ BroadcastDriver (BroadcastChannel + token) │
│ └─ StorageDriver (localStorage + storage 事件) │
└───────────────────────────────────────────────────────────┘
InstanceRegistry(同 id 进程内单例)
Entry 结构关键字段:
注册 / 释放流程要点:
getOrCreateEntry(id, options, factory):
- 命中已存在 Entry →
refCount++ + 加入 listenersSet + 非 listeners 字段冲突检查
- 首次创建 → 调用
pickDriver / pickDefaultAdapters 组装;按 options.getValue 返回同步值 / Promise 设置 dataReadyPromise(同步路径下设为 null,异步路径下设为合成 Promise)
releaseEntry(id, listeners):refCount-- + 从 listenersSet 移除 listeners;归零时 driver.destroy() + 解绑所有订阅 + registry.delete(id)
行为规则:
dataRef 引用以首次注册为准:后续 lockData({ id, getValue, ... }) 中的 getValue 直接忽略(不调用,不做浅合并);需要改数据请走 actions.update
- 异步路径边界:首次注册时
entry.dataRef.current 设为占位对象 {}(不暴露给调用方 —— lockData() 在 dataReadyPromise resolve 之后才把 [view, actions] 元组交付给调用方,PLACEHOLDER 永不暴露);getValue Promise resolve 后 dataRef.current = JSON.parse(JSON.stringify(awaited)) 重新赋值;详见「readonly view 的引用稳定契约」章节
options 冲突策略:非 listeners 字段若与 initOptions 不一致,logger.warn('[lockData] option conflict on id=<id>, using first registered value')
- listeners 不冲突:每个实例独立保留一份 listeners,driver 触发事件时向全部 listeners fanout
- listener 异常隔离:fanout 时单个 listener 抛错由内部
try/catch 拦截并走 logger.error('[lockData] listener threw', err),继续 fanout 给其他 listener,不打断主流程 / 不影响 actions 状态机;listener 异步抛错(Promise reject)同样吞掉 + 记录
- 引用计数回收:每次
lockData(...) 产出新实例时 refCount +1;actions.dispose() 或 options.signal.aborted 时 -1;归零时销毁 Entry、释放 driver、清理数据通道
dataReadyPromise 共享:
getValue 返回 Promise 时由首次注册的 Entry 统一持有;后续同 id 实例直接 await entry.dataReadyPromise,不重复调用 getValue
- 并发初始化下所有实例看到的
dataRef 引用一致,避免各自触发独立请求
- 已就绪(
dataReadyPromise === null)后新实例走同步分支,不再创建新 Promise
- 异步初始化失败时
dataReadyPromise reject,所有持有此 Entry 的同 Tab 调用方在 action 时通过 ensureDataReady 抛 LockDisposedError(cause 字段携带原始原因),并触发 refCount -1
- 无 id:不进入 InstanceRegistry,每次
lockData 完全独立;dataReadyPromise 仅存在于该 Entry 的内部生命周期内
Entry 对外可见 ↔ 数据已就绪(fail-fast 语义):
异步初始化未就绪场景(同 Tab 二次调用方命中 / authority.init 等待 / 异步初始化期间提前注册)通过 dataReadyPromise 等待 resolve;同步路径下 dataReadyPromise === null,Entry 构造瞬间即已就绪。
- 同步
getValue() 抛错 → lockData() 调用栈直接抛 LockDisposedError(Entry 不构造,不进 registry)
- 异步
getValue() reject → dataReadyPromise.reject + entry.refCount = 0 + registry.delete(id) 立即触发 teardowns(Entry 仍曾短暂注册,但所有持有方在 await 元组时一致拿到 reject)
进入 reject 后,同 Tab 所有持有此 Entry 的调用方在 action 时通过 ensureDataReady 抛 LockDisposedError(cause 字段携带 getValue 原始 reject 原因)。
能力检测与降级
pickDriver(adapters, options, id):
if (adapters.getLock) -> CustomDriver(adapters.getLock) // 最高优先级
if (!id) -> LocalLockDriver
if (options.mode === 'auto'):
if (navigator.locks) -> WebLocksDriver
else if (BroadcastChannel) -> BroadcastDriver
else -> StorageDriver
else -> 强制使用指定 driver
pickDefaultAdapters(userAdapters, ctx):
// 按字段逐个解析:用户提供 > 默认实现探测成功 > null(由调用方降级)
return {
authority: userAdapters.getAuthority?.(ctx) ?? tryDefaultLocalStorageAuthority(ctx),
channel: userAdapters.getChannel?.(ctx) ?? tryDefaultBroadcastChannel(ctx),
sessionStore: userAdapters.getSessionStore?.(ctx) ?? tryDefaultSessionStore(ctx),
logger: userAdapters.logger ?? defaultLogger,
// 注意:本期不再提供 clone 适配器,所有快照派生使用 JSON.parse(JSON.stringify(...)) 固化语义
}
// tryDefaultXxx 函数内部检查对应全局 API 可用性;不可用返回 null
pickDriver 与 pickDefaultAdapters 仅在 getOrCreateEntry 首次创建 Entry 时调用;同 id 后续实例直接复用 Entry 中的 driver 与 adapters。降级策略仅影响锁调度 / 跨进程同步层,readonly / actions 的行为对使用者完全透明。
降级触发矩阵:
CustomDriver
- 当
adapters.getLock 存在时,一切能力检测被跳过,driver 仅作为适配层把 LockAcquireContext 传给用户回调
- 内部仍负责:
- 拼接
name(lingshu:lock-data:<id>)并透传
- 准备
AbortSignal(在 dispose / revoked / acquireTimeout 时触发 abort)
- 把返回的
LockHandle.release 接入状态机的 release 链路
- 把
onRevokedByDriver 回调桥接到 listeners.onRevoked('force' | 'timeout')
syncMode: 'storage-authority' 在 CustomDriver 下仍由 StorageAuthority 独立管理权威副本(经 adapters.authority 读写),与锁调度完全解耦(CustomDriver 只管谁持锁,不参与数据同步)
LocalLockDriver
- 进程内维护 FIFO 等待队列;
acquire 产生的 LockHandle 在 release 调用时把下一个 waiter 从队首取出并 resolve
force: true 立即抢占:当前持有者的 onRevokedByDriver 以 'force' 回调,新请求跳过队列直接持锁
acquireTimeout 用本地 setTimeout 计时;signal.aborted 或 timeout 触发时把对应 waiter 从队列中移除并 reject
destroy:把所有等待者 reject 为 LockAbortedError,并清空队列;当前持有者 onRevokedByDriver('force') 并让 release 变成幂等 no-op
- 无 id 场景下同一 driver 实例内仍维护互斥语义(由 InstanceRegistry 按 id 唯一化 driver 保证"同 id 共享同一 driver")
WebLocksDriver(首选)
核心 API:navigator.locks.request(name, { mode: 'exclusive', steal, signal }, callback),name = lingshu:lock-data:<id>。
force → steal: true;原持有者回调被 reject AbortError,捕获后触发 onRevoked('force') + isHolding = false
timeout → AbortController.abort(),request reject
dispose → resolve 内部 holdPromise,锁自动释放,队列中下一个 waiter 激活
BroadcastDriver(降级)
当 navigator.locks 不可用时,用 BroadcastChannel('lingshu:lock-data:<id>') 模拟互斥锁:
- 持有者维护随机
token + 200ms alive 心跳
- 新 waiter 广播
acquire-request 并附带 requestId(时间戳 + 随机数);队列按 requestId 排序,所有成员本地维护 mirror
- 心跳连续丢失 N 次视为持有者崩溃,队列 FIFO 晋升
force:广播 force-acquire,持有者立即 onRevoked('force') + 自毁
StorageDriver(兜底降级)
BroadcastChannel 也不可用时用 localStorage + storage 事件模拟:
- key
lingshu:lock-data:<id>,value { token, heartbeat, queue }
- 心跳基于
setInterval;其他语义与 BroadcastDriver 一致
- 已知局限:同 Tab 内多实例不会触发
storage 事件,需要自行补发
只读代理实现要点
- 缓存:用
WeakMap<object, Proxy> 保证同一对象多次访问拿到同一个代理(避免身份比较失效)
- 惰性:只在
get 访问到对象时才递归包裹,避免初始化时深度遍历
- 写拦截:
set / deleteProperty / defineProperty 统一调用 throwType('lockData', 'cannot mutate readonly view')
actions.update commit 后会原地变更底层 data,因此 readonly 对同一引用的读取始终看到最新值
- 同 id 共享:所有共享同一 Entry 的实例,各自构建独立的
readonly 代理,但底层指向同一个 data;任一实例 commit 完成后,其他实例 readonly 立即可读
事务式 Draft(抢锁污染防御)
动机:force 抢占 / holdTimeout / signal abort 时,如果 recipe 已经对底层数据做了部分写入,新 holder 拿到的是"半成品"。通过"working copy + mutation log + 原子 commit"保证要么全部生效,要么全部丢弃。
数据结构
interface MutationLog {
// 路径为 (string | symbol)[];value 为 set 的新值;op 'delete' 时 value 为 undefined
entries: Array<{ path: PropertyKey[]; op: 'set' | 'delete'; value?: unknown }>
}
interface DraftContext<T extends object> {
/** 指向 Entry.data 的引用(commit 时原地改写) */
readonly target: T
/** 本次 recipe 的有效性开关 */
readonly validity: { isValid: boolean }
/** 本次变更的最小路径集 */
readonly log: MutationLog
}
Draft Proxy 行为
createDraft(target, ctx, parentPath = []):
return new Proxy(target, {
get(obj, key) {
const v = Reflect.get(obj, key)
if (isObject(v)) {
// 惰性递归,子 draft 共享同一个 ctx(共享 validity / log)
return createDraft(v, ctx, [...parentPath, key])
}
return v
},
set(obj, key, value) {
if (!ctx.validity.isValid) {
throwError('lockData', 'draft is no longer valid (lock revoked / aborted)', LockRevokedError)
}
ctx.log.entries.push({ path: [...parentPath, key], op: 'set', value })
return Reflect.set(obj, key, value) // 同时写到 target(见下方"选项权衡")
},
deleteProperty(obj, key) {
if (!ctx.validity.isValid) {
throwError('lockData', 'draft is no longer valid (lock revoked / aborted)', LockRevokedError)
}
ctx.log.entries.push({ path: [...parentPath, key], op: 'delete' })
return Reflect.deleteProperty(obj, key)
},
})
注意:上述 set/delete 同时改动 target(原 data)——这样做仅当 recipe 成功 commit 时等价于直接写入;一旦 revoke / abort,需要按 mutation log 回滚,见下。
提交流程(commit)
async runUpdateRecipe(recipe, callOpts):
ensureHolding(callOpts) // 抢锁 / 复用锁
const snapshot = snapshotFor(log) // 浅快照:log 中 set 路径的"旧值"预先记录,供回滚用
const ctx = { target: entry.dataRef.current, validity: { isValid: true }, log: createEmptyLog() }
const draft = createDraft(entry.dataRef.current, ctx)
try {
await recipe(draft) // 执行 recipe
if (!ctx.validity.isValid) {
// recipe 执行过程中被 revoke:回滚已写入的变更
rollback(entry.dataRef.current, snapshot)
throwError('lockData', 'revoked during recipe', LockRevokedError)
}
// commit 阶段:此时所有写入已落到 entry.dataRef.current(draft Proxy 的 set/delete 同时改 target)
entry.rev++ // 权威单调序号递增
entry.lastAppliedRev = entry.rev // 本 Tab 发起的 commit 不会再被自己的 storage 事件误判
const commitEvent = {
source, // 'update' | 'replace'
token,
rev: entry.rev, // 当前权威序号
mutations: freezeLog(ctx.log), // 深冻结,防止外部 mutate
snapshot: JSON.parse(JSON.stringify(entry.dataRef.current)), // JSON 拷贝隔离
}
listenersFanout.onCommit(commitEvent) // 审计 / 派生状态钩子
if (entry.authority): // syncMode === 'storage-authority' 时写入 localStorage 权威副本
entry.authority.write(entry.rev, Date.now(), commitEvent.snapshot)
} catch (err) {
// recipe 抛错 / revoked / aborted → 回滚到 snapshot;不触发 onCommit
rollback(entry.dataRef.current, snapshot)
throw err
} finally {
ctx.validity.isValid = false // 让遗留 draft 引用后续写入立即抛错
if (acquiredBySelf) await driver.release()
}
关键实现要点
- snapshot 按 mutation log 的路径做最小深拷贝:进入 set 拦截器时先记录"该路径的旧值"到 snapshot;rollback 时按路径逆序写回。这样避免整树克隆大对象
- validity 置否的时机:
driver.onRevokedByDriver('force' | 'timeout') 触发
holdTimeout 定时器触发
options.signal.aborted 或 callOpts.signal.aborted 触发
- recipe 正常结束(进入 finally)
- validity 置否后的 draft 写入:依旧抛
LockRevokedError,即便此时 recipe 已结束——防止 recipe 闭包捕获的 draft 引用被外部再次使用
replace(next, opts?) 的等价语义:在一个隐式的 update 事务里执行 Object.keys(draft).forEach(k => delete draft[k]) + Object.assign(draft, next);享有同样的 working copy / 回滚保护
- 同步 recipe 也走事务:语义一致、测试路径统一;同步 recipe 的额外开销仅限于 Proxy 访问与 mutation log 的数组 push
外部化前瞻(可选迁移路径)
此小节仅为架构备忘,不影响本 RFC 的接收;实施阶段先在 core/draft.ts 内部 self-contained,不预先外部化
背景:事务式 Draft(Proxy + mutation log + validity + 原地 rollback)本质是一套与 lock-data 解耦的通用机制——任意"先对对象做一串写入、某些条件下要整体回滚"的场景都可复用(表单草稿、乐观更新、编辑器临时操作、配置热更新、批量处理中途回滚等)。与 immer 的差异化定位:immer 产出新对象(persistent data structure),事务式 Draft 原地修改 + 失败回滚,保留引用稳定性(对"同 id 多实例共享 data 引用" / "React 外部 store"等场景是硬需求)。
当前决策(方向 A):
core/draft.ts 实现 self-contained,不预先抽离为 shared/transactional-draft
- 源文件顶部加一段迁移注释(指向本 RFC 章节),降低未来迁移的沟通成本
- 等真正出现第二个使用者(即在本仓或其他仓内再次需要同样机制)时再抽离,避免过早抽象导致 API 设计失真
抽离时机的判定条件(任一满足才启动抽离):
- 仓内新增至少 1 个 具体工具需要同样的"原地修改 + 失败回滚"机制(编辑器、动画回放、表单草稿等)
- 外部仓反馈有复用需求,且其场景与
lock-data 当前用法的交集 ≥ 70%
- 通用化 API 能清晰区别于
immer 的"产出新对象"语义,且体积仍保持在 ~1KB 量级
预留的通用化 API 骨架(仅作设计备忘,抽离时可据此演进):
// 预期位置:src/shared/transactional-draft/index.ts(未来抽离时)
interface Mutation {
path: PropertyKey[]
op: 'set' | 'delete'
value?: unknown
prevValue?: unknown // 通用版需暴露 prevValue(lock-data 内部实现中可选),支持审计 / 反向补丁
}
interface Transaction<T extends object> {
readonly draft: T // 可写代理
readonly mutations: ReadonlyArray<Mutation> // 只读 log
readonly isValid: boolean
commit(): { mutations: ReadonlyArray<Mutation> } // 冻结 log 并返回补丁
rollback(): void // 按 log 逆序恢复 target
dispose(): void // validity 置否,防止 draft 泄露后写入
}
function createTransaction<T extends object>(
target: T,
options?: {
onInvalidWrite?: (path: PropertyKey[]) => void // 默认抛错;lock-data 内部适配为 LockRevokedError
},
): Transaction<T>
相对于 lock-data 内部实现需要补齐的通用化能力:
- 暴露
prevValue 字段(内部实现中仅保留在 snapshot 里,通用工具需暴露给用户做审计 / 反向补丁)
- Map / Set 支持(内部实现只覆盖普通对象和数组)
- 手动
commit() / rollback() 显式控制(内部实现绑定在 recipe 生命周期上)
- 可选:savepoint / nested transaction(子事务独立回滚)
迁移成本评估:core/draft.ts 在 self-contained 状态下 API 表面小、核心约 80 行;未来抽离时只需在 shared/transactional-draft 产出通用实现,lock-data 内部用薄适配层(绑定 LockRevokedError + recipe 生命周期)即可复用——不构成预先外部化的理由,但为未来演进留足空间。
Actions 实现要点
- 内部状态机:
idle → acquiring → holding → committing → released / revoked / disposed
- 写入方法流程:
update / replace / getLock 入口统一走 ensureHolding(opts):
- 若
state === 'holding' 且锁未被 revoke → 直接复用当前锁
- 否则构造合并 AbortSignal(
options.signal + callOpts.signal + 内部 acquireTimeout controller),调用 driver.acquire({ acquireTimeout, holdTimeout, force, signal }) 拿锁
- 拿到锁后启动
holdTimeout 定时器(holdTimeout === NEVER_TIMEOUT 时跳过),到期后主动 release、置 validity.isValid = false、state 置 revoked
- 每一步状态流转都通过
listenersFanout.onLockStateChange(event) 分发到所有实例的 listeners
update(recipe):见"事务式 Draft"章节
- recipe 执行完:若该次是
update 自己抢的锁则立即 release;若是 getLock 抢的则保留
- recipe 结束一律将当次
ctx.validity.isValid = false,防止闭包泄露
snapshot():不抢锁,直接 JSON.parse(JSON.stringify(entry.dataRef.current)) 产出 JSON 拷贝隔离副本(详见「actions.snapshot()」章节)
getLock:只抢锁、启动 holdTimeout,不执行 recipe;释放后的"空持锁期"没有 draft,所以不涉及 validity
release:
- 仅处理"还锁":清理
holdTimeout 定时器、调用底层 lockHandle.release()、isHolding = false、state 回到 idle
- 不碰 InstanceRegistry 引用计数、不解绑 driver 侧监听、不解绑 authority 订阅
- 底层 driver 的 release 若返回 Promise(如 WebLocksDriver),内部 fire-and-forget(错误兜底走
logger.warn),release() 对外始终同步 void
- 未持锁(
state !== 'holding')时为 no-op
disposed 终态下调用 reject LockDisposedError
- 幂等:连续调用 release 只有第一次生效,后续直接 no-op
dispose:
- 先执行
release 的全部工作(清理 holdTimeout、release 锁)
- 清理 driver 侧监听、解绑 authority 订阅
- 从 InstanceRegistry 释放一次引用计数;归零时销毁 Entry(
driver.destroy() + 解绑订阅 + registry.delete(id))
- 调用后本 actions 进入
disposed 终态(幂等):重复 dispose() 仅第一次生效;后续任何 update / replace / getLock / release 调用 reject LockDisposedError
options.signal.aborted 等价于自动触发一次 dispose(),语义与手动调用一致
- NEVER_TIMEOUT 处理:
acquireTimeout === NEVER_TIMEOUT 时不注册抢锁超时 AbortSignal;holdTimeout === NEVER_TIMEOUT 时不注册 hold 定时器
- AbortSignal 组合:内部用
AbortSignal.any(或等价 polyfill)把 options.signal / callOpts.signal / 超时 / dispose 四路信号合成一个派生 signal,传递给 driver
- getValue 异步期间的抢锁:
getValue 返回 Promise 时,由 Entry.dataReadyPromise 统一持有;同 id 多个实例共享同一个 Promise,不会重复触发 getValue
- 此期间如调用
update / replace / getLock,action 将等待 entry.dataReadyPromise resolve 后再进入抢锁流程(而不是直接 reject)
acquireTimeout 计时从"进入抢锁流程"时开始计算,等待 dataReadyPromise 的时间不计入抢锁超时
- 异步初始化未就绪场景下首次调用的初始化失败时,
dataReadyPromise reject;同 Tab 所有持有此 Entry 的调用方在 action 时通过 ensureDataReady 抛 LockDisposedError(视为初始化失败,所有共享该 Entry 的实例一并不可用;错误 cause 字段携带 getValue 原始 reject 原因,便于业务区分"初始化失败"与"主动 dispose")
- 无 id 场景下
dataReadyPromise 由 actions 内部持有,语义与上述一致
syncMode 实现要点
'none'(默认):driver 只传输锁信号,不传数据;但同进程同 id 仍通过 InstanceRegistry 共享 data 引用,跨进程才真正"不同步"
'storage-authority':见下方「StorageAuthority」章节,由 Entry.authority 负责全部跨 Tab 数据同步职责
依赖倒置与适配器
动机:lockData 涉及多个环境 API(localStorage / sessionStorage / BroadcastChannel / structuredClone / logger / navigator.locks)。为支持非浏览器环境(Node / SSR / RN / Electron / Worker)、生产日志集成、测试内存替身等场景,把所有"对外部环境的依赖"收敛到 options.adapters 单一入口,内部提供默认实现,用户按需覆盖。
设计原则
- 单一入口:所有可外部化的依赖统一聚合到
options.adapters(而非平铺到顶层 options),减少 LockDataOptions 表面积
- 工厂函数风格:涉及 id 作用域的依赖(锁 / 权威副本 / 通道 / 会话存储)通过
getXxx(ctx) => Adapter 形式注入,与现有 getLock 对齐;无 id 作用域的依赖(logger)直接传实例
- 默认实现兜底:
adapters 各字段均可缺省;内部在 Entry 初始化时调用 pickDefaultAdapters(ctx) 组合默认实现(浏览器原生 API),用户提供的字段优先级最高
- 能力等价契约:所有默认实现与用户注入的 adapter 必须满足同一接口契约(同步 / 异步语义、订阅 / 取消语义、幂等性)。内部不对 adapter 做能力探测,语义正确性由提供方负责
- 跨 Tab 对齐:同一 id 的所有 Tab 必须使用语义等价的 adapter(否则权威副本互不可见);
StorageAuthority 的存储格式(rev → ts → epoch → snapshot 的字段顺序契约)由内部 codec 固化,不开放给 adapter 层
接口定义
/** 权威副本存储适配器:StorageAuthority 通过此对象读写权威副本 + 订阅外部更新 */
interface AuthorityAdapter {
/**
* 读取当前权威副本的原始字符串(未解析)
* 返回 null 表示 key 不存在(首个 Tab / 已被清空)
*/
read(): string | null
/**
* 写入权威副本;raw 为内部 codec 序列化后的字符串,适配器不应修改其内容
* 超配额 / 写入失败时建议 throw,内部会捕获并走 logger.warn 降级
*/
write(raw: string): void
/** 删除权威副本(session 策略首个 Tab 清理上一会话组残留用) */
remove(): void
/**
* 订阅外部(其他进程 / Tab / 设备)对权威副本的写入 / 删除
* - 写入事件:newValue 为新的 raw 字符串
* - 删除事件:newValue 为 null
* - 自进程写入是否触发回调由适配器决定;内部通过 lastAppliedRev 做幂等去重
* @returns 取消订阅函数
*/
subscribe(onExternalUpdate: (newValue: string | null) => void): () => void
}
/** 广播通道适配器:session-probe / session-reply 协议通过此对象收发消息 */
interface ChannelAdapter {
/** 向所有订阅者广播消息;消息内容为 JSON 可序列化对象 */
postMessage(message: unknown): void
/**
* 订阅通道消息
* - 自进程 postMessage 是否回调由适配器决定;内部通过 probeId 去重
* @returns 取消订阅函数
*/
subscribe(onMessage: (message: unknown) => void): () => void
/** 关闭通道(Entry refCount 归零时调用) */
close(): void
}
/** 会话级存储适配器:仅供 session 策略的 epoch 存储用;接口形态收敛为纯同步读写 */
interface SessionStoreAdapter {
/** 读取当前会话纪元;返回 null 表示 Tab 首次启动 */
read(): string | null
/** 写入会话纪元 */
write(value: string): void
}
/** 日志适配器;默认实现委托到 shared/logger */
interface LoggerAdapter {
warn(message: string, ...extras: unknown[]): void
error(message: string, ...extras: unknown[]): void
debug?(message: string, ...extras: unknown[]): void
}
默认实现
src/shared/lock-data/adapters/ 提供以下默认实现;pickDefaultAdapters(ctx) 在每个字段缺省时按需组合:
组合时机:getOrCreateEntry 首次创建 Entry 时调用 pickDefaultAdapters(options.adapters, { id, ... }),产出最终的 ResolvedAdapters 对象挂到 entry.adapters;后续所有内部模块通过 entry.adapters 访问,彻底解耦对全局 API 的直接依赖。
优先级:用户提供 > 默认实现探测成功 > 返回 null(对应字段走"降级"分支)。
DefaultLocalStorageAuthority / DefaultBroadcastChannel / DefaultSessionStore 等为内部实现的默认适配器命名占位,仅用于文档交叉引用,不对外导出;用户覆盖时通过 adapters.getAuthority / getChannel / getSessionStore 注入自己的实现即可,无需 import 这些内部命名。
logger 混合兜底契约
动机:LoggerAdapter 对外只要求 warn / error 必选、debug 可选,用户注入自定义 logger 时很容易只实现一部分方法。若下游模块在调用 logger.debug(...) 前都做 typeof === 'function' 判空,会导致:
- 每个调用点都要散落可选链 / 判空逻辑,代码噪音大
- 一旦漏判就会在生产抛
TypeError: logger.debug is not a function
- 调试开关语义分裂(有的环境 debug 静默、有的环境 debug 报错)
契约:adapters/logger.ts 导出 resolveLoggerAdapter(userLogger?),在 pickDefaultAdapters 阶段做字段级混合兜底,产出 ResolvedLoggerAdapter(三方法全部必选),所有下游模块仅与 ResolvedLoggerAdapter 交互:
/** 内部流转契约:三方法全部必选,调用点无需判空 */
interface ResolvedLoggerAdapter {
warn(message: string, ...extras: unknown[]): void
error(message: string, ...extras: unknown[]): void
debug(message: string, ...extras: unknown[]): void
}
function resolveLoggerAdapter(userLogger?: LoggerAdapter): ResolvedLoggerAdapter
解析规则(按字段独立判定,互不影响):
关键性质:
- 字段级合并,非对象级替换:用户只实现
warn/error 时,debug 自动走默认 logger,不会整体退回默认
- 防御性兜底:用户字段值不是 function(如误传字符串 / null)也会走默认,不抛错
- this 绑定:用户方法解析时会
.bind(userLogger),保证用户 logger 内部 this 引用正确
- 一次解析,全程复用:
pickDefaultAdapters 产出后 entry.adapters.logger 始终是 ResolvedLoggerAdapter,内部任何模块调用 logger.debug(...) 均安全无需判空
调用者约束:
- 内部模块(
clone.ts / authority.ts / channel.ts / session-store.ts / core 层)接受的 logger 参数类型一律声明为 ResolvedLoggerAdapter,不接受原始 LoggerAdapter
- 外部入口(
options.adapters.logger)仍保留 LoggerAdapter 宽松类型,debug 仍可选,保持对用户的 API 兼容性
StorageAuthority(localStorage 权威副本)
动机:commit 广播若走 BroadcastChannel 会与锁释放走不同通道,非 holder 在 driver.acquire 成功瞬间可能尚未收到最新 snapshot;而后台 Tab 进入 bfcache / freeze 时还会直接丢失广播消息。将"权威副本"抽象为 AuthorityAdapter(默认实现为 localStorage),把"推送"与"拉取"两条路径收敛到同一份持久化数据上,实现:
- 拿到锁 = 拿到最新值:
driver.acquire 后同步 authority.read() + lazy parse,读路径亚毫秒完成
- 后台唤醒自愈:
pageshow / visibilitychange 主动 pull,覆盖 bfcache / freeze 期间错过的变更
- 跨 Tab 顺序一致:所有 commit 写入都经过锁串行化,
rev 单调递增,任意 Tab 读到的必然是当前权威值
- 推送通道内建:
authority.subscribe(...) 封装 storage 事件(默认实现)/ IPC 广播(用户实现),完全不需要再用 BroadcastChannel 做数据广播
- 环境无关:非浏览器环境(Electron / RN / Node 多进程)通过
adapters.getAuthority 注入自定义实现,完整保留跨进程同步语义
职责边界
LockDriver StorageAuthority
───────── ────────────────
谁持锁 / 谁排队 / 谁释放 权威 snapshot 读写 + 跨 Tab 推送
与数据完全无关 与锁调度完全无关
两者共用同一存储层的不同 key(默认实现下都落在 localStorage,adapter 注入时由用户自行决定底层),互不干扰:
存储格式(固化契约)
// localStorage value 必须为 JSON 字符串,字段顺序固化为:rev → ts → epoch → snapshot
// rev 必须在首位,便于 lazy parse 时的快路径提取
type AuthorityRaw = string // `{"rev":42,"ts":1714198800123,"epoch":"ab12...","snapshot":{...}}`
function serialize(rev: number, ts: number, epoch: string, snapshot: unknown): string {
return `{"rev":${rev},"ts":${ts},"epoch":${JSON.stringify(epoch)},"snapshot":${JSON.stringify(snapshot)}}`
}
固化理由:
JSON.stringify({ rev, ts, snapshot }) 在 JS 规范上不保证字段顺序(V8/SpiderMonkey 实测按插入顺序,但不作为契约),手动拼接避免任何引擎差异
rev 固定首位,extractRev 用锚定开头的正则即可安全提取,不会被 snapshot 内容干扰
Lazy Parse 快路径
function extractRev(raw: string): number | null {
// 匹配 `{"rev":<整数>` 开头;失败(旧格式 / 手动写入)返回 null 走全量 parse 兜底
// 正则锚定开头 + 有界整数匹配,匹配成本恒为 O(首部长度),与 value 总长无关;
// 即便 localStorage value 达到 MB 级(浏览器通常配额 5~10MB),快路径开销仍稳定在亚微秒
const match = /^\{"rev":(-?\d+)/.exec(raw)
return match ? Number(match[1]) : null
}
function extractEpoch(raw: string): string | null {
// 匹配 `...,"epoch":"<string>"`;用于快路径 epoch 过滤(避免因上一会话组残留数据解析大 snapshot)
const match = /,"epoch":"([^"\\]*)"/.exec(raw)
return match ? match[1] : null
}
function readIfNewer(entry: Entry, raw: string | null): Snapshot | null {
if (!raw) return null
const remoteRev = extractRev(raw)
if (remoteRev === null) {
// 格式不识别,走全量 parse 兜底
const parsed = JSON.parse(raw)
if (parsed.rev <= entry.lastAppliedRev) return null
if (entry.epoch !== null && parsed.epoch !== entry.epoch) return null
return { rev: parsed.rev, snapshot: parsed.snapshot }
}
if (remoteRev <= entry.lastAppliedRev) return null // 快路径:无需解析 snapshot
// epoch 快路径过滤:session 策略下若 epoch 不一致(上一会话组残留),直接丢弃
if (entry.epoch !== null) {
const remoteEpoch = extractEpoch(raw)
if (remoteEpoch !== null && remoteEpoch !== entry.epoch) return null
}
const { snapshot } = JSON.parse(raw) // 仅在真的要应用时才解析
return { rev: remoteRev, snapshot }
}
收益:绝大多数 storage 事件(尤其同 Tab 高频 commit 时的频繁触发)命中"rev 未变"快路径,避免反复 JSON.parse 大 snapshot。persistence: 'session' 下 epoch 不匹配时同样走快路径直接丢弃,不会误应用上一会话组的数据。
写路径(commit 后)
onCommitSuccess(snapshot):
entry.rev++
entry.lastAppliedRev = entry.rev
if (entry.authority):
const raw = serialize(entry.rev, Date.now(), entry.epoch ?? 'persistent', snapshot)
entry.adapters.authority.write(raw)
// 默认实现:localStorage.setItem(key, raw),同源其他 Tab 自动收到 storage 事件
// 自定义实现:由 adapter 自行决定广播方式(IPC / postMessage / Redis pub/sub 等)
listenersFanout.onCommit({ source, token, rev: entry.rev, mutations, snapshot })
读路径(三个触发源共享同一应用流程)
applyAuthorityIfNewer(source, raw):
if (!raw) return // 删除 key 极少见,忽略
const result = readIfNewer(entry, raw)
if (!result) return // rev 未变 / 过时 / epoch 不匹配 → 直接丢弃,不解析 snapshot
entry.applyRemote(result.snapshot) // 内部执行 dataRef.current = JSON.parse(JSON.stringify(result.snapshot))
entry.rev = result.rev
entry.lastAppliedRev = result.rev
listenersFanout.onSync({ source, rev: result.rev, snapshot: result.snapshot })
pageshow / visibilitychange 仅在 typeof window !== 'undefined' 时注册;非浏览器环境如有等价的"应用从后台唤醒"语义,可由 AuthorityAdapter.subscribe 在回调时机自行触发,无需依赖这两个事件。
首次初始化(lockData Promise resolve 前)
initAuthority():
entry.authority.unsubscribe = entry.adapters.authority.subscribe(onAuthorityExternalUpdate)
订阅 pageshow / visibilitychange(仅浏览器环境)
entry.epoch = await resolveEpoch() // 见下方「会话级持久化与 epoch 探测」
const raw = entry.adapters.authority.read()
const result = readIfNewer(entry, raw) // entry.lastAppliedRev === 0 所以 rev 命中即应用;epoch 不匹配快路径丢弃
if (result):
entry.applyRemote(result.snapshot) // 内部执行 dataRef.current = JSON.parse(JSON.stringify(result.snapshot))
entry.rev = result.rev
entry.lastAppliedRev = result.rev
// 未命中(authority 无该 key / epoch 不匹配)说明是跨进程首次启动或上一会话组已失效,以 getValue 返回的初始值为准
副作用优点:localStorage 天然持久化;persistence: 'persistent' 下用户刷新页面 / 新开 Tab 会自动恢复最后 commit 的状态 —— 相当于免费的持久化兜底;persistence: 'session' 下则由 epoch 过滤保证"所有 Tab 关闭即重置"。
会话级持久化与 epoch 探测
目的:解决 "localStorage 天然持久化 vs 用户期望的会话级协作" 语义冲突 —— 用户希望只在多 Tab 活跃期间共享数据,所有 Tab 关闭后下次打开应当从 initial / getValue 重新开始。
策略总览
数据通道
resolveEpoch() 协议
启动分支与对应行为:
resolveEpoch():
if (persistence === 'persistent') return 'persistent' // A
if (!adapters.sessionStore) { logger.warn(...); return 'persistent' } // B
const stored = adapters.sessionStore.read()
if (stored) return stored // C:刷新 / bfcache 继承
// 首次启动分支
if (!adapters.channel): // D
logger.warn('[lockData] channel unavailable, skip session-probe')
return freshEpoch({ clearAuthority: true })
// 广播 session-probe,等待 session-reply(窗口 = sessionProbeTimeout,默认 100ms)
广播 { type: 'session-probe', probeId } 经 adapters.channel.postMessage
const resolved = await withTimeout(sessionProbeTimeout, 等待 'session-reply')
if (resolved) {
adapters.sessionStore.write(resolved.epoch)
return resolved.epoch // E:加入现有会话组
}
return freshEpoch({ clearAuthority: true }) // F:首个 Tab(主动清空残留)
freshEpoch({ clearAuthority }):
const fresh = generateUuid()
adapters.sessionStore.write(fresh)
if (clearAuthority) adapters.authority?.remove() // 主动清空,避免 epoch 不一致的残留
return fresh
响应方(所有 storage-authority + session 的 Tab 在 channel 可用时常驻监听):
on-message(msg):
if (msg.type === 'session-probe'):
const myEpoch = adapters.sessionStore?.read()
if (myEpoch):
adapters.channel.postMessage({ type: 'session-reply', probeId: msg.probeId, epoch: myEpoch })
关键实现要点
- epoch 使用
crypto.randomUUID(),fallback 到 Math.random().toString(36) + Date.now();长度可控,内部生成不受用户输入影响
- 探测窗口默认
sessionProbeTimeout: 100ms:同源 BroadcastChannel RTT 通常 <1ms,100ms 足够容纳首帧 JS 执行延迟;用户可通过 options 覆盖
- 首个 Tab 的主动清空是
'session' 语义的关键:仅靠 epoch 比较只能让 readIfNewer 丢弃旧值,但新 commit 仍会覆盖权威副本,体积不会回收;主动 authority.remove() 兼顾语义与空间
sessionStore 不可用(默认实现探测 sessionStorage 不可用 / 用户未提供 adapter):persistence: 'session' 降级为 persistence: 'persistent' + logger.warn
channel 不可用(默认实现探测 BroadcastChannel 不可用 / 用户未提供 adapter):session 策略下跳过探测,直接按"首个 Tab"语义处理 + logger.warn;权威副本仍是单一真源,失去"同会话组新开 Tab 继承"能力但不影响同步正确性
- session 常驻订阅:
session-probe 的订阅在 StorageAuthority 生命周期内常驻(即使已 resolveEpoch 完成),确保本 Tab 能响应后续新 Tab 的探测
refCount === 0 时:解绑 session 订阅,调用 entry.adapters.channel.close();权威副本 / 会话存储的 key 不主动清理(保留给同会话组其他 Tab 或下次刷新继承)
关键实现要点
lastAppliedRev 专门用于去重,与 rev 分离:
- commit 后
rev 自增同时 lastAppliedRev = rev(本 Tab 发起的 commit 不会再被自己的 storage 事件误判为"新值")
- 注意:写入方 Tab 不会收到自己的
storage 事件(规范行为),但保留 lastAppliedRev 以防极端场景下的重入
- 写入 snapshot 使用
JSON.parse(JSON.stringify(entry.dataRef.current))(JSON 拷贝隔离契约);getValue 入口与 actions.replace(next) 入口的 assertJsonSafe 已确保 entry.dataRef.current 不会包含 Function / Symbol / DOM / Map / Set / Date / 循环引用等非 JSON 安全值,因此 JSON 序列化恒成功
authority.write 抛出(如 localStorage 超配额 QuotaExceededError)时 logger.warn,本地 commit 仍视为成功(跨进程同步本次失效,下次 commit 重试)
- authority 不可用(默认实现探测 localStorage 不可用 / 用户未提供 adapter)时
entry.authority 为 null:降级为"同进程同 id 共享、跨进程完全不同步",与 syncMode: 'none' 效果一致,并 logger.warn('[lockData] authority unavailable, fallback to in-process sharing')
dispose / refCount === 0 时解绑所有订阅(authority.subscribe 返回的取消函数 / pageshow / visibilitychange / channel.close()),避免 Entry 被销毁后的野生回调;解绑操作幂等:options.signal.aborted + actions.dispose() 同时触发时,内部以一次性 disposed 标志位保证仅解绑一次
syncMode: 'none' 时 entry.authority = null,entry.rev 仍维护(只做本 Tab 内的单调计数,不参与持久化与广播);persistence 字段被忽略;adapters.getAuthority / adapters.getChannel / adapters.getSessionStore 均不会被调用
默认值总览
参数校验
沿用项目 dataHandler + $dt/$t 校验范式:
const validInfo = $dt({
id: $t.string(''),
// timeout 允许为 number 或 NEVER_TIMEOUT symbol,使用 $t.custom 自定义校验
timeout: $t.custom<number | typeof NEVER_TIMEOUT>(
(v) => v === NEVER_TIMEOUT || (typeof v === 'number' && v >= 0),
5000,
),
mode: $t.enum(['auto', 'web-locks', 'broadcast', 'storage'] as const, 'auto'),
syncMode: $t.enum(['none', 'storage-authority'] as const, 'none'),
persistence: $t.enum(['session', 'persistent'] as const, 'session'),
sessionProbeTimeout: $t.number(100),
})
persistence / sessionProbeTimeout 的额外校验规则:
syncMode === 'none' 下显式传入 persistence 或 sessionProbeTimeout → logger.warn('[lockData] persistence / sessionProbeTimeout ignored when syncMode is "none"'),字段按默认值回退
sessionProbeTimeout 必须是非负整数;违反走 throwType('lockData', ...)
$dt 未覆盖字段的补充校验规则(getValue / adapters / listeners / signal 为非 plain 值或需结构校验,不走 $dt):
getValue:须为 typeof === 'function'
adapters:须为对象(允许缺省,内部默认 {});其内部字段逐个校验:
getLock / getAuthority / getChannel / getSessionStore:须为 typeof === 'function'
logger:须为对象且 typeof logger.warn === 'function' && typeof logger.error === 'function'(debug 可选)
- 多余字段直接忽略(不报错,留足扩展空间)
listeners:须为对象;其中各 hook 单独做 typeof === 'function' 检查,允许缺省
signal:须满足 value instanceof AbortSignal,或结构等价检查 typeof value.addEventListener === 'function' && 'aborted' in value(用于 Node / 自定义实现兼容)
调用级校验:
ActionCallOptions 在每次 action 调用前独立校验(含 NEVER_TIMEOUT / signal 分支)
所有非法值统一走 throwType('lockData', ...)。
目录与文件规划
src/shared/lock-data/
├── RFC.md # 本文档
├── index.ts # lockData 主入口(组装 core + authority + drivers + adapters)
├── index.mdx # 用户向文档(RFC 落地后产出)
├── types.ts # 公共类型(含 LockDataAdapters / AuthorityAdapter / ChannelAdapter / ...)
├── constants.ts # NEVER_TIMEOUT / LOCK_PREFIX / HEARTBEAT_INTERVAL 等
├── errors.ts # LockTimeoutError 等错误定义
│
├── core/ # 核心协调层(与底层能力解耦,跑纯逻辑测试即可覆盖)
│ ├── registry.ts # InstanceRegistry(同 id 单例池 / 引用计数 / listeners fanout / listener 异常隔离 / signal.aborted 自动 dispose)
│ ├── actions.ts # LockDataActions 实现(update / replace / snapshot / dispose / getLock / release 六个方法 + 参数校验 + Draft 衔接 + 事件派发)
│ ├── readonly-view.ts # ReadonlyView 代理实现(深只读 wrapper Proxy / 嵌套惰性递归 / 代理身份稳定)
│ ├── draft.ts # 事务式 Draft:validityRef / mutationLog / rollback / 嵌套 draft 合并
│ └── signal.ts # AbortSignal.any 兼容封装(两路 signal "与"组合 + 老环境 polyfill)
│
├── authority/ # 权威副本 + 会话纪元协议
│ ├── index.ts # StorageAuthority 主类:initAuthority 初始化 / applyAuthorityIfNewer 应用读路径 / onCommitSuccess 写路径 / pageshow / visibilitychange 激活 pull / dispose 解绑
│ ├── serialize.ts # serialize() 固化字段顺序 rev → ts → epoch → snapshot(手动拼接,不走 JSON.stringify 对象)
│ ├── extract.ts # extractRev / extractEpoch lazy parse 快路径(锚定开头正则,O(首部长度))+ readIfNewer 过时判定(rev / epoch 校验)
│ └── epoch.ts # resolveEpoch 协议(A~F 六个启动分支 / session-probe / session-reply / freshEpoch + authority.remove 清空残留)
│
├── drivers/ # 锁驱动(底层能力层,按能力检测选择)
│ ├── index.ts # pickDriver 能力检测(优先级:CustomDriver → LocalLockDriver(无 id) → WebLocksDriver → BroadcastDriver → StorageDriver;首次创建 Entry 时调用一次,后续复用)
│ ├── local.ts # LocalLockDriver:进程内 FIFO 排队 + force 抢占 + acquireTimeout / holdTimeout 超时 + signal abort + destroy 全量 reject
│ ├── web-locks.ts # WebLocksDriver(首选):navigator.locks.request(name, { mode: 'exclusive', steal, signal }, cb);force → steal: true + onRevoked('force');timeout → AbortController.abort;dispose → resolve holdPromise 释放锁
│ ├── broadcast.ts # BroadcastDriver(降级):BroadcastChannel + 随机 token + 200ms alive 心跳 + 3 次丢失判死 + 队列 FIFO 晋升 + force 抢占去重
│ ├── storage.ts # StorageDriver(兜底降级):localStorage key `${LOCK_PREFIX}:${id}`、value `{ token, heartbeat, queue }`;setInterval 心跳;storage 事件跨 Tab;已知局限「同 Tab 多实例不触发 storage 事件」由本地补发排队 + token 重试兜底
│ └── custom.ts # CustomDriver:adapters.getLock 存在时跳过能力检测;负责拼接 name / 准备 AbortSignal / 接入 LockHandle.release 到 release 链 / 把 onRevokedByDriver 桥接到 listeners.onRevoked('force' \| 'timeout')
│
├── adapters/ # 依赖倒置的默认适配器(目录即语义,文件名精简)
│ ├── index.ts # pickDefaultAdapters 组合:逐字段合并 userAdapters 与默认实现(userAdapters.getXxx?.(ctx) ?? tryDefaultXxx(ctx))+ ResolvedAdapters 类型
│ ├── authority.ts # 默认 AuthorityAdapter(localStorage + storage 事件订阅 + QuotaExceededError 捕获);降级:localStorage 不可用 → 返回 null → entry.authority = null + logger.warn
│ ├── channel.ts # 默认 ChannelAdapter(BroadcastChannel 包装);降级:BroadcastChannel 不可用 → 返回 null → session-probe 跳过 + logger.warn
│ ├── session-store.ts # 默认 SessionStoreAdapter(sessionStorage 包装);降级:sessionStorage 不可用 → 返回 null → persistence: 'session' 降级为 'persistent' + logger.warn
│ ├── logger.ts # 默认 LoggerAdapter(委托 shared/logger);始终可用
│ # 注意:本期不再提供 clone 适配器,所有快照派生使用 JSON.parse(JSON.stringify(...)) 固化语义
│
└── __test__/ # 按源码结构镜像组织,便于定位与维护
├── README.md # 各测试文件的细粒度用例清单与断言快照(实施阶段产出)
├── core/
│ ├── readonly-view.node.test.ts # 只读代理行为(纯逻辑,无需浏览器)
│ ├── draft-transaction.node.test.ts # 事务式 Draft / mutation log / rollback / validity
│ ├── registry.node.test.ts # 同 id 单例 / 引用计数 / options 冲突 warn / signal.aborted 自动 dispose
│ └── signal.node.test.ts # options.signal / ActionCallOptions.signal 行为 / 两路 signal 与组合
├── adapters/
│ ├── resolve.node.test.ts # pickDefaultAdapters 合成 / 优先级 / 探测失败降级
│ └── memory-integration.node.test.ts # 基于内存 adapter 端到端跑 storage-authority + session 全链路
├── drivers/
│ ├── local.node.test.ts # LocalLockDriver 纯逻辑
│ ├── custom.node.test.ts # adapters.getLock 自定义锁(mock driver 即可)
│ ├── web-locks.browser.test.ts # navigator.locks 并发排队 / acquireTimeout / holdTimeout / force
│ ├── broadcast.browser.test.ts # BroadcastChannel 心跳 / 队列晋升 / 并发去重
│ └── storage.browser.test.ts # localStorage + storage 事件 / 同 Tab 多实例补发 / token 重试
├── authority/
│ ├── main.browser.test.ts # syncMode 语义 / rev-first 字段顺序 / onSync / lazy parse / 乱序丢弃 / 降级
│ ├── lifecycle.browser.test.ts # pageshow(e.persisted) / visibilitychange 激活 pull / bfcache / dispose 解绑
│ └── persistence.browser.test.ts # persistence 三类启动场景 / 残留清空 / epoch 不匹配跳 parse / 降级
└── integration/
├── actions-local.node.test.ts # 无 id 本地锁 + actions 端到端行为
└── options-validate.node.test.ts # 参数校验 / NEVER_TIMEOUT 处理 / 非法值走 throwType
目录约定:
- 顶层仅保留入口 + 基础设施(types / constants / errors),核心代码按职责进入四个子目录:
core/:纯逻辑协调层,不直接依赖浏览器 API,Node 环境即可完整测试
authority/:单点权威副本 + 会话纪元协议,内部细分序列化、快路径提取、纪元探测三块
drivers/:锁驱动按能力分层,pickDriver 能力检测选择 local / web-locks / broadcast / storage / custom
adapters/:依赖倒置的默认实现;目录名已隐含"默认适配器"语义,文件名精简为 authority.ts / channel.ts / session-store.ts / logger.ts(本期废弃 clone.ts)
- 测试镜像源码结构:
__test__/<source-dir>/*.test.ts 一一对应,出问题按路径直接找到目标测试
- 跨模块集成测试统一放
__test__/integration/
- 测试后缀
*.node.test.ts / *.browser.test.ts:按"是否依赖浏览器 API"拆分,浏览器用例走 browser provider,node 用例走 node provider(由 vitest.project.config.ts 分别声明)
导出路径需要在 src/shared/index.ts 追加 export * from './lock-data',并确保 NEVER_TIMEOUT 一同导出。
测试策略
按"是否依赖浏览器 API"拆分成 *.node.test.ts(纯逻辑,Node 运行)与 *.browser.test.ts(真实浏览器 API)。覆盖点汇总:
每个文件的细粒度用例清单 + 断言快照在实施阶段随代码一并产出到 __test__/README.md,不在 RFC 中穷举。
风险与取舍
后续扩展(非本期)
syncMode: 'crdt':基于 CRDT 的冲突合并同步
syncMode: 'broadcast-channel':基于 BroadcastChannel 的实时推送通道(如需要比 storage 事件更低延迟的场景)
- React hook
useLockData(data, options)
- Vue composable
useLockData(data, options)
- DevTools:可视化当前所有锁的持有 / 等待队列
- 持久化:持有者崩溃后的数据恢复(配合
create-storage-handler)
actions.extend(ms):延长 holdTimeout
公开决策记录
开放问题
actions.extend(holdTimeout) 是否本期就加?(当前倾向不加,作为扩展)
附录 A:完整接口索引
完整的 TypeScript 接口签名与 JSDoc 详情;正文字段说明指向此附录。
/**
* 永不超时标记(导出为常量)
* 用于 options.timeout / ActionCallOptions.{acquireTimeout,holdTimeout}
*/
export const NEVER_TIMEOUT: unique symbol
interface LockDataOptions<T extends object> {
/**
* 锁作用域标识
* - 不传:仅实例内互斥,不加入单例注册表
* - 传入字符串:双重语义
* 1. **进程内单例键**:同进程内相同 id 的多次调用共享同一份 data 引用和同一个 driver
* 2. **跨进程唯一标识**:启用跨 Worker / 跨同源 Tab 的互斥(自动拼接前缀作为 lock name)
*/
id?: string
/**
* 默认超时时间(ms),actions 调用可单独覆盖
* 默认 5000
* 作用对象:
* 1. 抢锁超时(等锁排队时)
* 2. 持有超时(拿到锁后,recipe / replace 执行 + 持锁的最长时长)
* 两者共用同一个默认值,actions 调用可通过 { acquireTimeout, holdTimeout } 拆开覆盖
* 传入 NEVER_TIMEOUT 表示永不超时
*/
timeout?: number | typeof NEVER_TIMEOUT
/**
* 跨上下文锁的底层实现模式
* - 'auto'(默认):优先 Web Locks,降级 BroadcastChannel,最后 storage token
* - 'web-locks' | 'broadcast' | 'storage':强制指定
* 注意:若提供了 adapters.getLock(自定义锁驱动),则 mode 被忽略
*/
mode?: 'auto' | 'web-locks' | 'broadcast' | 'storage'
/**
* 跨进程数据同步模式(同进程内始终共享同一份 data,与此选项无关)
* - 'none'(默认):**不跨进程同步**,跨 Tab / Worker 的 readonly 保持各自的本地值
* - 'storage-authority':以 localStorage 作为跨 Tab 权威副本
* - 每次 commit 成功后把 `{ rev, ts, epoch, snapshot }` 写入 `${LOCK_PREFIX}:${id}:latest`
* - 其他 Tab 通过 `storage` 事件接收并按 `rev` 去重后通过 `entry.applyRemote(next)` 重新赋值 `dataRef.current`
* - acquire 成功后额外同步 `getItem` + lazy parse 拉一次,保证"拿到锁 = 拿到最新值"
* - `pageshow` / `visibilitychange` 激活时主动 pull,覆盖 bfcache / freeze 期间丢失的消息
* (仅浏览器环境注册;自定义 `adapters.getAuthority` 可在非浏览器环境通过 `subscribe` 回调自行触发等价 pull)
* 注意:storage-authority 为覆盖式同步,并发合并依赖锁的串行化
*/
syncMode?: 'none' | 'storage-authority'
/**
* 跨 Tab 权威副本的持久化策略(仅 syncMode === 'storage-authority' 生效)
* - 'session'(默认):会话级持久化。同会话组所有 Tab 关闭后,权威副本随下一次启动被重置,
* 新 Tab 从 `initial` / `getValue` 开始。通过 sessionStorage 维护 epoch,
* Tab 启动时广播 session-probe 探测现有会话(默认 100ms 窗口):
* · 探测到响应 → 继承该 epoch,pull 权威副本("同会话组新开 Tab"场景)
* · 探测超时 → 视为首个 Tab,清空权威副本并生成新 epoch
* · sessionStorage 中已有 epoch(刷新 / bfcache 恢复)→ 直接继承,跳过探测
* - 'persistent':长期持久化。localStorage 是单一真源,永不自动清空。
* 典型场景:跨日持久化的用户草稿、配置、长期协作文档等。
*/
persistence?: 'session' | 'persistent'
/**
* 'session' 策略下的 session-probe 探测窗口(ms),默认 100
* 仅首次启动(sessionStorage 无 epoch 时)会阻塞这么久;刷新 / bfcache 恢复不走探测
*/
sessionProbeTimeout?: number
/**
* 自定义数据初始化函数
* - 返回同步值 → lockData 同步返回
* - 返回 Promise → lockData 返回 Promise,resolve 后 readonly 视图原地更新
*/
getValue?: () => T | Promise<T>
/**
* 依赖倒置注入点:所有可外部化的环境依赖聚合在此
* 未提供的字段走内部默认实现;详见「依赖倒置与适配器」章节
*/
adapters?: LockDataAdapters
/** 控制整个 lockData 实例生命周期的 abort 信号;aborted 等价于 dispose() */
signal?: AbortSignal
/** 事件回调收敛点,避免 options 平铺过多字段 */
listeners?: LockDataListeners
}
interface LockDataAdapters {
/** 自定义锁驱动;存在时覆盖默认能力检测 */
getLock?: (ctx: LockAcquireContext) => Promise<LockHandle> | LockHandle
/** 自定义权威副本存储(syncMode === 'storage-authority' 生效) */
getAuthority?: (ctx: AuthorityContext) => AuthorityAdapter
/** 自定义广播通道(session-probe / session-reply 用) */
getChannel?: (ctx: ChannelContext) => ChannelAdapter
/** 自定义会话级存储(存 epoch 用) */
getSessionStore?: (ctx: SessionStoreContext) => SessionStoreAdapter
/** 自定义日志(默认委托 shared/logger) */
logger?: LoggerAdapter
}
interface AuthorityContext {
/** 完整 localStorage key(已拼接前缀:`lingshu:lock-data:<id>:latest`) */
key: string
/** 锁作用域名(已拼接前缀:`lingshu:lock-data:<id>`) */
name: string
/** lockData 的 id(未拼接前缀) */
id: string
}
interface ChannelContext {
/** 通道名(已拼接前缀,如 `lingshu:lock-data:<id>:session`) */
name: string
/** 通道用途 */
purpose: 'session-probe'
/** lockData 的 id */
id: string
}
interface SessionStoreContext {
/** 完整 key(已拼接前缀:`lingshu:lock-data:<id>:epoch`) */
key: string
/** lockData 的 id */
id: string
}
interface LockAcquireContext {
/** 锁作用域名(已拼接前缀) */
name: string
/** 请求方唯一 token(与 actions.token 对应) */
token: string
/** 抢锁来源 */
source: 'update' | 'replace' | 'getLock'
/** 抢锁超时(ms 或 NEVER_TIMEOUT) */
acquireTimeout: number | typeof NEVER_TIMEOUT
/** 持锁超时(ms 或 NEVER_TIMEOUT) */
holdTimeout: number | typeof NEVER_TIMEOUT
/** 是否强制抢占 */
force: boolean
/** 外部 abort 信号(dispose / revoked 时触发) */
signal: AbortSignal
}
interface LockHandle {
/** 释放锁;lockData 内部会在每次 action 完成 / dispose 时调用 */
release: () => Promise<void> | void
/** driver 主动驱逐当前持有者时调用;内部会把 isHolding 置 false 并触发 onRevoked */
onRevokedByDriver?: (cb: (reason: 'force' | 'timeout') => void) => void
}
interface LockDataListeners<T extends object = object> {
/** 锁状态机阶段:waiting / acquired / released / rejected */
onLockStateChange?: (event: LockStateEvent) => void
/** 锁被外部驱逐(force / timeout / dispose) */
onRevoked?: (reason: 'force' | 'dispose' | 'timeout') => void
/** recipe 成功 commit 后触发;携带 mutation log + snapshot + rev */
onCommit?: (event: LockCommitEvent<T>) => void
/** 收到其他进程的权威副本更新时触发(仅 syncMode === 'storage-authority') */
onSync?: (event: LockSyncEvent<T>) => void
}
interface LockCommitEvent<T extends object> {
source: 'update' | 'replace'
token: string
/** 本次 commit 后的权威单调序号;无 id 场景下仅为本实例单调递增计数,不具跨进程可比性 */
rev: number
/** 本次变更的最小路径集(只读深冻结) */
readonly mutations: ReadonlyArray<{
readonly path: ReadonlyArray<PropertyKey>
readonly op: 'set' | 'delete'
readonly value?: unknown
}>
/** commit 后的完整快照(经 JSON.parse(JSON.stringify(...)) 产生,已与内部 dataRef.current 隔离) */
readonly snapshot: Readonly<T>
}
interface LockSyncEvent<T extends object> {
source: 'pull-on-acquire' | 'storage-event' | 'pageshow' | 'visibilitychange'
rev: number
readonly snapshot: Readonly<T>
}
interface LockStateEvent {
source: 'update' | 'replace' | 'getLock'
phase: 'waiting' | 'acquired' | 'released' | 'rejected'
reason?: 'timeout' | 'revoked' | 'disposed'
token: string
/** 本次排队 → 获取的耗时(acquired / rejected 时存在,ms) */
waitedMs?: number
}
// Adapter 接口签名(完整 JSDoc 见「依赖倒置与适配器」章节)
interface AuthorityAdapter {
read(): string | null
write(raw: string): void
remove(): void
subscribe(onExternalUpdate: (newValue: string | null) => void): () => void
}
interface ChannelAdapter {
postMessage(message: unknown): void
subscribe(onMessage: (message: unknown) => void): () => void
close(): void
}
interface SessionStoreAdapter {
read(): string | null
write(value: string): void
}
interface LoggerAdapter {
warn(message: string, ...extras: unknown[]): void
error(message: string, ...extras: unknown[]): void
debug?(message: string, ...extras: unknown[]): void
}
附录 B:完整示例集
正文「使用示例」仅保留最常用的 6 个核心场景;此处汇总状态观察、审计、跨端适配、单元测试等高级示例。
观察抢锁过程(listeners.onLockStateChange)
lockData({
id: 'k',
getValue: () => data,
listeners: {
onLockStateChange: (evt) => {
if (evt.phase === 'waiting') showLoading()
if (evt.phase === 'acquired') hideLoading(`waited ${evt.waitedMs}ms`)
if (evt.phase === 'rejected') toast(`lock failed: ${evt.reason}`)
},
onRevoked: (reason) => toast(`lock revoked: ${reason}`),
onSync: (evt) => console.log(`received remote update rev=${evt.rev}`, evt.snapshot),
},
})
审计 commit(listeners.onCommit)
lockData({
id: 'audit',
getValue: () => data,
listeners: {
onCommit: ({ source, token, mutations, snapshot }) => {
// mutations:本次变更的最小路径集(只读深冻结)
// [{ path: ['user', 'name'], op: 'set', value: 'cmt' }]
auditLogger.push({
at: Date.now(),
by: token,
action: source,
changes: mutations,
})
// snapshot:commit 后的完整快照(经 JSON.parse(JSON.stringify(...)) 产生,可安全持久化)
persistToStorage(snapshot)
},
},
})
自定义日志(adapters.logger)
import { lockData } from '@cmtlyt/lingshu-toolkit/shared'
import * as Sentry from '@sentry/browser'
const [view, actions] = lockData({
id: 'editor',
syncMode: 'storage-authority',
getValue: () => initialData,
adapters: {
logger: {
warn: (message, ...extras) => Sentry.captureMessage(message, { level: 'warning', extra: { extras } }),
error: (message, ...extras) => Sentry.captureException(new Error(message), { extra: { extras } }),
},
},
})
// 注意:本期 lockData 强制要求数据为 JSON 安全(无 Map / Set / Date / class instance / 循环引用 / Function / Symbol)
// 入口 assertJsonSafe 会在 getValue resolve 与 actions.replace(next) 处 fail-fast 拒绝非法值(抛 InvalidOptionsError)
// 因此不再需要自定义 clone 适配器;所有快照派生使用 JSON.parse(JSON.stringify(...))
Electron 主进程广播适配(自定义 authority + channel)
import { lockData } from '@cmtlyt/lingshu-toolkit/shared'
import type { AuthorityAdapter, ChannelAdapter } from '@cmtlyt/lingshu-toolkit/shared'
import { ipcRenderer } from 'electron'
function createElectronAuthority(ctx: { key: string }): AuthorityAdapter {
return {
read: () => ipcRenderer.sendSync('lockData:read', ctx.key),
write: (raw) => ipcRenderer.send('lockData:write', ctx.key, raw),
remove: () => ipcRenderer.send('lockData:remove', ctx.key),
subscribe: (onExternalUpdate) => {
const handler = (_: unknown, key: string, newValue: string | null) => {
if (key === ctx.key) onExternalUpdate(newValue)
}
ipcRenderer.on('lockData:update', handler)
return () => ipcRenderer.off('lockData:update', handler)
},
}
}
function createElectronChannel(ctx: { name: string }): ChannelAdapter {
return {
postMessage: (message) => ipcRenderer.send('lockData:broadcast', ctx.name, message),
subscribe: (onMessage) => {
const handler = (_: unknown, channel: string, message: unknown) => {
if (channel === ctx.name) onMessage(message)
}
ipcRenderer.on('lockData:broadcast', handler)
return () => ipcRenderer.off('lockData:broadcast', handler)
},
close: () => { /* ipcRenderer 由主进程管理,无需单独 close */ },
}
}
const [view, actions] = await lockData({
id: 'app:shared',
syncMode: 'storage-authority',
persistence: 'persistent',
getValue: () => fetchSharedState(),
adapters: {
getAuthority: createElectronAuthority,
getChannel: createElectronChannel,
},
})
// 多个 BrowserWindow 之间通过主进程 IPC 广播达成跨窗口同步,跳过 localStorage 限制
单元测试内存适配器(脱离浏览器环境跑完整链路)
import { lockData } from '@cmtlyt/lingshu-toolkit/shared'
import type { AuthorityAdapter, ChannelAdapter, SessionStoreAdapter } from '@cmtlyt/lingshu-toolkit/shared'
function createMemoryAdapters(scope: Map<string, string>, bus: Map<string, Set<(msg: unknown) => void>>) {
const subsByKey = new Map<string, Set<(v: string | null) => void>>()
const getAuthority = (ctx: { key: string }): AuthorityAdapter => ({
read: () => scope.get(ctx.key) ?? null,
write: (raw) => {
scope.set(ctx.key, raw)
subsByKey.get(ctx.key)?.forEach((cb) => cb(raw))
},
remove: () => {
scope.delete(ctx.key)
subsByKey.get(ctx.key)?.forEach((cb) => cb(null))
},
subscribe: (cb) => {
const set = subsByKey.get(ctx.key) ?? new Set()
set.add(cb)
subsByKey.set(ctx.key, set)
return () => set.delete(cb)
},
})
const getChannel = (ctx: { name: string }): ChannelAdapter => ({
postMessage: (msg) => bus.get(ctx.name)?.forEach((cb) => cb(msg)),
subscribe: (cb) => {
const set = bus.get(ctx.name) ?? new Set()
set.add(cb)
bus.set(ctx.name, set)
return () => set.delete(cb)
},
close: () => {},
})
const getSessionStore = (ctx: { key: string }): SessionStoreAdapter => {
const sessionScope = new Map<string, string>()
return {
read: () => sessionScope.get(ctx.key) ?? null,
write: (v) => sessionScope.set(ctx.key, v),
}
}
return { getAuthority, getChannel, getSessionStore }
}
// 测试里:多个 Tab 共享同一套 memory adapters,在 Node 环境下跑通 session-probe 全链路
const storage = new Map<string, string>()
const bus = new Map<string, Set<(msg: unknown) => void>>()
const adapters = createMemoryAdapters(storage, bus)
const [viewA, actA] = await lockData({ id: 't', syncMode: 'storage-authority', adapters, getValue: () => ({ count: 0 }) })
const [viewB] = await lockData({ id: 't', syncMode: 'storage-authority', adapters, getValue: () => ({ count: 0 }) })
await actA.update((d) => { d.count = 1 })
expect(viewB.count).toBe(1) // authority.subscribe 同步触达
评审通过记录
Accepted on 2026/04/29
- 评审版本:0.1.4
- 评审通过方:@cmtlyt(仓库所有者 / RFC 作者)
- 后续动作:进入实施阶段。下一次对本 RFC 的追加或修订发生在实施完成并发布后,版本号按规范直接升至
1.0.0(见「版本管理」章节 L22)