lockData

package version >0.6.0

shadcn any version

author: cmtlyt

update time: 2026/05/11 13:46:00

install

npm
npm i @cmtlyt/lingshu-toolkit
shadcn
npx shadcn@latest add https://cmtlyt.github.io/lingshu-toolkit/r/sharedLockData.json

usage

import { lockData } from '@cmtlyt/lingshu-toolkit/shared'
// or
import { lockData } from '@cmtlyt/lingshu-toolkit/shared/lock-data'

特性

  • 事务式写入:通过 actions.update 传入 recipe 函数,recipe 内部可任意改写草稿,提交时整体成功或整体回滚
  • 深只读视图:返回的 view 是深只读代理,任何直接写入都会抛 ReadonlyMutationError,保证读写边界清晰
  • 跨模块共享:同一 id 的多次调用复用同一份数据,天然适合模块边界解耦
  • 跨 Tab 同步:开启 syncMode: 'storage-authority' 后自动在同源 Tab 间同步,无需手动编排
  • 声明式会话语义persistence: 'session' 下所有 Tab 关闭即重置;'persistent' 跨浏览器重启保留
  • 可插拔适配器:锁驱动、权威副本、通道、会话存储、日志全部可注入,便于测试和定制
  • 严格超时控制:抢锁 / 持锁超时独立控制,NEVER_TIMEOUT 显式表达"永不超时"意图
  • 类型安全:同步 / 异步分支在类型层明确区分,无 any,无隐式 Promise

基础用法

同步初始化

getValue 同步返回且 syncMode'none'(默认)时,lockData 直接返回元组:

import { lockData } from '@cmtlyt/lingshu-toolkit/shared';

interface Counter {
  count: number;
  label: string;
}

const [view, actions] = lockData({ getValue: () => ({ count: 0, label: 'init' }) });

// view 是深只读视图
console.log(view.count); // 0
console.log(view.label); // 'init'

// 事务式写入
await actions.update((draft) => {
  draft.count = 42;
});
console.log(view.count); // 42

// 用完主动销毁
await actions.dispose();

直接写 view 会抛错

import { lockData, ReadonlyMutationError } from '@cmtlyt/lingshu-toolkit/shared';

const [view, actions] = lockData({ getValue: () => ({ count: 0 }) });

// ❌ 运行时抛 ReadonlyMutationError
try {
  (view as { count: number }).count = 999;
} catch (error) {
  console.log(error instanceof ReadonlyMutationError); // true
}

// ✅ 必须通过 actions
await actions.update((draft) => {
  draft.count = 999;
});

await actions.dispose();

整体替换

const [view, actions] = lockData({ getValue: () => ({ count: 0, label: 'init' }) });

// replace 相当于一次隐式事务:整体替换成新对象
await actions.replace({ count: 100, label: 'reset' });
console.log(view.count); // 100
console.log(view.label); // 'reset'

await actions.dispose();

异步初始化

getValue 返回 Promise 时,lockData 整体返回 Promise<元组>

interface User {
  id: string;
  name: string;
}

const [view, actions] = await lockData({
  getValue: () => fetch('/api/user').then((res) => res.json()) as Promise<User>,
});

console.log(view.name); // 从 /api/user 拉到的值

await actions.dispose();

跨模块共享(同 id 复用)

传入 id 后,同一进程内多次 lockData 会复用同一份数据:

// moduleA.ts
const [viewA, actionsA] = lockData({ id: 'shared-counter', getValue: () => ({ counter: 0 }) });
await actionsA.update((draft) => {
  draft.counter = 5;
});

// moduleB.ts(同一进程)
const [viewB, actionsB] = lockData({ id: 'shared-counter', getValue: () => ({ counter: 0 }) });
// 注意:第二次调用的 getValue 不会被执行,viewB 读到的是已有数据
console.log(viewB.counter); // 5

// 两边都 dispose 后,底层数据才真正释放
await actionsA.dispose();
await actionsB.dispose();

高级用法

跨 Tab 同步(storage-authority)

开启 syncMode: 'storage-authority' 后,lockData 返回 Promise<元组>,同源 Tab 间自动同步:

const [view, actions] = await lockData({
  id: 'cross-tab-counter',
  syncMode: 'storage-authority',
  getValue: () => ({ count: 0 }),
});

// Tab A 写入
await actions.update((draft) => {
  draft.count += 1;
});

// Tab B(同 id + 同 syncMode)会通过 listeners.onSync 收到新值

监听数据变更

const [view, actions] = lockData({
  getValue: () => ({ count: 0 }),
  listeners: {
    onCommit: (event) => {
      // event.source: 'update' | 'replace'
      // event.rev: 本次提交后的版本号(单调递增)
      // event.snapshot: 提交成功时的最新快照
      console.log(`commit #${event.rev} by ${event.source}`);
    },
    onRevoked: (event) => {
      // 锁被撤销(抢占 / 超时 / dispose)时触发
      console.log(`revoked: ${event.reason}`);
    },
  },
});

await actions.update((draft) => {
  draft.count = 1;
}); // onCommit rev=1
await actions.update((draft) => {
  draft.count = 2;
}); // onCommit rev=2
await actions.dispose();

手动持锁(多步事务)

getLock + 多次 update + release 可以把多个写操作合并到同一把锁内:

const [view, actions] = lockData({ getValue: () => ({ balance: 100, pending: 0 }) });

// 手动抢锁;获取后 isHolding === true
await actions.getLock();

// 持锁期间的多次 update 不会各自再抢锁
await actions.update((draft) => {
  draft.balance -= 50;
});
await actions.update((draft) => {
  draft.pending += 50;
});

// 手动释放
actions.release();

await actions.dispose();

超时控制

import { lockData, NEVER_TIMEOUT } from '@cmtlyt/lingshu-toolkit/shared';

const [view, actions] = lockData({
  getValue: () => ({ count: 0 }),
  timeout: 3000, // 默认 5000ms;本实例默认改为 3000ms
});

// 单次调用覆盖
await actions.update(
  (draft) => {
    draft.count = 1;
  },
  { acquireTimeout: 1000, holdTimeout: 2000 },
);

// 永不超时(业务自控)
await actions.update(
  (draft) => {
    draft.count = 2;
  },
  { acquireTimeout: NEVER_TIMEOUT },
);

await actions.dispose();

AbortSignal 控制

const controller = new AbortController();

const [view, actions] = lockData({ getValue: () => ({ count: 0 }), signal: controller.signal });

// abort 等价于自动 dispose
controller.abort();
// 之后调用 update / replace 会抛 LockDisposedError

强制抢占

// actions.update 的 callOptions.force 可以强制抢占当前持有者
await actions.update(
  (draft) => {
    draft.count = 999;
  },
  { force: true },
);

错误处理

import {
  lockData,
  LockAbortedError,
  LockDisposedError,
  LockRevokedError,
  LockTimeoutError,
  ReadonlyMutationError,
  InvalidOptionsError,
} from '@cmtlyt/lingshu-toolkit/shared';

const [view, actions] = lockData({ getValue: () => ({ count: 0 }) });

try {
  await actions.update(
    (draft) => {
      draft.count = 1;
    },
    { acquireTimeout: 100 },
  );
} catch (error) {
  if (error instanceof LockTimeoutError) {
    // 抢锁超时 / 持锁超时
  } else if (error instanceof LockAbortedError) {
    // callOptions.signal 被 abort
  } else if (error instanceof LockRevokedError) {
    // 被更高优先级的 force 调用抢占
  } else if (error instanceof LockDisposedError) {
    // dispose 之后的操作
  }
}

await actions.dispose();

API

lockData

单参数 API,返回类型由 options 的组合自动推断(条件类型):

function lockData<const O extends LockDataOptions<unknown>>(options: O): LockDataResolveReturn<O>

返回类型判定规则:

  • syncMode === 'storage-authority'(需配 id: string)→ Promise<LockDataTuple<T>>
  • getValue 返回 Promise<T>Promise<LockDataTuple<T>>
  • 其他 → LockDataTuple<T>(同步返回)

LockDataOptions

属性类型必填默认值描述
getValue() => T | Promise<T>数据来源唯一入口;返回 Promise 时 lockData 整体返回 Promise
idstringundefined锁 id;未传视为"纯本地只读锁",不参与跨模块共享
timeoutnumber | typeof NEVER_TIMEOUT5000默认抢锁 + 持锁超时(毫秒)
mode'auto' | 'web-locks' | 'broadcast' | 'storage''auto'锁驱动选择;'auto' 按能力降级
syncMode'none' | 'storage-authority''none'跨进程同步模式
persistence'session' | 'persistent''session'持久化策略(仅在 syncMode'none' 时生效)
sessionProbeTimeoutnumber100首次启动时探测同会话 Tab 的等待窗口(毫秒)
signalAbortSignalundefined实例级生命周期控制;abort 等价于 dispose
listenersLockDataListeners<T>undefined事件监听器
adaptersLockDataAdapters<T>undefined环境依赖注入入口

返回值

返回深只读视图与 actions 的元组 [ReadonlyView<T>, LockDataActions<T>]

  • getValue 同步返回且 syncMode'none' → 同步返回元组
  • getValue 返回 Promise 或 syncMode: 'storage-authority' → 返回 Promise<元组>

TypeScript 条件类型会从 options 自动推断返回类型,调用方无需显式传任何泛型或手动断言。

初始化失败时:同步路径直接抛 LockDisposedError;异步路径返回的 Promise reject LockDisposedErrorcause 字段携带原始错误)。

LockDataActions

interface LockDataActions<T extends object> {
  readonly isHolding: boolean;
  readonly token: string;
  update: (recipe: (draft: T) => void | Promise<void>, callOptions?: ActionCallOptions) => void | Promise<void>;
  replace: (next: T, callOptions?: ActionCallOptions) => void | Promise<void>;
  snapshot: () => T;
  getLock: (callOptions?: ActionCallOptions) => void | Promise<void>;
  release: () => void;
  dispose: () => Promise<void> | void;
}
方法描述
isHolding当前是否持有锁(只读)
token本实例唯一标识(只读)
update(recipe, callOptions?)事务式写入;recipe 抛错 / 被撤销时整体回滚
replace(next, callOptions?)整体替换为 next;与 update 相同的事务语义
snapshot()不抢锁的 JSON 拷贝隔离读取,返回快照(解决 structuredClone(view) 抛错问题)
getLock(callOptions?)手动抢锁,后续 update 不会重复抢
release()只还锁,不销毁实例;actions 仍可继续使用
dispose()还锁 + 销毁实例;后续 update / replace / getLock 全部抛 LockDisposedError;幂等

ActionCallOptions

属性类型描述
acquireTimeoutnumber | typeof NEVER_TIMEOUT覆盖 options.timeout 的抢锁部分
holdTimeoutnumber | typeof NEVER_TIMEOUT覆盖 options.timeout 的持锁部分
forceboolean强制抢占当前持有者
signalAbortSignal仅影响本次调用的取消信号

LockDataListeners

事件签名触发时机
onLockStateChange(event: { phase; token }) => void锁状态流转
onRevoked(event: { reason; token }) => void锁被撤销('force' / 'timeout' / 'dispose'
onCommit(event: { source; token; rev; mutations; snapshot }) => void事务提交成功;rev 单调递增
onSync(event: { source; rev; snapshot }) => void跨进程同步到达(仅 syncMode'none'

错误类

所有错误类都继承 Error,可用 instanceof 精准判别:

错误类触发场景
InvalidOptionsErroroptions 结构非法(如 listeners 不是对象)
LockAbortedErrorcallOptions.signal 被 abort
LockDisposedErrordispose 之后调用 update / replace / getLock / snapshot,或异步初始化失败
LockRevokedErrorforce: true 的调用抢占
LockTimeoutError抢锁 / 持锁超时
ReadonlyMutationError直接对 view 写入属性

常量

常量类型描述
NEVER_TIMEOUTunique symbol表示"永不超时";用于 timeout / acquireTimeout / holdTimeout

注意事项

⚠️ 直接写 view 会抛错

view 是深只读代理,任何直接赋值(view.count = 1delete view.count)都会抛 ReadonlyMutationError。写入必须经 actions.updateactions.replace

⚠️ 同 id 的后续 getValue 不会被执行

相同 id 的第二次及之后调用 lockData 会复用已有数据,新传入的 getValue 不会被调用。如需重置数据,请先让所有持有者 dispose,或主动 replace

⚠️ 条件类型决定同步 / 异步返回

  • getValue 同步返回且 syncMode'none'(默认)→ 同步返回元组
  • getValue 返回 Promise 或 syncMode: 'storage-authority' → 返回 Promise<元组>

TypeScript 条件类型会从 options 自动推断返回类型,无需手动断言。

⚠️ recipe 内避免同步抛错

actions.update(recipe) 的 recipe 抛错时,所有 draft 上的改动会被整体回滚,update 返回的 Promise 会 reject。业务侧请在 recipe 外层统一 try/catch

⚠️ dispose 后的 actions 不可复用

dispose 是终态:之后调用 update / replace / getLock / snapshot 都会抛 LockDisposedError。如需重用,请重新 lockData

⚠️ storage-authority 的会话语义

syncMode: 'storage-authority' 配合 persistence: 'session'(默认)时,"所有同源 Tab 全部关闭"会触发数据重置;如需跨浏览器重启保留,请显式传 persistence: 'persistent'

🔧 能力检测与降级

  • syncMode: 'storage-authority' 在不支持 localStorage 的环境(SSR / 隐私模式)下会降级为本地共享语义并触发一条 warn 日志
  • persistence: 'session' 在不支持 sessionStorage 的环境下会自动降级为 'persistent'
  • 锁驱动按 web-locks → broadcast → storage 的顺序降级;强制指定 mode 时,若对应能力不可用则抛错