@codejoo/storage
English | 简体中文
对 localStorage / sessionStorage / IndexedDB 的轻量、类型安全封装,统一一套 API:TTL 与绝对过期、滑动续期、命名空间、可插拔序列化(含 Date / Map / Set / bigint)、可选混淆 codec、按需开启的内存缓存,以及绑定 key 的快捷访问器。同步后端返回值,异步的 IndexedDB 后端返回 Promise —— 由泛型区分,一套实现。
- 零依赖 · ESM ·
sideEffects: false· 附带按模块拆分、可 tree-shake 的产物(dist/esm/)。 - 原生 API 不可用时(隐私模式、沙箱 iframe 等)自动回退到内存存储。
安装
pnpm add @codejoo/storage快速开始
import { factory } from "@codejoo/storage";
const { ls, ss } = factory();
ls.set("token", "abc"); // localStorage
ls.get("token"); // "abc"
ls.get("missing", "default"); // "default"
ls.set("session", 1, 60_000); // 60s 后过期(ttl 毫秒)
ls.remove("token");
ls.clear();
ls.length; // 条目数使用 IndexedDB(异步、容量大)。Idb 默认不打包,需自行导入:
import { factory, Idb } from "@codejoo/storage";
const { db } = factory({ db: new Idb() });
await db.set("user", { id: 1 }); // Promise<void>
await db.get("user"); // Promise<{ id: 1 }>API
factory(options?)
返回 { ls, ss, db, destroy, setNamespace },分别是对 localStorage、sessionStorage 和传入的 IndexedDB 实例的处理器。ls/ss 为同步,db 为异步(返回 Promise)。三者共享同一套选项行为。destroy() 一次性释放所有层(清空各层 memo 读缓存并断开 db 的 IndexedDB 连接),返回 Promise;不删除已落盘数据。setNamespace(username?) 原地切换三层前缀(适合按账号隔离、登入/登出时调用)——已持有的引用自动生效;它只做隔离,不会清除上个命名空间的落盘数据。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
options |
BaseStorageOptions |
否 | {} |
实例级配置,应用于所有层。 |
BaseStorageOptions
| 选项 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
memoized |
boolean |
否 | false |
启用内存读缓存:写入双写、读取缓存优先、删除双删。按需开启(非全量镜像),内存随使用增长。 |
cloned |
boolean |
否 | false |
与 memo 缓存共享的对象按深拷贝(structuredClone)返回,隔离调用方修改对缓存的污染;默认共享引用(零开销)。 |
serialize |
(entity: StorageEntity) => string |
否 | JSON.stringify |
自定义 entity → 字符串序列化。 |
deserialize |
(raw: string) => StorageEntity |
否 | JSON.parse |
自定义字符串 → entity 反序列化(需与 serialize 配对)。 |
codeable |
boolean |
否 | false |
是否调用 codec。便于按环境(开发/生产)开关编解码。 |
codec |
Codec |
否 | — | 对序列化字符串做编解码(混淆/压缩)。仅 codeable 为 true 时生效。 |
sliding |
boolean |
否 | false |
滑动过期:每次读命中按原始 ttl 续期(适合会话/登录态)。剩余寿命超过 90% 时跳过续期回写,高频读不产生写放大。 |
namespace |
string |
否 | "" |
key 前缀(namespace:key),隔离同源下不同应用/模块。 |
raw |
boolean |
否 | false |
直接存原始值,跳过 entity 信封(无 ttl/codec)。用于与外部数据互通。 |
force |
boolean |
否 | true |
容量不足时清理过期项后重试写入,否则记录日志并放弃。仅同步后端生效。 |
readonly |
boolean |
否 | false |
只写一次:仅当键为空(不存在/已过期)才写入,否则丢弃本次写入。 |
enckey |
boolean |
否 | false |
是否对键也混淆:配置了 codec 时,存储键经 codec 确定性混淆(隐藏明文键名)。仅用 codec 混淆键名、非安全防护;且需配合 codec,否则告警并降级为明文键。 |
onError |
(info: { op: "set"; key: string; error: unknown }) => void |
否 | — | 写入失败回调(配额超限、force 重试仍失败等)。提供时取代默认的 console.error,使调用方可感知失败(set 返回 void,失败本不可见)。批量 set 下每个失败键各回调一次。 |
db |
AsyncStorage |
否 | — | IndexedDB 实例(如 new Idb()),暴露为 factory().db。未传却使用 db 会抛错提示。 |
处理器方法(ls / ss / db)
R<T> 在同步后端(ls/ss)为 T,在异步后端(db)为 Promise<T>。
| 方法 | 返回 | 说明 |
|---|---|---|
get<T>(key) |
R<T | null> |
读取;不存在 → null。 |
get(key, defaultValue) |
R<T> |
读取;不存在/已过期/解不开 → defaultValue。 |
set(key, value, ttl?) |
R<void> |
写入;ttl 毫秒。非法 ttl(0 / 负数 / NaN / Infinity)告警并忽略——数据照常持久化(不会写入即被删,也不会变永不过期)。 |
set(key, value, options?) |
R<void> |
写入;StorageOptions(ttl / expireAt / memoized)。开启 memo 现仅能经对象传入(set(k, v, { memoized: true }))。 |
remove(key) |
R<void> |
删除(缓存 + 后端)。 |
get(keys, defaults?) |
R<元组> |
批量读取:传键数组,返回等长元组;defaults 逐位对应并逐位联动类型(get(["a","b"],[1,false]) → [number, boolean];as const 保留字面量)。 |
set(keys, values, options?) |
R<void> |
批量写入:逐位配对;第三参对全部键生效。values 偏短时缺位键跳过(告警)。 |
remove(keys) |
R<void> |
批量删除。批量 get/set/remove 在键数组上逐键复用单键逻辑实现(异步后端为逐键一事务)。 |
keys() |
R<string[]> |
本实例管辖范围内的全部逻辑键(已解密、去命名空间前缀)。 |
purge() |
R<void> |
主动清理过期条目(仅管辖内、本库写入的数据)。平时为惰性过期,长期不被读取的过期数据靠它回收配额。 |
clear() |
R<void> |
配置了 namespace 或 enckey 时仅清本实例管辖的键(不波及其他命名空间/外部数据);否则整库清空。 |
destroy() |
R<void> |
释放资源:清空 memo 读缓存并断开可关闭后端(IndexedDB 连接)。保留已落盘数据。 |
key(index) |
R<string|null> |
第 index 个逻辑键(已解密、去命名空间前缀)。 |
length |
R<number> |
条目数(getter)。配置 namespace 或 enckey 时只数本实例管辖的键(与 keys()/clear() 作用域一致);否则返回后端全局条目数。 |
namespace |
string |
命名空间前缀(形如 "ns:",无则为 "")。 |
setNamespace(ns?) |
void |
原地切换前缀(如按 username 隔离账号);清空 memo 读缓存,已持有的引用自动生效。 |
StorageOptions(set 的按次选项)
仅以下三项按次生效(codec / sliding / raw 等其余配置均为实例级,见 BaseStorageOptions):
| 选项 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
ttl |
number |
否 | — | 存活时间(毫秒,相对)。设置 expireAt = now + ttl。非法值(0 / 负数 / NaN / Infinity)告警并忽略,数据照常持久化且不带过期。 |
expireAt |
number | string | Date |
否 | — | 绝对过期(时间戳/日期字符串/Date)。若早于当前且无法按 sliding + ttl 续期,告警并放弃写入。 |
memoized |
boolean |
否 | — | 本次写入是否同步存入 memo 读缓存(覆盖实例级 memoized)。 |
fast(target, key)
绑定一个处理器和一个 key,返回 { get, set, remove },免去反复写 key。同步/异步返回类型跟随 target。值类型在 fast<V>(...) 指定一次即可。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
target |
ls / ss / db 处理器 |
是 | — | factory() 返回的处理器。 |
key |
string |
是 | — | 要绑定的键。 |
const token = fast<string>(ls, "token");
token.set("abc"); // 值必须是 string
token.get(); // string | null
token.get("def"); // string
token.remove();访问器形态 —— SyncAccessor<V>(同步)/ AsyncAccessor<V>(异步):
| 方法 | 返回 | 说明 |
|---|---|---|
get() |
R<V | null> |
读取。 |
get(defaultValue) |
R<V> |
带默认值读取。 |
set(value, options?) |
R<void> |
写入;options = ttl/memoized/选项。 |
remove() |
R<void> |
删除。 |
lazy(target, key)
与 fast 类似,但返回一个 getter:首次调用才创建访问器并缓存。配合 /*#__PURE__*/ 注释,未使用的导出会被 tree-shake —— 适合在集中式 cache.ts 里登记大量 key。
export const token = /*#__PURE__*/ lazy<string>(ls, "token");
token().get(); // 首次使用才创建,之后复用batchFast(target, keys)
一次绑定多个 key;返回以各 key 为属性名的对象,每个属性是对应 key 的快捷访问器(键名通过 const 泛型保留;值类型 V 对所有 key 统一,默认 unknown)。
const { token, user } = batchFast(ls, ["token", "user"]);
token.set("abc");
user.get();JSONX
与 JSON 同名 API,额外可逆地支持 bigint / Date / Map / Set。方法不依赖 this,可直接作为 serialize/deserialize 传入。
| 方法 | 返回 | 说明 |
|---|---|---|
JSONX.stringify(value, space?) |
string |
序列化,保留富类型。 |
JSONX.parse(text) |
any |
反序列化,还原富类型。 |
const { ls } = factory({ serialize: JSONX.stringify, deserialize: JSONX.parse });
ls.set("x", { when: new Date(), ids: new Set([1n, 2n]) }); // 完整还原不支持循环引用(沿用
JSON.stringify行为 —— 会抛错)。
混淆 codec —— codec / codecBase64 / codecAtob
三个轻量混淆 codec(避免明文直接暴露在 devtools——不是强加密,password 随包发布)。均接受可选 password(不传用内置默认值);改 password 后旧数据无法解出——decode 返回 null,读取时按损坏清除。配合 { codeable: true, codec: codec("pw") } 使用。
| 导出 | 方案 | 适用 |
|---|---|---|
codec(password?)(默认) |
UTF-16 码元 10 位 XOR(零分支),输出 = 原文 + 1 码元 | 体积最优(零膨胀——中文配额仅为 base64 系 1/3)、延迟最低、无运行时要求 |
codecBase64(password?) |
原生 Uint8Array.toBase64(base64url、无 padding、旋转),旧运行时自动回退 atob/btoa(同格式) |
大体量 ASCII 吞吐最高(原生 SIMD);体积 +33%(中文 3 倍) |
codecAtob(password?) |
全程 TextEncoder + atob/btoa |
行为处处一致(无特性检测);与 codecBase64 同格式、可互解 |
Codec 形态:
| 方法 | 返回 | 说明 |
|---|---|---|
encode(value) |
string |
混淆字符串。 |
decode(value) |
string | null |
还原;密钥不符/损坏时返回 null(不抛错)。 |
Idb(name?)
基于 IndexedDB 的异步 Storage 风格后端。不维护全量内存镜像(内存恒定、利于 GC)。传给 factory({ db })。IndexedDB 不可用或运行时 open() 失败时自动回退内存。
| 参数 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
name |
string |
否 | "@codejoo/storage" |
IndexedDB 数据库名。 |
方法(均返回 Promise):get(key)、set(key, value)、remove(key)、clear()、key(index)、keys()、length()、destroy()(关闭连接;保留数据)。处理器层的批量操作在这些单键方法上循环实现(异步后端逐键一事务)——不再有 getMany/setMany/removeMany 批量原语。
crossTab(handler, channel?)
独立插件(未用到即被 tree-shake)。仅在纯内存模式(原生 storage 不可用:隐私模式、沙箱 iframe)下启用:经 BroadcastChannel 把 set/remove/clear 回放到同源其他标签页,保持各标签内存数据一致。原生 storage 可用时为空操作(数据本就共享);重复挂载同一 handler 也为空操作。本地写入先于广播生效,广播失败(值不可结构化克隆)仅告警;setNamespace 不参与同步,需各标签自行切换。返回停止函数。
import { factory, crossTab } from "@codejoo/storage";
const { ls } = factory();
const stop = crossTab(ls);debug(handler)
独立辅助函数,经独立子路径(@codejoo/storage/debug)发布——不在主入口中,单文件产物(dist/index.mjs / index.min.js)物理上不含它。读出 handler 全部条目的解密后明文,返回 { "命名空间:键": 值 } 快照(保留命名空间)。它是纯读取、无副作用——不会把快照写回存储,因此不会污染 keys()/length。用于查看以 codeable/enckey 写入的数据。
import { factory, codec } from "@codejoo/storage";
import { debug } from "@codejoo/storage/debug";
const { ls, db } = factory({ codeable: true, codec: codec("pw"), enckey: true });
debug(ls); // 同步 → { "key": value, ... }
await debug(db); // 异步后端 → Promise说明
- 同步 vs 异步 由后端类型经泛型决定:
ls.get(k)返回值,db.get(k)返回Promise。一套 proxy 实现同时服务两者。 db的特性:ttl/expireAt/codec/namespace/sliding/memoized对db同样生效(只是要await)。force容量清理目前仅对同步后端生效。- memo 按
factory()实例隔离:每次factory()调用各自独立的内存读缓存,不同实例互不共享 memo(不会跨实例串读)。 - Tree-shaking:包的
import指向dist/esm/(每模块一个文件)。配合sideEffects: false,未用到的模块/导出会被打包器删除。
与原生 localStorage 的差异
- 默认存的是 entity 信封(
{ value, createdAt, ... }),而非裸字符串。绕过本库直接用原生localStorage.getItem读到的是 JSON 信封,而非你的原始值(raw模式除外)。 set失败不抛配额错(原生会抛QuotaExceededError)。配额满时仅记录日志 / 回调onError后放弃本次写入——需要感知写入失败请用onError。length与clear()受命名空间作用域影响:配置namespace/enckey时仅覆盖本实例管辖的键,与原生的全局语义不同。- 过期是惰性的:过期项不读取也不主动删除,靠
purge()或配额压力回收。
构建产物
| 路径 | 格式 | 用途 |
|---|---|---|
dist/esm/*.mjs |
按模块 ESM | 默认 import,可 tree-shake。 |
dist/index.mjs |
单文件 ESM bundle | 整体引入。 |
dist/index.min.js |
压缩 ESM | ./min 子路径。 |
测试
pnpm test会在真实 Chromium(Playwright,vitest browser 模式)中跑完整集成套件(test/*.browser.test.ts),使用真实的 localStorage / sessionStorage / IndexedDB / BroadcastChannel(非 jsdom 模拟),覆盖同步后端、异步 IDB 事务、跨标签同步等真实浏览器行为。
交互式 playground 仍保留在 test/manual.html:运行 pnpm dev 后打开 /test/manual.html。
许可
MIT