npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@brmtech/cache

v0.1.3

Published

A lightweight framework-agnostic cache client for browser projects.

Readme

@brmtech/cache

@brmtech/cache 是一个轻量、框架无关的前端缓存客户端。它适合缓存接口响应、配置、字典、语言包等浏览器端数据,支持持久化存储、TTL、stale-while-revalidate、相同 key 请求去重、批量失效、容量清理、订阅通知和多种 storage adapter。

它不绑定 Vue、React、Vite、Next.js 或任何请求库。你只需要提供一个返回 Promisefetcher

安装

pnpm add @brmtech/cache

# 或者
npm install @brmtech/cache
yarn add @brmtech/cache

快速开始

import { createCache, indexedDBAdapter } from '@brmtech/cache'

interface GameList {
  list: Array<{ id: string; name: string }>
  total: number
}

const cache = createCache({
  namespace: 'my-app',
  version: '1.0.0',
  storage: indexedDBAdapter({ dbName: 'MY_APP_CACHE' }),
  defaultPolicy: {
    ttl: '30m',
    staleTime: '5m',
    staleWhileRevalidate: true,
    allowStaleOnError: true,
  },
})

export function getGameList(page: number) {
  const key = cache.key('/api/games', { page, size: 20 }, { scope: 'country:br' })

  return cache.wrap<GameList>(
    key,
    () => fetch(`/api/games?page=${page}&size=20`).then(res => res.json()),
    {
      tags: ['game'],
      scope: 'country:br',
      onUpdate: fresh => {
        // stale 后台刷新成功且数据发生变化时触发,可以在这里更新页面状态。
        renderGameList(fresh)
      },
    },
  )
}

这段代码会带来这些行为:

  • 首次调用时执行 fetcher,并把结果写入 IndexedDB。
  • 后续同一个 key 在 TTL 内会优先返回缓存。
  • 数据超过 staleTime 但未超过 ttl 时,默认先返回旧数据,同时后台刷新。
  • 后台刷新拿到新数据后,如果内容发生变化,会触发 onUpdatesubscribe
  • 多个相同 key 的并发 wrap() 调用只会执行一次 fetcher
  • fetcher 失败时,如果存在旧缓存且 allowStaleOnErrortrue,会返回旧缓存兜底。

公开导出

可导入 API

import {
  createCache,
  buildCacheKey,
  memoryAdapter,
  indexedDBAdapter,
  localStorageAdapter,
  sessionStorageAdapter,
  hybridAdapter,
  jsonSerializer,
} from '@brmtech/cache'

| 导出 | 说明 | | --- | --- | | createCache(options?) | 创建缓存客户端,返回 CacheClient。 | | buildCacheKey(path, params?, options?) | 生成稳定 key 的纯函数,返回 { key, path, namespace?, version?, scope? }。 | | memoryAdapter(options?) | 内存 adapter,适合测试、SSR 临时缓存、不需要持久化的场景。 | | indexedDBAdapter(options?) | IndexedDB adapter,浏览器端推荐持久化方案。 | | localStorageAdapter(options?) | localStorage adapter,适合小体积 fallback。 | | sessionStorageAdapter(options?) | sessionStorage adapter,适合当前浏览器会话内缓存。 | | hybridAdapter(adapters, name?) | 组合多个 adapter,读时按顺序查找,写时至少一个成功即成功。 | | jsonSerializer | 默认 JSON serializer,使用 JSON.stringifyJSON.parse。 |

可导入类型

import type {
  CacheClient,
  CacheOptions,
  CachePolicy,
  CacheSetOptions,
  CacheWrapOptions,
  CacheGetOptions,
  CacheKeyOptions,
  CacheCleanupOptions,
  CacheClearOptions,
  CacheEntry,
  CacheEntryFilter,
  CacheStorageAdapter,
  CacheSerializer,
  CacheStats,
  CacheWriteContext,
  CacheFetcher,
  CacheSubscribeListener,
  Duration,
} from '@brmtech/cache'

adapter 的 options 类型会出现在函数参数提示里,但当前入口没有把 MemoryAdapterOptionsIndexedDBAdapterOptionsLocalStorageAdapterOptionsSessionStorageAdapterOptions 作为可直接 import 的命名类型导出。需要复用时可以这样写:

type IDBOptions = Parameters<typeof indexedDBAdapter>[0]

核心概念

| 概念 | 说明 | 示例 | | --- | --- | --- | | key | 缓存唯一标识。短 key 会自动加上当前实例的 namespace/version 前缀。 | home:list | | namespace | 项目或模块隔离维度。clear()cleanup() 默认只处理当前 namespace。 | adminh5 | | version | 数据版本隔离维度,适合放应用版本或构建 hash。 | 1.0.0 | | scope | 用户、租户、地区等业务维度。 | user:123country:br | | tags | 批量失效分组。一个 entry 可以有多个 tag。 | productactivity | | ttl | 最大可用时间,超过后视为 expired。null 表示不设置过期时间。 | 30m | | staleTime | 新鲜时间,超过后视为 stale,但还未 expired。null 表示没有 stale 阶段。 | 5m |

时间线:

updatedAt ------------- staleAt ------------- expiresAt
          fresh                    stale                  expired

| 状态 | get() | getFresh() | wrap() 默认行为 | | --- | --- | --- | --- | | fresh | 返回缓存 | 返回缓存 | 返回缓存 | | stale | 返回缓存 | 返回 null | 返回旧缓存并后台刷新 | | expired | 返回 null | 返回 null | 执行 fetcher,失败时可用旧缓存兜底 |

注意:如果 wrap()staleWhileRevalidatefalse,stale 数据在过期前仍会被直接返回,不会自动刷新。想要强制拉取最新数据,请调用 refresh()

创建缓存实例

import { createCache, indexedDBAdapter } from '@brmtech/cache'

export const cache = createCache({
  namespace: 'my-app',
  version: '1.0.0',
  storage: indexedDBAdapter({
    dbName: 'MY_APP_CACHE',
    storeName: 'entries',
  }),
  memory: {
    enabled: true,
    maxEntries: 100,
  },
  defaultPolicy: {
    ttl: '10m',
    staleTime: '2m',
    staleWhileRevalidate: true,
    allowStaleOnError: true,
    maxEntryBytes: 500 * 1024,
  },
  cleanup: {
    maxEntries: 500,
    maxBytes: 50 * 1024 * 1024,
  },
  touchThrottleMs: 5000,
  deleteExpiredOnGet: false,
})

CacheOptions

| 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | namespace | string | 无 | 当前实例的命名空间。短 key 会解析成 namespace::keynamespace::version::key。 | | version | string | 无 | 当前实例的数据版本。短 key 会带上 version。 | | storage | CacheStorageAdapter | 有 IndexedDB 时用 indexedDBAdapter(),否则用内存 adapter | 主存储层。 | | memory.enabled | boolean | true | 是否启用内存一级缓存。 | | memory.maxEntries | number | 100 | 内存一级缓存最多保留多少条,超过后按 LRU 淘汰。 | | defaultPolicy.ttl | Duration \| null | '5m' | 默认 TTL。 | | defaultPolicy.staleTime | Duration \| null | null | 默认 stale 时间。 | | defaultPolicy.staleWhileRevalidate | boolean | true | wrap() 命中 stale 数据时是否后台刷新。 | | defaultPolicy.allowStaleOnError | boolean | true | fetcher 失败时是否返回旧缓存。 | | defaultPolicy.maxEntryBytes | number | 无 | 单条缓存最大字节数,超过则跳过写入。 | | cleanup.maxEntries | number | 无 | cleanup() 后最多保留多少条未过期数据。 | | cleanup.maxBytes | number | 无 | cleanup() 后最多保留多少字节未过期数据。 | | cleanup.maxEntryBytes | number | 无 | 全局默认单条大小限制,会参与写入策略合并。 | | cleanup.interval | Duration | 无 | 类型中保留的字段,当前核心不会自动按 interval 调度 cleanup()。 | | cleanup.eviction | 'lru' | 无 | 类型中只允许 lru,当前容量清理实际按 LRU 执行。 | | cleanup.quotaThreshold | number | 无 | 类型中保留的字段,当前核心逻辑没有读取它。 | | serializer | CacheSerializer | jsonSerializer | 序列化器。默认要求数据可 JSON 序列化。 | | shouldCache | (value, context) => boolean \| Promise<boolean> | 无 | 写入前判断是否允许缓存,返回 false 会跳过写入。 | | touchThrottleMs | number | 5000 | 命中缓存后写回 lastAccessedAt/hitCount 的节流间隔。 | | deleteExpiredOnGet | boolean | false | get() 发现 expired 数据时是否立即删除。默认保留,便于请求失败时兜底。 |

策略合并优先级从低到高是:内置默认策略、cleanup.maxEntryBytesdefaultPolicy、单次调用 options。

时间参数 Duration

Duration 可以是数字,也可以是带单位的字符串。数字表示毫秒。

type Duration = number | `${number}ms` | `${number}s` | `${number}m` | `${number}h` | `${number}d`

可用示例:

'500ms'
'10s'
'5m'
'2h'
'7d'
60000

字符串格式不合法时会抛出错误或导致写入失败,建议只使用 mssmhd

key 生成

cache.key(path, params?, options?)

生成稳定 key,并在当前实例中记录 path/namespace/version/scope 元信息,方便后续 set()invalidate({ pathPrefix }) 使用。

const key = cache.key('/api/list', { size: 20, page: 1 })
// my-app::1.0.0::/api/list?page=1&size=20

签名:

key(
  path: string,
  params?: Record<string, unknown> | URLSearchParams,
  options?: CacheKeyOptions,
): string

CacheKeyOptions

| 参数 | 类型 | 说明 | | --- | --- | --- | | namespace | string | 覆盖当前实例的 namespace。 | | version | string | 覆盖当前实例的 version。 | | scope | string | 加入 key 的业务维度。 | | keepEmptyString | boolean | 是否保留空字符串参数,默认过滤空字符串。 |

规则:

  • query 参数按参数名排序,同名参数再按值排序。
  • nullundefined 会被忽略。
  • 空字符串默认忽略,传 keepEmptyString: true 后保留。
  • 数组会展开为多个同名 query 参数。
  • 参数名和值会使用 encodeURIComponent 编码。
  • 普通对象不会深度序列化,会走 String(value),不建议把嵌套对象直接放进 params。
  • path 已经带 query 时,新参数会用 & 追加。

buildCacheKey(path, params?, options?)

纯函数版本,只生成 key,不依赖缓存实例。

import { buildCacheKey } from '@brmtech/cache'

const built = buildCacheKey('/api/list', { page: 1 }, {
  namespace: 'my-app',
  version: '1.0.0',
})

console.log(built.key)
console.log(built.path)

返回值:

{
  key: string
  path: string
  namespace?: string
  version?: string
  scope?: string
}

如果你用 buildCacheKey() 生成 key,再直接 cache.set(built.key, value),当前 cache 实例不会自动记住 built.path。需要依赖 pathPrefix 失效时,请使用 cache.key(),或写入时手动传 path: built.path

CacheClient API

cache.set(key, value, options?)

写入一条缓存。

const success = await cache.set('config', config, {
  ttl: '1h',
  tags: ['config'],
  metadata: { source: 'bootstrap' },
})
set<T = unknown>(key: string, value: T, options?: CacheSetOptions): Promise<boolean>

CacheSetOptions

| 参数 | 类型 | 说明 | | --- | --- | --- | | ttl | Duration \| null | 当前 entry 的 TTL。 | | staleTime | Duration \| null | 当前 entry 的 stale 时间。 | | maxEntryBytes | number | 当前 entry 最大字节数,超过会跳过写入。 | | staleWhileRevalidate | boolean | 策略字段,对 wrap() 有意义;单独 set() 不会把这个行为写入 entry。 | | allowStaleOnError | boolean | 策略字段,对 wrap()refresh() 的错误兜底有意义。 | | path | string | 原始资源路径,用于 invalidate({ pathPrefix })。使用 cache.key() 时通常不用手动传。 | | namespace | string | 覆盖 entry 的 namespace。 | | version | string | 覆盖 entry 的 version。 | | scope | string | entry 的业务作用域。 | | tags | string[] | entry 的标签。 | | metadata | Record<string, unknown> | 自定义元信息,会存入 entry。 |

返回 true 表示写入成功。返回 false 表示写入被跳过或失败,例如超过 maxEntryBytesshouldCache 返回 false、序列化失败或 adapter 写入失败。

cache.get(key, options?)

读取未过期缓存。stale 但未 expired 的数据也会返回。

const config = await cache.get<AppConfig>('config')
get<T = unknown>(key: string, options?: CacheGetOptions): Promise<T | null>

| 参数 | 类型 | 说明 | | --- | --- | --- | | allowStale | boolean | 名称是 stale,但当前实现效果是允许返回 expired 缓存。普通 stale 缓存默认就会返回。请谨慎使用。 | | maxAge | Duration | 只接受 updatedAt 距今不超过该时间的数据。超过后返回 null。 |

await cache.get('config', { maxAge: '10m' })
await cache.get('config', { allowStale: true })

cache.getFresh(key, options?)

只读取 fresh 缓存。只要 entry 已经 stale 或 expired,就返回 null

const cached = await cache.getFresh<HomeData>('home:list')
getFresh<T = unknown>(key: string, options?: CacheGetOptions): Promise<T | null>

getFresh() 中只有 maxAge 有实际意义。即使传 allowStale: true,也不会返回 stale 数据。

cache.wrap(key, fetcher, options?)

请求缓存主 API。它会先查缓存,必要时执行 fetcher,成功后写入缓存。

const data = await cache.wrap(
  'home:list',
  () => fetch('/api/home').then(res => res.json()),
  { ttl: '30m', staleTime: '5m', tags: ['home'] },
)
wrap<T = unknown>(
  key: string,
  fetcher: () => Promise<T>,
  options?: CacheWrapOptions<T>,
): Promise<T>

CacheWrapOptions<T> 继承 CacheSetOptions,额外支持:

| 参数 | 类型 | 说明 | | --- | --- | --- | | onUpdate | (value: T) => void | stale 后台刷新成功且数据变化时触发。初次请求、前台刷新和 refresh() 请直接使用返回值。 | | isEqual | (cached: T, fresh: T) => boolean | 判断 stale 后台刷新得到的新值是否和旧值等价。等价时只续期缓存,不触发 onUpdatesubscribe。默认用 serializer 序列化后比较。 |

执行流程:

| 场景 | 行为 | | --- | --- | | fresh 命中 | 直接返回缓存,不执行 fetcher。 | | stale 命中且 staleWhileRevalidate: true | 返回旧缓存,同时后台执行 fetcher 并写入新结果。 | | stale 命中且 staleWhileRevalidate: false | 返回旧缓存,不自动刷新。 | | miss 或 expired | 执行 fetcher,成功后写入并返回新数据。 | | 同 key 并发请求 | 复用同一个进行中的 fetcher Promise。 | | fetcher 失败且有旧缓存、allowStaleOnError: true | 返回旧缓存,包括 expired 旧缓存。 | | fetcher 失败且不允许旧缓存兜底 | 抛出 fetcher 的错误。 |

SWR 更新页面示例:

const data = await cache.wrap(
  'home:list',
  () => fetch('/api/home').then(res => res.json()),
  {
    ttl: '30m',
    staleTime: '5m',
    onUpdate: fresh => setHomeData(fresh),
  },
)

setHomeData(data)

自定义等价判断:

await cache.wrap(
  'home:list',
  () => fetch('/api/home').then(res => res.json()),
  {
    staleTime: '5m',
    isEqual: (cached, fresh) => cached.version === fresh.version,
    onUpdate: fresh => setHomeData(fresh),
  },
)

cache.refresh(key, fetcher, options?)

跳过缓存读取,直接执行 fetcher,并把结果写入缓存。

const fresh = await cache.refresh(
  'home:list',
  () => fetch('/api/home').then(res => res.json()),
  { ttl: '30m', tags: ['home'] },
)
refresh<T = unknown>(key: string, fetcher: () => Promise<T>, options?: CacheSetOptions): Promise<T>

refresh() 适合用户手动刷新、操作成功后重新拉取最新数据。需要注意,refresh()fetcher 失败时也会读取旧缓存作为 fallback;如果不希望这样,传 { allowStaleOnError: false }

cache.delete(key)

删除单条缓存。

await cache.delete('config')

短 key 会按当前实例的 namespace/version 解析。传入 cache.key() 生成的完整 key 时,会直接删除对应 entry。

cache.invalidate(filter)

按条件批量删除缓存,返回删除条数。

await cache.invalidate({ tags: ['activity'] })
await cache.invalidate({ scope: `user:${userId}` })
await cache.invalidate({ pathPrefix: '/api/activity' })
await cache.invalidate({ keyPrefix: 'home:' })
await cache.invalidate({ keys: ['config', 'home:list'] })
invalidate(filter: CacheEntryFilter): Promise<number>

CacheEntryFilter

| 参数 | 类型 | 说明 | | --- | --- | --- | | keys | string[] | 指定 key 删除。短 key 会按当前 namespace/version 解析。 | | keyPrefix | string | 按 key 前缀删除。短前缀会按当前 namespace/version 解析。 | | pathPrefix | string | 按 entry.path 前缀删除,推荐配合 cache.key() 使用。 | | tags | string[] | entry 包含任意一个指定 tag 即命中。 | | namespace | string | 指定 namespace。默认会使用当前实例 namespace。 | | scope | string | 指定业务作用域。 | | version | string | 指定版本。 | | expired | boolean | true 只匹配已过期,false 只匹配未过期。 |

多个条件之间是 AND 关系。例如 { tags: ['product'], scope: 'user:1' } 只会删除同时满足 tag 和 scope 的 entry。

cache.clear(options?)

清理当前实例 namespace 下的全部缓存。

await cache.clear()
await cache.clear({ all: true })
clear(options?: { all?: boolean }): Promise<void>

如果创建实例时没有配置 namespacecache.clear() 本身就会清理整个 storage。多应用共享同一个 IndexedDB、localStorage prefix 或 adapter 时,建议务必配置 namespace。

cache.cleanup()

执行过期和容量清理,返回删除条数。

const removed = await cache.cleanup()

清理范围默认是当前 namespace。没有配置 namespace 时会扫描整个 storage。

清理顺序:

  1. 删除 expired 数据。
  2. 如果超过 cleanup.maxEntries,按 LRU 删除最久未访问的数据。
  3. 如果超过 cleanup.maxBytes,继续按 LRU 删除,直到总大小达标。

cleanup() 不会自动定时执行。通常可以在应用启动后低频调用:

void cache.cleanup()

cache.subscribe(key, listener)

监听某个 key 的写入通知,返回取消订阅函数。

const unsubscribe = cache.subscribe<HomeData>('home:list', (value, entry) => {
  render(value)
  console.log(entry.updatedAt)
})

unsubscribe()
subscribe<T = unknown>(
  key: string,
  listener: (value: T, entry: CacheEntry<T>) => void,
): () => void

订阅只在当前 CacheClient 实例的 JS 运行上下文内生效,不会跨标签页、跨 worker 或跨另一个 cache 实例同步。

cache.getStats()

获取当前实例统计信息。

const stats = cache.getStats()
interface CacheStats {
  hits: number
  misses: number
  staleHits: number
  writes: number
  skips: number
  evictions: number
  errors: number
}

Storage Adapter

选择建议

| Adapter | 持久化 | 适合场景 | | --- | --- | --- | | indexedDBAdapter | 是 | 浏览器端推荐方案,适合接口列表、大 JSON、字典、语言包等。 | | memoryAdapter | 否 | 测试、SSR 临时缓存、不需要持久化的数据。 | | localStorageAdapter | 是 | 小体积 fallback,不适合大 JSON。 | | sessionStorageAdapter | 会话内 | 只需要当前浏览器会话保留的数据。 | | hybridAdapter | 取决于子 adapter | 多存储降级兜底。 |

indexedDBAdapter(options?)

const storage = indexedDBAdapter({
  name: 'idb-cache',
  dbName: 'MY_APP_CACHE',
  storeName: 'entries',
  version: 1,
})

| 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | name | string | 'indexed-db' | adapter 名称。 | | dbName | string | 'BRM_CACHE' | IndexedDB 数据库名。 | | storeName | string | 'entries' | object store 名。 | | version | number | 1 | IndexedDB 数据库版本。 | | indexedDB | IDBFactory | globalThis.indexedDB | 自定义 IndexedDB 实现,测试中可传 fake-indexeddb。 |

该 adapter 会创建 keyPath 为 key 的 object store,并创建 namespacescopeversionpathupdatedAtexpiresAtlastAccessedAt 索引。当前筛选逻辑会遍历 entry 后在 JS 中匹配 filter。

memoryAdapter(options?)

const storage = memoryAdapter({
  name: 'memory-cache',
  maxEntries: 200,
})

| 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | name | string | 'memory' | adapter 名称。 | | maxEntries | number | 无 | 最大条数,超过后按 lastAccessedAt LRU 淘汰。 |

localStorageAdapter(options?)

const storage = localStorageAdapter({
  prefix: 'my-app:',
  maxEntryBytes: 100 * 1024,
})

| 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | name | string | 'local-storage' | adapter 名称。 | | prefix | string | '@brm/cache:' | 写入 localStorage 的 key 前缀。 | | storage | Storage \| null | 当前环境的 localStorage | 自定义 Storage,实现测试或特殊 WebView 适配。 | | maxEntryBytes | number | 无 | 单条 entry JSON 字符串最大长度,超过会抛错。当前实现按字符串长度判断。 |

sessionStorageAdapter(options?)

参数和 localStorageAdapter 相同,只是默认使用 sessionStorage

const storage = sessionStorageAdapter({
  prefix: 'my-app-session:',
})

hybridAdapter(adapters, name?)

const storage = hybridAdapter([
  indexedDBAdapter(),
  localStorageAdapter(),
  memoryAdapter(),
])

行为:

  • get() 按数组顺序读取,返回第一个命中的 entry。
  • set()delete()clear() 会写入所有子 adapter,至少一个成功就算成功。
  • keys() 会合并所有可用 adapter 的 key 并去重。
  • entries() 会合并所有支持 entries() 的 adapter,并按 entry.key 去重。
  • 传空数组会抛出错误。
  • 当前不会把低优先级 adapter 命中的数据自动回填到高优先级 adapter。

自定义 adapter

自定义存储只需要实现 CacheStorageAdapter

import type { CacheEntry, CacheEntryFilter, CacheStorageAdapter } from '@brmtech/cache'

export function customAdapter(): CacheStorageAdapter {
  return {
    name: 'custom',
    async get<T>(key: string): Promise<CacheEntry<T> | null> {
      return null
    },
    async set<T>(key: string, entry: CacheEntry<T>): Promise<void> {
      // write entry
    },
    async delete(key: string): Promise<void> {
      // delete entry
    },
    async keys(filter?: CacheEntryFilter): Promise<string[]> {
      return []
    },
    async clear(filter?: CacheEntryFilter): Promise<void> {
      // clear entries
    },
  }
}

可选方法:

| 方法 | 说明 | | --- | --- | | entries(filter?) | 返回完整 entry 列表。实现后 cleanup() 可以少一次批量 get。 | | getMany(keys) | 批量读取。当前核心保留该接口。 | | deleteMany(keys) | 批量删除。invalidate() 会优先使用它。 |

序列化

默认 jsonSerializer

export const jsonSerializer = {
  serialize: value => JSON.stringify(value),
  deserialize: raw => JSON.parse(raw),
}

默认适合缓存普通 JSON 数据。DateMapSet、类实例、函数、循环引用等不会被自动恢复原型或可能无法序列化。需要特殊类型时,传入自定义 serializer。

import type { CacheSerializer } from '@brmtech/cache'

const serializer: CacheSerializer = {
  serialize(value) {
    return JSON.stringify(value)
  },
  deserialize(raw) {
    return JSON.parse(raw)
  },
}

const cache = createCache({ serializer })

shouldCache 写入过滤

shouldCache 会在写入前执行,可以用于跳过敏感数据、空数据或超出业务规则的数据。

const cache = createCache({
  shouldCache(value, context) {
    if (context.tags?.includes('sensitive')) return false
    if (context.key.includes('token') || context.key.includes('password')) return false
    return value != null
  },
})

CacheWriteContext

interface CacheWriteContext {
  key: string
  path?: string
  namespace?: string
  version?: string
  scope?: string
  tags?: string[]
  size: number
}

常见用法

接口缓存

const key = cache.key('/api/products', { page: 1, size: 20 })

const products = await cache.wrap(
  key,
  () => fetch('/api/products?page=1&size=20').then(res => res.json()),
  {
    ttl: '10m',
    staleTime: '1m',
    tags: ['product'],
  },
)

数据更新后失效相关缓存

await updateProduct(productId, payload)
await cache.invalidate({ tags: ['product'] })

用户退出后清理用户维度缓存

await cache.invalidate({ scope: `user:${userId}` })

Vue 或 Pinia 中同步 SWR 结果

不建议把 Pinia 本身当作持久化存储 adapter。更推荐用 IndexedDB/localStorage 做缓存持久化,再用 onUpdatesubscribe 把刷新后的数据同步到 store。

const data = await cache.wrap(
  cache.key('/api/home'),
  () => api.getHome(),
  {
    ttl: '30m',
    staleTime: '5m',
    onUpdate: fresh => homeStore.setHomeData(fresh),
  },
)

homeStore.setHomeData(data)

完整类型参考

下面是入口明确导出的类型,按源码声明展开。adapter options 字段请参考上面的 adapter 小节。

type Duration = number | `${number}ms` | `${number}s` | `${number}m` | `${number}h` | `${number}d`

interface CacheEntry<T = unknown> {
  key: string
  path?: string
  value: T
  createdAt: number
  updatedAt: number
  expiresAt: number | null
  staleAt: number | null
  lastAccessedAt: number
  hitCount: number
  size: number
  version?: string
  namespace?: string
  scope?: string
  tags?: string[]
  metadata?: Record<string, unknown>
}

interface CacheEntryFilter {
  keys?: string[]
  keyPrefix?: string
  pathPrefix?: string
  tags?: string[]
  namespace?: string
  scope?: string
  version?: string
  expired?: boolean
}

interface CacheStorageAdapter {
  name: string
  get<T>(key: string): Promise<CacheEntry<T> | null>
  set<T>(key: string, entry: CacheEntry<T>): Promise<void>
  delete(key: string): Promise<void>
  keys(filter?: CacheEntryFilter): Promise<string[]>
  entries?<T = unknown>(filter?: CacheEntryFilter): Promise<Array<CacheEntry<T>>>
  clear(filter?: CacheEntryFilter): Promise<void>
  getMany?<T>(keys: string[]): Promise<Array<CacheEntry<T> | null>>
  deleteMany?(keys: string[]): Promise<void>
}

interface CacheSerializer {
  serialize(value: unknown): string
  deserialize<T = unknown>(raw: string): T
}

interface CachePolicy {
  ttl?: Duration | null
  staleTime?: Duration | null
  staleWhileRevalidate?: boolean
  allowStaleOnError?: boolean
  maxEntryBytes?: number
}

interface CacheSetOptions extends CachePolicy {
  path?: string
  namespace?: string
  version?: string
  scope?: string
  tags?: string[]
  metadata?: Record<string, unknown>
}

interface CacheWrapOptions<T = unknown> extends CacheSetOptions {
  isEqual?: (cached: T, fresh: T) => boolean
  onUpdate?: (value: T) => void
}

interface CacheGetOptions {
  allowStale?: boolean
  maxAge?: Duration
}

interface CacheKeyOptions {
  namespace?: string
  version?: string
  scope?: string
  keepEmptyString?: boolean
}

interface CacheCleanupOptions {
  maxEntries?: number
  maxBytes?: number
  maxEntryBytes?: number
  interval?: Duration
  eviction?: 'lru'
  quotaThreshold?: number
}

interface CacheOptions {
  namespace?: string
  version?: string
  storage?: CacheStorageAdapter
  memory?: {
    enabled?: boolean
    maxEntries?: number
  }
  defaultPolicy?: CachePolicy
  cleanup?: CacheCleanupOptions
  serializer?: CacheSerializer
  shouldCache?: (value: unknown, context: CacheWriteContext) => boolean | Promise<boolean>
  touchThrottleMs?: number
  deleteExpiredOnGet?: boolean
}

interface CacheWriteContext {
  key: string
  path?: string
  namespace?: string
  version?: string
  scope?: string
  tags?: string[]
  size: number
}

interface CacheStats {
  hits: number
  misses: number
  staleHits: number
  writes: number
  skips: number
  evictions: number
  errors: number
}

type CacheFetcher<T> = () => Promise<T>

type CacheSubscribeListener<T = unknown> = (value: T, entry: CacheEntry<T>) => void

interface CacheClearOptions {
  all?: boolean
}

interface CacheClient {
  get<T = unknown>(key: string, options?: CacheGetOptions): Promise<T | null>
  getFresh<T = unknown>(key: string, options?: CacheGetOptions): Promise<T | null>
  set<T = unknown>(key: string, value: T, options?: CacheSetOptions): Promise<boolean>
  delete(key: string): Promise<void>
  clear(options?: CacheClearOptions): Promise<void>
  wrap<T = unknown>(key: string, fetcher: CacheFetcher<T>, options?: CacheWrapOptions<T>): Promise<T>
  refresh<T = unknown>(key: string, fetcher: CacheFetcher<T>, options?: CacheSetOptions): Promise<T>
  invalidate(filter: CacheEntryFilter): Promise<number>
  cleanup(): Promise<number>
  subscribe<T = unknown>(key: string, listener: CacheSubscribeListener<T>): () => void
  getStats(): CacheStats
  key(path: string, params?: Record<string, unknown> | URLSearchParams, options?: CacheKeyOptions): string
}

注意事项

  • 不要缓存 token、密码、证件号、银行卡号等敏感数据。
  • 默认 serializer 只适合普通 JSON 数据。
  • pathPrefix 依赖 entry 的 path 字段,接口缓存推荐用 cache.key()
  • IndexedDB 在隐私模式、旧浏览器或特殊 WebView 中可能不可用,可用 hybridAdapter 降级。
  • localStorage 容量小且同步阻塞,不建议存大 JSON。
  • 当前没有内置跨标签页同步,需要时可在业务层接入 BroadcastChannel 后调用 invalidate()refresh()
  • 当前不会自动定时执行 cleanup(),需要在应用启动、空闲时机或业务调度中手动调用。
  • 没有配置 namespace 时,clear()cleanup() 会作用于整个 storage,多个应用共用存储时请谨慎。

本地开发

pnpm install
pnpm run typecheck
pnpm test
pnpm run build

构建产物输出到 dist/,包含 ESM、CJS 和类型声明。