feat(cache): 实现 cacheFn 双层包装(react.cache + cacheStore)

This commit is contained in:
SpecialX
2026-07-05 18:07:11 +08:00
parent 92913a728f
commit d6227d6e6c
2 changed files with 132 additions and 0 deletions

86
src/shared/lib/cache/cache-fn.test.ts vendored Normal file
View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
// Mock store-factory
const mockStore = {
getOrSet: vi.fn(),
invalidateTags: vi.fn(),
}
const getCacheStoreMock = vi.fn().mockResolvedValue(mockStore)
vi.mock("./store-factory", () => ({
getCacheStore: () => getCacheStoreMock(),
}))
import { cacheFn } from "./cache-fn"
/**
* 真正的缓存 mock第一次调 producer后续命中缓存直接返回。
* 模拟 cacheStore.getOrSet 的语义,让 react.cache 包装层在测试中可观测。
*/
function makeCachingGetOrSet() {
const cache = new Map<string, unknown>()
return vi.fn(async (key: string, producer: () => Promise<unknown>) => {
if (!cache.has(key)) cache.set(key, await producer())
return cache.get(key)
})
}
describe("cacheFn", () => {
beforeEach(() => {
vi.clearAllMocks()
// vitest mockReset: true 会清除 mockResolvedValue需重新设置
getCacheStoreMock.mockResolvedValue(mockStore)
})
it("首次调用触发 producer第二次命中缓存", async () => {
const producer = vi.fn().mockResolvedValue("v1")
// 使用真正缓存的 mock第一次调 producer第二次命中缓存
mockStore.getOrSet = makeCachingGetOrSet()
const cached = cacheFn(producer, { tags: ["users"], ttl: 60, keyParts: ["users", "by-id"] })
const r1 = await cached("u1")
const r2 = await cached("u1")
expect(r1).toBe("v1")
expect(r2).toBe("v1")
expect(producer).toHaveBeenCalledTimes(1) // 第二次命中 cacheStore 缓存,不调 producer
expect(mockStore.getOrSet).toHaveBeenCalledTimes(2)
})
it("不同参数生成不同 key", async () => {
const producer = vi.fn().mockResolvedValue("v")
mockStore.getOrSet.mockImplementation(async (_key, prod) => prod())
const cached = cacheFn(producer, { tags: ["users"], keyParts: ["users"] })
await cached("u1")
await cached("u2")
const keys = mockStore.getOrSet.mock.calls.map((c) => c[0])
expect(keys[0]).not.toBe(keys[1])
})
it("keyParts 参与生成 key", async () => {
const producer = vi.fn().mockResolvedValue("v")
mockStore.getOrSet.mockImplementation(async (_key, prod) => prod())
const cached = cacheFn(producer, { tags: ["users"], keyParts: ["users", "by-id"] })
await cached("u1")
const key = mockStore.getOrSet.mock.calls[0][0]
expect(key).toContain("users")
expect(key).toContain("by-id")
})
it("tags 与 ttl 透传给 store", async () => {
const producer = vi.fn().mockResolvedValue("v")
mockStore.getOrSet.mockImplementation(async (_key, prod) => prod())
const cached = cacheFn(producer, { tags: ["users", "users:detail"], ttl: 120 })
await cached("u1")
const options = mockStore.getOrSet.mock.calls[0][2]
expect(options.tags).toEqual(["users", "users:detail"])
expect(options.ttl).toBe(120)
})
})

46
src/shared/lib/cache/cache-fn.ts vendored Normal file
View File

@@ -0,0 +1,46 @@
import "server-only"
import { cache as reactCache } from "react"
import { getCacheStore } from "./store-factory"
import type { CacheFnOptions } from "./types"
/**
* 缓存包装器:双层职责。
*
* - 外层 react.cache请求级 memoization同一 RSC 请求内去重)
* - 内层 cacheStore.getOrSet跨请求/跨实例数据缓存
*
* @param fn 原始异步函数
* @param options tags必填、ttl、keyParts
* @returns 与原函数同类型的包装函数
*/
export function cacheFn<TArgs extends unknown[], TResult>(
fn: (...args: TArgs) => Promise<TResult>,
options: CacheFnOptions,
): (...args: TArgs) => Promise<TResult> {
return reactCache((async (...args: TArgs): Promise<TResult> => {
const key = buildKey(fn, args, options)
const store = await getCacheStore()
return store.getOrSet(key, () => fn(...args), {
tags: options.tags,
ttl: options.ttl,
})
}) as (...args: TArgs) => Promise<TResult>)
}
/**
* 生成缓存 key。
* 格式:{keyParts joined}|{args JSON}
*/
function buildKey<TArgs extends unknown[]>(
fn: (...args: TArgs) => Promise<unknown>,
args: TArgs,
options: CacheFnOptions,
): string {
const prefix = options.keyParts
? options.keyParts.map(String).join(":")
: fn.name || "anonymous"
const argsHash = JSON.stringify(args)
return `${prefix}|${argsHash}`
}