feat(cache): 实现 cacheFn 双层包装(react.cache + cacheStore)
This commit is contained in:
86
src/shared/lib/cache/cache-fn.test.ts
vendored
Normal file
86
src/shared/lib/cache/cache-fn.test.ts
vendored
Normal 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
46
src/shared/lib/cache/cache-fn.ts
vendored
Normal 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}`
|
||||
}
|
||||
Reference in New Issue
Block a user