From d6227d6e6cdbfaf9e87fb3f5392bcb24e681f1fc Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:07:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(cache):=20=E5=AE=9E=E7=8E=B0=20cacheFn=20?= =?UTF-8?q?=E5=8F=8C=E5=B1=82=E5=8C=85=E8=A3=85=EF=BC=88react.cache=20+=20?= =?UTF-8?q?cacheStore=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/lib/cache/cache-fn.test.ts | 86 +++++++++++++++++++++++++++ src/shared/lib/cache/cache-fn.ts | 46 ++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/shared/lib/cache/cache-fn.test.ts create mode 100644 src/shared/lib/cache/cache-fn.ts diff --git a/src/shared/lib/cache/cache-fn.test.ts b/src/shared/lib/cache/cache-fn.test.ts new file mode 100644 index 0000000..7d5ebff --- /dev/null +++ b/src/shared/lib/cache/cache-fn.test.ts @@ -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() + return vi.fn(async (key: string, producer: () => Promise) => { + 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) + }) +}) diff --git a/src/shared/lib/cache/cache-fn.ts b/src/shared/lib/cache/cache-fn.ts new file mode 100644 index 0000000..b0cdc0a --- /dev/null +++ b/src/shared/lib/cache/cache-fn.ts @@ -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( + fn: (...args: TArgs) => Promise, + options: CacheFnOptions, +): (...args: TArgs) => Promise { + return reactCache((async (...args: TArgs): Promise => { + 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) +} + +/** + * 生成缓存 key。 + * 格式:{keyParts joined}|{args JSON} + */ +function buildKey( + fn: (...args: TArgs) => Promise, + 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}` +}