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.ydraft / review 阶段):
    • x → 重大设计变更(新增 / 删除 / 语义翻转一级字段或协议)
    • y → 澄清、措辞、示例调整
    • status 迁移(draft → review → accepted)本身不触发版本递增;只有对应分支已推送 / 已被外部引用后的再次变更才需递增
  • 1.0.0:评审通过(status: accepted)后一次性升级
  • 1.x.y 及以后:仅在已 accepted 的 RFC 再做追加或修订时递增
版本日期变更摘要
0.1.02026/04/27初稿(含 30 条决策记录、依赖倒置适配器聚合、persistence + epoch 探测、StorageAuthority 权威副本、文档结构重组为「正文 + 附录 A 完整接口索引 + 附录 B 完整示例集」);随后在同一版本内由 draft 转入 review
0.1.12026/04/28四轮严格自检后的澄清与措辞修正 + 基础设施级前置改动(无 RFC 字段 / 协议级变更)。主要改动:① update / replace / getLock 返回类型契约精确化(按异步条件枚举,替代先前"有 id/无 id"二分);② dispose 幂等语义明确(第二次起恒同步 void);③ data === undefined 边界与 getValue 异步分支衔接;④ LockDisposedErrorgetValue Promise reject 下通过 cause 字段传递原始错误;⑤ read()syncMode: 'storage-authority' 下的过时数据提示;⑥ dataReadyState 状态转换表 + listener 异常隔离 + 订阅解绑幂等;⑦ resolveEpoch TOCTOU 窗口纳入风险表;⑧ 决策 #10 补入遗漏的 onCommit;⑨ 正文 / 附录 A / 附录 B 三方回调示例签名对齐(onSync 使用 LockSyncEvent 对象);⑩ 默认实现命名占位说明 + 重载匹配规则说明 + extractRev 复杂度注释;⑪ 基础设施:shared/throw-error 同步扩展为支持 options.cause 参数(ES2022 Error.cause),重载兼容既有调用点,RFC 的「错误类型」章节新增「基础设施约定」blockquote 并附签名摘录
0.1.22026/04/28目录结构调整(无 RFC 字段 / 协议级变更)。核心代码不再平铺根目录,按职责分 4 个子目录:core/(协调层:registry / actions / readonly-view / draft / signal)、authority/(拆分为 index(StorageAuthority 主类 / initAuthority / applyAuthorityIfNewer / onCommitSuccess / 生命周期订阅)+ serialize(固化字段顺序 rev→ts→epoch→snapshot)+ extract(extractRev / extractEpoch / readIfNewer 快路径过时判定)+ epoch(resolveEpoch A~F 六分支 / session-probe / session-reply))、drivers/(不变)、adapters/(文件名简化:authority.ts / channel.ts / session-store.ts / logger.ts / clone.ts);根目录仅保留 index.ts / index.mdx / types.ts / constants.ts / errors.ts + RFC.md。测试目录按源码镜像为 __test__/{core,adapters,drivers,authority,integration}/,跨模块集成测试归入 integration/。同步更新「目录与文件规划」「依赖倒置与适配器」「测试策略」「风险与取舍」「公开决策记录 #14」等章节的路径引用
0.1.32026/04/28API 表面扩展(非 breaking):LockDataActions 新增 release(): void 方法,拆分原 dispose 过载的"还锁 + 销毁实例"双重职责。release 仅处理还锁(release 底层锁 + 清理 holdTimeout + state 回 idle),不碰引用计数 / 订阅解绑,actions 仍可继续使用;dispose 语义不变。同步更新:① 正文「API 设计 / LockDataActions」签名 + JSDoc;② 正文「调用语义要点」新增 release vs dispose 职责分工说明 + 长生命周期用法示例;③ 正文「Actions 实现要点」拆分 release / dispose 流程;④ 使用示例「多步事务」新增长生命周期场景(场景 B:用 release 还锁、实例复用);⑤ 附录 A 接口索引同步(共用定义,随正文变更);⑥ 新增公开决策记录 #31;⑦ 新增风险表「release / dispose 语义混淆」条目
0.1.42026/04/29架构备忘(无 API / 协议变更):记录"事务式 Draft 暂不外部化为 shared/transactional-draft"的决议(方向 A)。实施阶段 core/draft.ts 保持 self-contained 实现,源文件顶部加迁移注释指向 RFC;RFC 正文「事务式 Draft」章节新增「外部化前瞻(可选迁移路径)」小节,预留通用化 API 骨架(createTransaction / Transaction / Mutation)+ 需补齐的通用化能力清单 + 明确的抽离触发条件。新增公开决策记录 #32
0.1.52026/05/08协议级 breaking 重构:① API 单参数化lockData(initial, options) 三重载 → lockData(options) 单签名 + 条件类型推断;getValue 升为必传,独立 initial 入参彻底删除;② actions.read()actions.snapshot():语义对齐「独立于 readonly view 的数据快照」,配合 wrapper Proxy 解决 structuredClone(view) 浏览器硬限制;③ 顶层数组双重禁止:类型层 LockDataValueShape<T> = T extends readonly unknown[] ? never : T(覆盖 string[] / ReadonlyArray<X> / 元组等所有数组形态)+ 运行时 Array.isArray(awaited) fail-fast 抛 InvalidOptionsError;④ wrapper Proxy 方案:废弃旧「entry.data 引用稳定 + applyInPlace 原地覆写」契约,改为 entry.dataRef: { current: T } + view Proxy trap 重定向到 dataRef.current;commit / host.applyRemote 通过整体重新赋值 dataRef.current 触发更新,新契约下 view 引用稳定但 Object.isFrozen(view) 永远 false(已知瑕疵,决策接受);⑤ JSON 拷贝隔离契约:所有进入 dataRef.current 的值(getValue / replace / commit / applyRemote / snapshot)统一走 JSON.parse(JSON.stringify(...));新增 utils/json-safe.tsassertJsonSafe / assertJsonSafeInput / assertNotTopLevelArray / cloneByJson)在 getValue / replace 入口 fail-fast;删除 adapters.clone / CloneFn / createSafeCloneFn / adapters/clone.ts;⑥ dataReadyState 状态机半极简化:删除 Entry.dataReadyState / Entry.dataReadyError 字段,仅保留 Entry.dataReadyPromise 单标志位;同步抛错路径不构造 Entry(直接抛出),异步 reject 走 dataReadyPromise 透传;⑦ authority 钩子重构StorageAuthorityHost.data 字段 + applySnapshot 钩子 → StorageAuthorityHost.applyRemote(next: T): void 方法;StorageAuthorityDeps 删除 clone / applySnapshot 注入。同步更新:API 设计 / 总览 / 签名 / 顶层数组禁止 / readonly view 引用稳定契约 / actions.snapshot 章节 / storage-authority Promise resolve 三步流程 / 附录 A 接口索引;新增决策 #33;actions.ts 拆分为 actions.ts + actions-helpers.ts 满足 biome noExcessiveLinesPerFile
0.1.62026/05/08测试组织形式优化(无 API / 协议变更):参考仓库内 src/shared/condition-merge/index.test-d.ts 模式,把 lockData 的编译期类型断言(expectTypeOf)从 runtime 测试中抽离到独立的 .test-d.ts 文件 —— 新建 src/shared/lock-data/index.test-d.ts(覆盖 lockData 同步路径精确 LockDataTuple<T> / 异步路径精确 Promise<LockDataTuple<T>> / syncMode='storage-authority' 强制 id 的 4 项类型契约 + ReadonlyView<T>readonly)+ src/shared/lock-data/__test__/integration/entry.test-d.ts(覆盖三条初始化路径类型契约 + ReadonlyView<T> 嵌套递归 + 函数类型透传不递归);从 index.test.ts / __test__/integration/entry.node.test.ts 移除全部 11 处 expectTypeOf 断言及未使用的类型导入(LockDataTuple / ReadonlyView)。配套:vitest.project.config.tstypecheck.include = ['src/${namespace}/**/*.test-d.ts'] 已支持自动收口(CI 环境 enabled=!CI_TEST 跳过省时间,本地 / tsc --noEmit 仍能强制校验)。收益:① runtime 测试聚焦 runtime 行为、类型测试聚焦编译期契约,关注点分离;② 风格与仓库其它工具(如 condition-merge)保持一致;③ 不再混入"无 runtime expect"的纯类型断言用例,测试报告列表更清晰
X.Y.ZYYYY/MM/DD一句话变更摘要;涉及字段 / 协议变更时列明新增、删除、重命名;对应的决策追加到「公开决策记录」并引用决策编号(如 #31)

背景与动机

在构建工具库、状态管理、配置中心等场景时,经常会遇到这样的诉求:

  • 某份数据在"持有方"视角下只读,防止被外部(插件、业务代码)直接篡改
  • 修改行为必须收敛到一组受控 API(鉴权 / 埋点 / 审计 / 校验)
  • 在多 Worker、多 Tab 同时持有"同一份逻辑数据"时,需要一把互斥锁来保证修改的串行化

现有的 Object.freezeProxy 读写拦截、immer 都只能解决同线程同文档内的只读语义,对跨线程 / 跨标签页场景无能为力。 WebAPI 里的 navigator.locks 天然提供了跨 Worker / 跨同源 Tab 的互斥锁原语,但它只管调度不管数据;而 BroadcastChannelstorage 事件提供了跨上下文广播能力。 本 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.locksBroadcastChannel + token → localStorage + token
  • 遵循项目既有风格:throw-error 报错、dataHandler 校验、logger 日志、无实现细节外泄

非目标

  • 实现 CRDT 级别的冲突合并(syncMode 基于锁序列化的权威副本 + 单调 rev 单向覆盖,CRDT 留给未来)
  • 实现 SharedArrayBuffer / Atomics 级别的共享内存互斥
  • 实现持久化存储(宿主进程退出锁自动释放)
  • 替代 immer / mobx / pinia,只做"锁 + 只读视图 + 受控写入"这三件事

名词约定

名词含义
Holder(持有者)当前通过 lockData 成功获取锁的上下文(一个标签页 / Worker / 进程实例)
Waiter(等待者)已发起 lockData 但尚未获得锁的调用
Scope(作用域)id 唯一标识的逻辑锁域;未传 id 则作用域局限在当前上下文
Instance(实例)一次 lockData(...) 调用产出的 [readonly, actions] 元组;同 id 多实例共享同一份 data
Entry(单例条目)进程内 InstanceRegistry 中与某个 id 关联的共享状态(data 引用 / driver / 引用计数 / listeners fanout)
ReadonlyView(只读视图)readonly 代理,任何写操作抛 TypeError
Actions(受控 API)修改数据的唯一合法通道
Draft(草稿)update(recipe) 中 recipe 接收的可写代理,写入落在 working copy 上,不污染底层 data
Working Copy(工作副本)每次 update 内部为 draft 绑定的影子对象,commit 成功才会原子地落到底层 data
Mutation Log(变更日志)draft 的 set / delete 操作按路径记录的最小变更集;commit 时回放,abort 时丢弃

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, ... }) 返回独立的 actionsreadonly 代理,但底层 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 阶段中止等价于 LockAbortedErrorholding 阶段中止会丢弃本次 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:
    1. localStorage 权威副本的 storage 事件订阅已就绪,BroadcastChannelsession-probe 订阅已就绪
    2. 会话 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',不做探测
    3. localStorage 权威 key 同步 getItem + lazy parse + epoch 校验
      • 命中且 snapshot.epoch === entry.epochentry.dataRef.current = JSON.parse(JSON.stringify(snapshot)) 重新赋值(wrapper 方案下不再走 applyInPlace 原地覆写,详见「readonly view 的引用稳定契约」章节)
      • 命中但 epoch 不一致 → 丢弃(视为上一会话组残留,已被步骤 2 的"清空"清理)
      • 未命中(首次启动 / localStorage 不可用)→ 以本地 data / getValue 为准

LockDataOptions

字段总览(完整类型签名见「附录 A:完整接口索引」):

字段类型默认值说明
idstring锁作用域标识;传入即启用进程内单例池 + 跨进程互斥;不传仅实例内互斥
timeoutnumber | typeof NEVER_TIMEOUT5000acquireTimeout / holdTimeout 的默认值;action 调用可拆开覆盖
mode'auto' | 'web-locks' | 'broadcast' | 'storage''auto'锁驱动选择;adapters.getLock 存在时被忽略
syncMode'none' | 'storage-authority''none'跨进程数据同步模式;非 'none'lockData 返回 Promise
persistence'session' | 'persistent''session'权威副本生命周期;仅 syncMode === 'storage-authority' 生效
sessionProbeTimeoutnumber100'session' 策略首次启动的 session-probe 窗口(ms)
getValue() => T | Promise<T>自定义初始化;返回 Promise 时 lockData 返回 Promise
adaptersLockDataAdapters{}依赖倒置聚合入口;详见「依赖倒置与适配器」章节
signalAbortSignal实例级 abort;aborted 等价于 dispose() + refCount -1
listenersLockDataListeners{}事件回调(onLockStateChange / onRevoked / onCommit / onSync

关键字段补充

  • 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.subscriberev 去重后原地更新;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.onLockStateChangeonRevoked 收敛进 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 方案):

层次引用稳定性
entry.dataRef(内部 wrapper 引用)✅ 永不变更(Entry 构造完成后)
view(用户面 Proxy)✅ 永不变更(指向 wrapper Proxy)
entry.dataRef.current(实际数据引用)❌ 每次 commit / init resolve / host.applyRemote 都重新赋值

用户面 view 引用稳定,但 view 内字段值会跟随 dataRef.current 重新赋值变化(通过 Proxy trap 解引用 dataRef.current 实现)。

已知语义瑕疵(用户决策接受):Object.isFrozen(view) 永远返回 false(wrapper Proxy 不是冻结对象),但任何写入 view 的操作仍被 trap 拒绝。

顶层数组禁止

getValue 返回类型不允许为顶层数组。双重拦截

  1. 类型层 fail-fast(编译期):LockDataValueShape<T> = T extends unknown[] ? never : T,把 getValue: () => string[] 这类用法在 TypeScript 编译期排除为 never
  2. 运行时 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 的完成时间计时

  • releasedispose 的职责分离

    • release:只处理"还锁"语义(release 锁 + 清理 holdTimeout、state 回 idle),actions 仍可继续使用;适合长生命周期实例(如 React 组件内的持续交互)
    • dispose:处理"销毁实例"语义(包含 release 的所有工作 + 引用计数-1 + 订阅解绑 + 进入 disposed 终态);适合短生命周期事务
    • 只要能同步走完 release 路径,release 始终为同步 voiddispose 则按是否持锁返回 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
错误触发时机
LockTimeoutErrorError超过 timeout 仍未获得锁
LockRevokedErrorError持有锁期间被 force 抢占 / holdTimeout 触发;当前 working copy 被丢弃,持有者后续写入 draft 立即抛错
LockDisposedErrorErrordispose() 后继续调用 actions;或 options.signal.aborted 后任意调用;或 options.getValue 同步抛错 / 返回的 Promise reject 后共享同一 Entry 的任意调用(cause 字段携带原始原因)
LockAbortedErrorErrorActionCallOptions.signal 在 acquiring / holding 阶段 abort
ReadonlyMutationErrorTypeError直接修改 readonly 视图
InvalidOptionsErrorTypeErroroptions 不合法(如 timeout < 0 / getValue 缺失 / getValue 返回顶层数组 / replace(next) 入参非 JSON-safe)

使用示例

本地只读锁(不传 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 结构关键字段

字段说明
dataRefwrapper 引用 { current: T }dataRef 引用本身永不变更,dataRef.current 在 commit / host.applyRemote 时重新赋值;readonly view 通过 wrapper Proxy 跟随
applyRemote(next: T) => void:authority 远程同步入口;方法内部执行 dataRef.current = JSON.parse(JSON.stringify(next))
driver共享锁驱动(由 pickDriver 决定)
adapterspickDefaultAdapters 解析后的最终适配器(authority / channel / sessionStore / logger)
refCount已发出未 dispose 的实例数;归零时销毁 Entry
listenersSetSet<LockDataListeners>,每实例独立;driver 事件向全部 fanout
initOptions首次注册时的冻结配置(用于冲突检查)
dataReadyPromise异步初始化未就绪场景的等待依据;null 表示已就绪;非 null 出现于:① 异步 getValue 期间的内部状态;② 同 Tab 二次调用方命中 Entry 时;③ authority.init 等待场景
rev当前 data 的权威单调序号;commit 成功 +1,初始 0
lastAppliedRev最近一次应用 authority snapshot 的 rev,用于去重
authoritysyncMode: 'storage-authority' 时存在;承载权威副本读写 + 订阅
epoch当前 Tab 所属会话纪元('session' 策略用),null'persistent' 表示不做 epoch 过滤

注册 / 释放流程要点

  • 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 +1actions.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 时通过 ensureDataReadyLockDisposedErrorcause 字段携带原始原因),并触发 refCount -1
  • 无 id:不进入 InstanceRegistry,每次 lockData 完全独立;dataReadyPromise 仅存在于该 Entry 的内部生命周期内

Entry 对外可见 ↔ 数据已就绪(fail-fast 语义)

异步初始化未就绪场景(同 Tab 二次调用方命中 / authority.init 等待 / 异步初始化期间提前注册)通过 dataReadyPromise 等待 resolve;同步路径下 dataReadyPromise === null,Entry 构造瞬间即已就绪。

  • 同步 getValue() 抛错 → lockData() 调用栈直接抛 LockDisposedErrorEntry 不构造,不进 registry)
  • 异步 getValue() reject → dataReadyPromise.reject + entry.refCount = 0 + registry.delete(id) 立即触发 teardowns(Entry 仍曾短暂注册,但所有持有方在 await 元组时一致拿到 reject)

进入 reject 后,同 Tab 所有持有此 Entry 的调用方在 action 时通过 ensureDataReadyLockDisposedErrorcause 字段携带 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

pickDriverpickDefaultAdapters 仅在 getOrCreateEntry 首次创建 Entry 时调用;同 id 后续实例直接复用 Entry 中的 driver 与 adapters。降级策略仅影响锁调度 / 跨进程同步层,readonly / actions 的行为对使用者完全透明。

降级触发矩阵

场景触发条件降级行为
自定义锁驱动adapters.getLock 提供覆盖默认能力检测
权威副本不可用adapters.getAuthority 未提供 且 localStorage 不可用entry.authority = nullsyncMode: 'storage-authority' 退化为同进程共享 + logger.warn
广播通道不可用adapters.getChannel 未提供 且 BroadcastChannel 不可用session-probe 跳过(按"首个 Tab"语义) + logger.warn
会话存储不可用adapters.getSessionStore 未提供 且 sessionStorage 不可用persistence: 'session' 降级为 'persistent' + logger.warn
克隆失真Function / Symbol / DOM / Map / Set / Date / 循环引用等非 JSON 安全值会被 JSON 拷贝吞掉或抛错getValue resolve 后 + actions.replace(next) 入参在边界上由 assertJsonSafe fail-fast 拒绝(抛 InvalidOptionsError),保证 entry.dataRef.current 永远只含 JSON 安全值,下游 JSON.parse(JSON.stringify(...)) 恒成功

CustomDriver

  • adapters.getLock 存在时,一切能力检测被跳过,driver 仅作为适配层把 LockAcquireContext 传给用户回调
  • 内部仍负责:
    • 拼接 namelingshu: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 产生的 LockHandlerelease 调用时把下一个 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>

  • forcesteal: true;原持有者回调被 reject AbortError,捕获后触发 onRevoked('force') + isHolding = false
  • timeoutAbortController.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.abortedcallOpts.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. 仓内新增至少 1 个 具体工具需要同样的"原地修改 + 失败回滚"机制(编辑器、动画回放、表单草稿等)
  2. 外部仓反馈有复用需求,且其场景与 lock-data 当前用法的交集 ≥ 70%
  3. 通用化 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 内部实现需要补齐的通用化能力

  1. 暴露 prevValue 字段(内部实现中仅保留在 snapshot 里,通用工具需暴露给用户做审计 / 反向补丁)
  2. Map / Set 支持(内部实现只覆盖普通对象和数组)
  3. 手动 commit() / rollback() 显式控制(内部实现绑定在 recipe 生命周期上)
  4. 可选:savepoint / nested transaction(子事务独立回滚)

迁移成本评估core/draft.ts 在 self-contained 状态下 API 表面小、核心约 80 行;未来抽离时只需在 shared/transactional-draft 产出通用实现,lock-data 内部用薄适配层(绑定 LockRevokedError + recipe 生命周期)即可复用——不构成预先外部化的理由,但为未来演进留足空间。

Actions 实现要点

  • 内部状态机:idleacquiringholdingcommittingreleased / revoked / disposed
  • 写入方法流程:update / replace / getLock 入口统一走 ensureHolding(opts)
    1. state === 'holding' 且锁未被 revoke → 直接复用当前锁
    2. 否则构造合并 AbortSignaloptions.signal + callOpts.signal + 内部 acquireTimeout controller),调用 driver.acquire({ acquireTimeout, holdTimeout, force, signal }) 拿锁
    3. 拿到锁后启动 holdTimeout 定时器(holdTimeout === NEVER_TIMEOUT 时跳过),到期后主动 release、置 validity.isValid = false、state 置 revoked
    4. 每一步状态流转都通过 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 时通过 ensureDataReadyLockDisposedError(视为初始化失败,所有共享该 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) 在每个字段缺省时按需组合:

Adapter默认实现位置不可用时的降级
getLockpickDriver(options, id) 能力检测(Web Locks → Broadcast → Storage → Local)drivers/index.ts由能力检测自行兜底
getAuthorityDefaultLocalStorageAuthority:localStorage + storage 事件订阅 + QuotaExceededError 捕获adapters/authority.ts探测 localStorage 不可用 → 返回 null → entry.authority = null + logger.warn
getChannelDefaultBroadcastChannelBroadcastChannel 包装adapters/channel.ts探测 BroadcastChannel 不可用 → 返回 null → session-probe 跳过 + logger.warn
getSessionStoreDefaultSessionStore:sessionStorage 包装adapters/session-store.ts探测 sessionStorage 不可用 → 返回 null → persistence: 'session' 降级为 'persistent' + logger.warn
loggershared/loggeradapters/logger.ts始终可用

组合时机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用户对应字段是 function绑定用户 this 后作为该字段实现
同上用户未传该字段 / 值不是 function / 显式传 undefined回落到默认 logger 对应方法

关键性质

  • 字段级合并,非对象级替换:用户只实现 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 注入时由用户自行决定底层),互不干扰:

key职责写入方访问入口
lingshu:lock-data:${id}锁调度状态(StorageDriver 专用)StorageDriveradapters.getLock 或默认 driver
lingshu:lock-data:${id}:latest数据权威副本StorageAuthorityadapters.getAuthority 或默认实现
lingshu:lock-data:${id}:epoch当前 Tab 的会话纪元(sessionStorage)StorageAuthorityadapters.getSessionStore 或默认实现

存储格式(固化契约)

// 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 })
触发源触发时机source 取值数据拉取方式
acquire 时 pulldriver.acquire 成功、进入 recipe 前'pull-on-acquire'entry.adapters.authority.read() 同步读
authority.subscribe push其他进程写入触发订阅回调'storage-event'回调直接传入 newValue(默认实现由 storage 事件触发;自定义实现由 IPC / MessagePort / Redis sub 触发)
激活时主动 pullpageshow(e.persisted) / visibilitychange → 'visible''pageshow' / 'visibilitychange'entry.adapters.authority.read() 同步读

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 重新开始。

策略总览

persistence生命周期典型场景
'session'(默认)当前会话组(同源所有活跃 Tab)的最大存活期临时协作表单、多 Tab 共享的操作进度、向导状态
'persistent'localStorage 自然持久期(跨会话、跨浏览器重启)用户偏好、长期草稿、跨日持续的协作文档

数据通道

通道用途所属Adapter
会话级存储(默认 sessionStorage) key ${LOCK_PREFIX}:${id}:epoch当前 Tab 的会话纪元(Tab 级隔离,关闭即清空,刷新保留)StorageAuthorityadapters.getSessionStore
权威副本存储(默认 localStorage) key ${LOCK_PREFIX}:${id}:latest跨进程权威副本,value 包含 epoch 字段StorageAuthorityadapters.getAuthority
广播通道(默认 BroadcastChannel) name ${LOCK_PREFIX}:${id}:sessionsession-probe / session-reply 消息StorageAuthorityadapters.getChannel

resolveEpoch() 协议

启动分支与对应行为:

分支判定条件epoch 来源清空 authority备注
A. 持久化persistence === 'persistent'常量 'persistent'不做探测
B. sessionStore 不可用'session' + !adapters.sessionStore降级为 'persistent'logger.warn
C. 刷新 / bfcachesessionStore.read() 有值直接继承不探测
D. channel 不可用首次启动 + !adapters.channel生成新 UUID✅ 清空残留logger.warn + 按"首个 Tab"处理
E. 同会话组新开 Tab首次启动 + 探测收到 session-reply继承响应方 epochpull 命中权威副本
F. 首个 Tab(含残留)首次启动 + 探测超时生成新 UUID✅ 清空上一会话组残留localStorage 已为空时 remove 也是 no-op
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.authoritynull:降级为"同进程同 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 = nullentry.rev 仍维护(只做本 Tab 内的单调计数,不参与持久化与广播);persistence 字段被忽略;adapters.getAuthority / adapters.getChannel / adapters.getSessionStore 均不会被调用

默认值总览

选项默认值备注
idundefined不传即本地锁;传入即启用进程内单例池 + 跨进程唯一标识
timeout5000同时作为 acquireTimeout / holdTimeout 的默认;NEVER_TIMEOUT 表示永不超时
mode'auto'自动能力检测;adapters.getLock 存在时忽略
syncMode'none'默认不跨进程同步(同进程同 id 始终共享);'storage-authority' 启用 localStorage 权威副本,lockData 返回 Promise
persistence'session'syncMode === 'storage-authority' 生效;默认"会话级":同会话组所有 Tab 关闭后权威副本自动重置;'persistent' 保留长期持久化
sessionProbeTimeout100'session' 策略下 session-probe 探测超时(ms),仅首次启动阻塞
getValueundefined可选初始化函数
adapters{}依赖倒置注入点聚合对象;各字段缺省时走默认实现(见下)
adapters.getLockundefined自定义锁驱动工厂,存在时覆盖默认 driver
adapters.getAuthorityundefined自定义权威副本存储工厂,默认 DefaultLocalStorageAuthority
adapters.getChannelundefined自定义广播通道工厂,默认 DefaultBroadcastChannel
adapters.getSessionStoreundefined自定义会话存储工厂,默认 DefaultSessionStore(sessionStorage)
adapters.loggerundefined自定义日志,默认委托 shared/logger
signalundefined控制整个实例生命周期;abort 等价于 dispose
listeners{}事件回调收敛点,可省略任意 hook
ActionCallOptions.acquireTimeoutoptions.timeout抢锁超时
ActionCallOptions.holdTimeoutoptions.timeout持锁超时
ActionCallOptions.forcefalse默认排队
ActionCallOptions.signalundefined本次调用专用 abort 信号

参数校验

沿用项目 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' 下显式传入 persistencesessionProbeTimeoutlogger.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)。覆盖点汇总:

文件环境核心覆盖点
core/readonly-view.node.test.tsNode深只读:嵌套对象 / 数组 / Set / Map 的 mutation 全抛 ReadonlyMutationError;代理身份一致;actions 写入后读到最新值
core/draft-transaction.node.test.tsNode正常 recipe / 抛错回滚 / 异步期间被 force / holdTimeout / 嵌套 draft / replace 语义 / mutation log 精确性
core/registry.node.test.tsNode同 id 多实例共享 dataRef;后续 getValue 不调用;非 listeners 字段冲突 logger.warn;refCount 归零销毁;signal.aborted 自动 dispose;同步 getValue() 抛错走 LockDisposedError;顶层数组运行时 fail-fast
core/signal.node.test.tsNodeoptions.signal + ActionCallOptions.signal 的 acquiring / holding 阶段 abort 行为;两路 signal 的"与"组合
adapters/resolve.node.test.tsNodepickDefaultAdapters 字段级优先级;默认 authority / channel / sessionStore 探测失败的降级;用户 adapter 参数校验
adapters/memory-integration.node.test.tsNode用内存 adapter 端到端跑 syncMode: 'storage-authority' + persistence: 'session' | 'persistent' 全链路,替代一大半浏览器集成测试
drivers/local.node.test.tsNodeLocalLockDriver 纯逻辑:同实例内互斥排队、acquire/release、hold 超时触发 revoke
drivers/custom.node.test.tsNodeadapters.getLock 覆盖默认 driver;同步 / 异步 release;onRevokedByDriver 桥接到 listeners.onRevoked('force')
drivers/web-locks.browser.test.tsBrowsernavigator.locks 并发排队(FIFO);acquireTimeout / holdTimeout / force 抢占 + onRevoked('force')
drivers/broadcast.browser.test.tsBrowser(mock navigator.locks = undefined心跳丢失后队列晋升;并发抢锁去重;force 抢占
drivers/storage.browser.test.tsBrowser(再 mock BroadcastChannel = undefinedstorage 事件跨 Tab 模拟;同 Tab 多实例本地补发排队;并发竞态下的 token 重试兜底
authority/main.browser.test.tsBrowsersyncMode: 'none' | 'storage-authority' 语义;rev-first 字段顺序;onSync 触发;lazy parse 快路径;rev 乱序丢弃;不可克隆值 / QuotaExceededError / localStorage 不可用的降级
authority/lifecycle.browser.test.tsBrowserpageshow(e.persisted) / visibilitychange 激活时 pull;bfcache 场景;dispose 后解绑
authority/persistence.browser.test.tsBrowserpersistence: 'session' | 'persistent' 三类启动场景(首个 Tab / 同会话组新开 / 刷新)+ 残留清空 + epoch 不匹配跳过 parse + 降级(sessionStorage / BroadcastChannel 不可用)
integration/actions-local.node.test.tsNodeidlockData 同步;update / replace / read / dispose / getLock 行为;listeners.onLockStateChange 状态机流转
integration/options-validate.node.test.tsNodetimeout / listeners / ActionCallOptions 合法值与非法值校验;getValue 同步 / 异步 / reject 各路径;NEVER_TIMEOUT 不注册定时器

每个文件的细粒度用例清单 + 断言快照在实施阶段随代码一并产出到 __test__/README.md,不在 RFC 中穷举。

风险与取舍

风险说明取舍
非浏览器环境(Node/SSR)没有 navigator.locks / BroadcastChannel / localStorageid 模式下降级为 LocalLockDriver + logger warn;syncMode: 'storage-authority'entry.authority === null,同步失效,维持同进程共享语义
localStorage 不可用(隐身 / 禁用 storage)StorageAuthority 无法读写entry.authority === null + logger.warn,跨 Tab 同步失效但单 Tab 功能不受影响;所有跨 Tab 的 onSync 均不会触发
后台 Tab / bfcache / freeze 期间错过 storage 事件窗口长期不可见或被冻结时无法收到 pushpageshow / visibilitychange 切回 visible 时主动 getItem + readIfNewer,自动补齐最新值
localStorage 写入超配额(QuotaExceededErrorsnapshot 过大或浏览器配额紧张logger.warn 提示 + 本地 commit 保持成功(本 Tab 的 entry.dataRef.current 已更新);跨 Tab 同步本次失效,下次 commit 重试覆盖
force 抢占导致数据不一致原持有者未完成事务就被驱逐事务式 Draft 兜底:working copy 自动回滚,新 holder 永远拿到上一次成功 commit 的完整状态;业务层可用 listeners.onRevoked 做补偿
mutation log rollback 的开销每次 set 需记录"旧值" + revoked 时回放只记录实际写入路径,未触碰路径无开销;对大对象的浅路径写入无压力;极端场景可由业务通过 snapshot() 自行做 snapshot-compare
同 id 单例的 options 冲突后续调用传入不一致的配置首次注册为准 + logger.warn;listeners 独立保留不冲突
同 id 单例的 data 污染不同模块对同一 id 调用但期望不同初始值设计上"同 id = 同一份逻辑数据",后续传入的 getValue 不再被调用;业务应自己约定 id 命名空间
心跳间隔的取舍过短耗电,过长响应慢默认 200ms 心跳 + 3 次丢失判死,允许通过 constants 覆盖
holdTimeout 误伤长事务异步 recipe 耗时超过 5000ms 会被中断用户可在每次 action 调用时覆盖;文档明示默认 5000;也可用 NEVER_TIMEOUT + signal 交由业务自行控制
releasedispose 语义混淆用户不清楚区别可能:① 长生命周期场景误用 dispose 导致后续 action 全部 reject;② 短事务场景误用 release 导致 actions 实例泄漏(Entry 引用计数不归零)API 文档明示两者职责分工(release 还锁 + 实例可复用 / dispose 还锁 + 销毁实例 + 引用计数-1);附录 B 同时提供两种场景示例;disposed 终态下 release 调用也 reject 防止误操作
syncMode: 'storage-authority' 的一致性localStorage 是单点权威,读写顺序依赖锁串行化;写入方 Tab 不会收到自己的 storage 事件(规范)通过 rev 单调序号 + lastAppliedRev 去重;acquire 时 pull + storage 事件 push + 激活时 pull 三条路径协同,保证"拿到锁 = 拿到最新值"rev 乱序到达时直接丢弃旧值不会回退
StorageAuthoritygetItem / JSON.parse 开销每次 acquire + 每次 storage 事件都要读 localStoragelocalStorage.getItem 亚毫秒级;lazy parse 快路径:按固化字段顺序用 extractRev 正则提取 rev,rev <= lastAppliedRev 时完全不 JSON.parse snapshot,绝大多数事件命中快路径
localStorage value 格式契约序列化顺序必须固化为 rev → ts → epoch → snapshot,否则 extractRev / extractEpoch 失效serialize 函数手动拼接,不走 JSON.stringify 对象;extractRev 失败时走全量 JSON.parse 兜底,保证向后兼容
persistence: 'session' 首次启动的 100ms 阻塞session-probe 探测窗口会推迟首个 Tab 的 lockData Promise resolve首次启动(sessionStorage 无 epoch 时)阻塞,默认 100ms;刷新 / bfcache 恢复直接继承 sessionStorage 跳过探测;可通过 sessionProbeTimeout 调整
persistence: 'session' 的"会话组"判定依赖 BroadcastChannel不可用时无法识别"同会话组新开 Tab"降级为"首个 Tab"处理(清空 localStorage + 新 epoch)+ logger.warn;每个 Tab 各自独立但 localStorage 仍是权威,不影响数据正确性
sessionStorage 不可用极罕见,但隐身模式部分浏览器会禁用自动降级 persistence: 'session''persistent' + logger.warn,跨会话恢复但不会丢数据
session-probe 的消息竞态多个 Tab 同时启动时相互探测,可能都收到响应都不做清空正确行为:它们会收敛到最早启动 Tab 的 epoch(其他 Tab 探测时已有响应);即便出现 2 个 Tab 互相都是"首个",各自生成不同 epoch 也只有一方 commit 能被对方接受,readIfNewer epoch 校验会自动丢弃另一方的写入 → 最多损失一次 commit(由用户重试)
resolveEpoch 的 TOCTOU 窗口sessionStorage.getItem → 判空 → broadcast probe 之间,理论上另一 Tab 可能刚好 commit 新 epoch实际概率极低(同 Tab 主线程执行窗口 <1ms);即便发生,新 epoch 会通过 session-probe 响应或后续 storage 事件收敛,最多表现为短暂读到旧 epoch 的残留权威副本(rev/epoch 校验自动丢弃),不影响数据正确性
localStorage value 清理时机persistence: 'session' 下 Tab 关闭不会立即清 localStorage,只在下次首个 Tab 启动时清可接受:浏览器未打开期间残留不影响任何运行时;避免依赖不可靠的 beforeunload
队列公平性BroadcastDriver / StorageDriver 无法做到严格原子 FIFO允许极小概率并发抢锁;用 token 比较 + 重试兜底
AbortSignal.any 兼容性较老环境缺失 AbortSignal.any通过 core/signal.ts 的统一封装做 polyfill
适配器语义契约依赖用户自律用户注入的 AuthorityAdapter.subscribe 若不按"写入 / 删除均触发"实现,跨进程同步会失效;ChannelAdapter.postMessage 若丢消息,session-probe 会误判为"首个 Tab"RFC 以接口 JSDoc 为准;内部不做行为探测;提供 MemoryAdapter 参考实现给测试与用户作对照;建议用户用 __test__/adapters/memory-integration.node.test.ts 的用例集验证自己的实现
跨 Tab adapter 语义等价契约同一 id 的多个 Tab 必须使用语义等价(能互相看到写入、互相收到订阅)的 authority / channel adapter,否则权威副本各自独立、同步失效内部存储格式(rev → ts → epoch → snapshot)由 serialize 固化,不开放给 adapter;用户只需保证 "A 写 B 能读 / A 发 B 能收",codec 由内部保证;跨不同底层(如 A 用 localStorage、B 用 IndexedDB)不互通为预期行为
默认实现与自定义 adapter 混用一个 Tab 不传 adapters(走默认 localStorage)、另一个 Tab 注入自定义 Electron store,二者不互通预期行为:同 id 的所有进程应采用一致策略;文档明示"要么全用默认,要么全用同一套自定义 adapter";不做运行时检测

后续扩展(非本期)

  • 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

公开决策记录

#决策结论
1API 形态[readonly, actions] 元组
2初始化时机lockData 初始化恒同步,不抢锁;getValue 返回 Promise 或 syncMode === 'storage-authority' 时返回 Promise(后者需在 resolve 前完成 localStorage 首次 pull)
3抢锁时机actions.update / replace / getLock 时抢锁
4只读深只读强制执行,无开关
5底层技术栈Web Locks(首选)→ BroadcastChannel(降级)→ localStorage(兜底);adapters.getLock 存在时交由用户自定义
6force 归属从 options 移到 ActionCallOptions,按调用传递
7timeout 语义options 层默认 5000;action 调用可拆 acquireTimeout / holdTimeout 覆盖;支持 NEVER_TIMEOUT 永不超时
8getValue可同步可异步,返回值决定 lockData 是否为 Promise;异步期间抢锁为"等待"语义
9options.getLock已被 #30 移入 adapters.getLock自定义锁驱动入口,覆盖默认能力检测;不再承担观察回调职责(语义不变,仅字段位置收敛到 adapters
10事件回调收敛到 options.listenersonLockStateChange / onRevoked / onCommit / onSynconCommit 由 #21 补入)
11NEVER_TIMEOUT导出的 unique symbol,可用在 timeout / acquireTimeout / holdTimeout 任意位置
12syncMode语义收敛为"仅跨进程同步";同进程同 id 始终共享;本期提供 'none' | 'storage-authority',CRDT 留给未来;非 'none'lockData 返回 Promise(被 #28 最终定稿)
13actions.getLock提供手动抢锁接口用于多步事务
14测试文件*.node.test.ts(纯逻辑)+ *.browser.test.ts(浏览器 API)拆分存放于 __test__/,按源码结构镜像分为 core/ / adapters/ / drivers/ / authority/ / integration/ 五个子目录
15文档位置src/shared/lock-data/RFC.md
16同 id 单例进程内 InstanceRegistry 以 id 作 key,共享 data 引用与 driver;首次注册的 options 为准,冲突字段 logger.warn;listeners 不冲突,多实例 fanout;引用计数归零时销毁 Entry
17AbortSignal 支持LockDataOptions.signal 控制实例生命周期(abort == dispose);ActionCallOptions.signal 控制本次调用;两者"与"组合;新增 LockAbortedError
18事务式 Draft每次 update 内部建立 working copy + mutation log + validityRef;recipe 成功则视为 commit(已写入 data),recipe 失败/revoked/aborted 时按 mutation log 回滚到 recipe 开始前的状态;replace 走同样事务
19revoke 后 draft 行为LockRevokedError;闭包里的 draft 永久失效;同步 / 异步 recipe 行为一致
20Draft 实现不引入 immer 依赖,自研轻量可写代理(Proxy + validityRef + mutation log);体积最小、可控性最高,且与 ReadonlyView 共享 WeakMap<object, Proxy> 缓存策略
21listeners.onCommit向用户暴露 mutation logsnapshot;仅 commit 成功时触发;mutations 深冻结,禁止外部 mutate;典型用途:审计、埋点、持久化、派生状态
22dataReadyPromise 共享同 id 场景下由 Entry 统一持有;后续实例共享同一 Promise,不重复触发 getValue'failed' 终态使所有共享实例一并进入 LockDisposedError
23syncStrategy已被 #27 作废曾作为 'on-acquire' | 'broadcast-only' 的 acquire 握手一致性开关;引入 localStorage 权威副本后,其全部职责(acquire 时同步最新值、首个 holder 兜底)由 StorageAuthority 覆盖,字段随 #27 一并删除
24权威单调序号 rev引入 Entry.rev / Entry.lastAppliedRev采用递增整数而非时间戳Date.now() 可跨 Tab 回退,performance.now() 不同 Tab origin time 不同无法比较);commit 成功时 rev++,写入权威副本;跨 Tab 以 rev 大者为最新;无 id 场景下 rev 也维护(仅作本 Tab 单调计数,不参与持久化)
25StorageAuthority(localStorage 权威副本)localStorage 作为跨 Tab 数据同步的单点权威(key = ${LOCK_PREFIX}:${id}:latest);写路径:commit 后同步 setItem;读路径三条:acquire 时同步 getItem + storage 事件 push + pageshow/visibilitychange 主动 pull;写入方 Tab 不触发自己的 storage 事件(规范),通过 lastAppliedRev 做额外防御
26JSON 字符串存储 + Lazy ParselocalStorage value 固化格式 {"rev":N,"ts":T,"snapshot":...}字段顺序契约rev 必须首位;手动拼接而非 JSON.stringify(obj)(规范不保证字段顺序);读路径先用 extractRev 正则提取 rev 做 lastAppliedRev 比较,命中"过时"时完全跳过 JSON.parse(snapshot)extractRev 失败则走全量 parse 兜底。曾评估的替代方案:双层 JSON(外层对象 + snapshot 预先 JSON.stringify 成字符串字段)被否决,理由:① 写入需 2 次 stringify + 内层整串转义,体积膨胀 5-15%、更易撞 QuotaExceededError;② 读取 rev 必须先 JSON.parse 外层(复杂度 O(整串长度)),已等价甚至高于单层全量 parse,彻底丢失"快路径";③ 应用时还要再 parse 内层,成本翻倍。单层 JSON + 正则 extractRev 才是真正的 lazy(快路径 O(常数)
27删除 syncStrategy / acquireSyncTimeout / broadcast 握手协议localStorage 权威副本取代了 sync-request / sync-response 握手;syncStrategy: 'broadcast-only'storage 事件 push 语义重复故删除;acquireSyncTimeout 不再需要(getItem 同步 + 亚毫秒);phase: 'syncing' 状态机从 onLockStateChange 中移除;首个 holder 判定问题自然消失(localStorage 无该 key 即首个)
28syncMode 枚举重命名'holder-broadcast''storage-authority',名字语义对齐实现(localStorage 权威副本而非单向 broadcast);RFC 未发布,非 breaking
29persistence 字段 + epoch 探测新增 LockDataOptions.persistence: 'session' | 'persistent'默认 'session'(符合"协作仅在多 Tab 活跃期"的直觉);'session' 通过 sessionStorage.${LOCK_PREFIX}:${id}:epoch + BroadcastChannel('${LOCK_PREFIX}:${id}:session')session-probe / session-reply 协议实现"所有 Tab 关闭即重置";首次启动探测超时默认 sessionProbeTimeout: 100ms;localStorage 存储格式扩展为 {"rev":N,"ts":T,"epoch":"xxx","snapshot":...}(rev 仍首位兼容 lazy parse,新增 extractEpoch 快路径 epoch 过滤);首个 Tab 判定为"所有 Tab 关闭后重启"时主动 removeItem 清空 localStorage 权威副本'persistent' 固定 epoch 为常量 'persistent'、不做探测,保留跨会话持久化能力;sessionStorage 不可用时 'session' 降级为 'persistent' + logger.warn
30依赖倒置聚合到 options.adapters所有可外部化的环境依赖(锁驱动 / 权威副本 / 广播通道 / 会话存储 / 日志)收敛到 options.adapters 单一入口,替代原先平铺在 options 顶层的 getLock;每个字段可独立注入,缺省走默认实现(pickDefaultAdapters 组合)。接口风格:涉及 id 作用域的依赖(getLock / getAuthority / getChannel / getSessionStore)用工厂函数 getXxx(ctx) => Adapter,与原 getLock 对齐;无作用域的依赖(logger)直接传实例。设计原则:① 单一入口减少 options 表面积;② 用户提供 > 默认实现 > null(触发降级);③ 存储格式 codec 由内部固化不开放(跨 Tab 语义对齐由用户保证"A 写 B 能读");④ 语义正确性由提供方负责,内部不做行为探测。收益:彻底支持非浏览器环境(Node / SSR / RN / Electron / Worker);单元测试可用内存 adapter 在 Node 跑完整链路,大幅精简浏览器集成测试。兼容性:RFC 未发布,顶层 getLock 移入 adapters.getLock 非 breaking;决策 #9 相关描述更新但语义不变。克隆策略:本期不再提供 adapters.clone,所有快照派生使用 JSON.parse(JSON.stringify(...)) 固化语义;getValue / replace 入口由 assertJsonSafe fail-fast,确保 dataRef.current 永远 JSON 安全
31release / dispose 职责分离新增 actions.release(): void,仅处理"还锁"语义(release 底层锁 + 清理 holdTimeout + state 回 idle),不碰 InstanceRegistry 引用计数、不解绑订阅,actions 仍可继续使用。动机:原先只有 dispose 既负责还锁又负责销毁实例,语义过载;对长生命周期场景(如 React 组件内多次交互)用户容易误用(以为 dispose 只是还锁,导致后续 action 全部 reject LockDisposedError)。API 对称性getLockrelease(同级别操作)、lockDatadispose(整个生命周期)。返回类型release 始终同步 void(底层 driver 的 Promise release 内部 fire-and-forget,错误走 logger.warn);dispose 返回规则不变。幂等性:重复 release no-op;disposed 终态下 release 同样 reject LockDisposedError(与其他 action 一致)。兼容性:RFC 未发布,纯新增 API 非 breaking;附录 B「多步事务」补长生命周期用法示例
32事务式 Draft 暂不外部化(方向 A)决议:事务式 Draft(Proxy + mutation log + validity + 原地 rollback)具备作为通用 shared/transactional-draft 工具的价值(与 immer "产出新对象"语义差异化定位为"原地修改 + 失败回滚"),但本期不预先抽离,在 core/draft.ts 中保持 self-contained 实现。理由:① 当前仅有 lock-data 一个使用者,过早抽象易导致 API 设计失真;② 仓内暂无第二个确切复用场景(表单草稿、乐观更新、编辑器临时操作、配置热更新等均为潜在而非既定需求);③ 迁移成本低(核心仅约 80 行),未来抽离时 lock-data 可通过薄适配层复用,不阻塞后续演进。操作:① 在 core/draft.ts 源文件顶部加迁移注释指向本 RFC「外部化前瞻」小节;② RFC 预留 shared/transactional-draft 的通用化 API 骨架(createTransaction / Transaction / Mutation)+ 需要补齐的通用化能力清单(prevValue 暴露、Map/Set 支持、手动 commit/rollback、可选 nested);③ 设定明确的抽离触发条件(至少一个新的具体复用场景 / 外部仓反馈 + 场景交集 ≥ 70% / 能清晰区别 immer 且体积保持 ~1KB)。兼容性:RFC 未发布,纯内部决策,零 API 面变更
33API 单参数化 + wrapper Proxy 引用稳定契约重构(协议级 breaking统一决策:把"API 形态简化、引用稳定契约重构、JSON-only 隔离、状态机简化"打包为同一波 breaking 变更,避免分多次破坏外部使用方。A. API 单参数化 + 条件类型推断lockData(initial, options) 三重载 → lockData(options) 单签名;getValue 升为必传(数据源唯一入口);签名形态为 function lockData<const O extends LockDataOptions<unknown>>(options: O): LockDataResolveReturn<O>TO['getValue'] 反推(调用方无需显式传任何泛型):① LockDataInfer<O> = O extends { getValue: () => infer R } ? Awaited<R> & object : never 把同步 / 异步的 getValue 返回值统一 Awaited;② LockDataResolveReturn<O>LockDataValueShape<LockDataInfer<O>>never 时直接返回 never(顶层数组类型层 fail-fast);③ LockDataReturn<T, O> 三层条件分支与运行时判定优先级严格对齐:O extends { syncMode: 'storage-authority' } 时进一步 O extends { id: string } ? Promise<LockDataTuple<T>> : never最严格:缺 id 编译期推为 never,避免运行时静默 fallback 到 'none'),否则按 getValue 返回值是否为 Promise 决定同步元组 / 异步 Promise。约束放宽规避循环推断O extends LockDataOptions<unknown> 仅约束最弱形状,避免 O 的约束依赖 LockDataInfer<O>LockDataInfer<O> 又依赖 O 的循环(具体 T 反推与顶层数组校验都在返回值类型层 fail-fast);LockDataReturn<T, O> 第二参数约束为 object 而非 LockDataOptions<X>,避免 LockDataListeners.onCommit(event: CommitEvent<T>) 的逆变位置导致 T 双向不变阻断协变兜底(条件分支只关心 O 是否包含 syncMode / id / getValue 字段,无需 OLockDataOptions 的子类型)。LockDataValueShapeT extends readonly unknown[] ? never : Treadonly 覆盖 string[] / ReadonlyArray<X> / 元组等所有数组形态);运行时入口 Array.isArray(awaited) 二次 fail-fast 兜底 as unknown 绕过。收益(条件类型推断):① 调用方写 lockData({ getValue: () => ({ count: 0 }) }) 直接得到 LockDataTuple<{count:number}>(同步元组);写 lockData({ getValue: () => Promise.resolve({...}) }) 直接得到 Promise<LockDataTuple<...>>,无需 as 断言或 instanceof Promise 分支;② 调用方传错 syncMode='storage-authority' 但漏写 id 时直接编译期 never,IDE 立即报错;③ 测试中 expectTypeOf(result).toEqualTypeOf<LockDataTuple<T>>()expectTypeOf(result).toEqualTypeOf<Promise<LockDataTuple<T>>>() 两个分支精确分离,旧版 `LockDataTuple

开放问题

  • 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)