From b6d7cc7c76ba4837e01b93afcf013554f0f10ac8 Mon Sep 17 00:00:00 2001 From: Ming <527990618@163.com> Date: Wed, 16 Apr 2025 20:08:13 +0800 Subject: [PATCH 1/5] feat(cache): support customKey for cache --- .changeset/little-mirrors-design.md | 6 ++ .../guides/basic-features/data/data-cache.mdx | 58 +++++++++++++ .../guides/basic-features/data/data-cache.mdx | 62 ++++++++++++++ .../runtime-utils/src/universal/cache.ts | 67 +++++++++------ .../tests/universal/cache-server.test.ts | 82 +++++++++++++++++++ 5 files changed, 250 insertions(+), 25 deletions(-) create mode 100644 .changeset/little-mirrors-design.md diff --git a/.changeset/little-mirrors-design.md b/.changeset/little-mirrors-design.md new file mode 100644 index 000000000000..df4606e3bc0d --- /dev/null +++ b/.changeset/little-mirrors-design.md @@ -0,0 +1,6 @@ +--- +'@modern-js/runtime-utils': patch +--- + +feat(cache): support customKey for cache +feat(cache): 为缓存支持 customKey 函数 diff --git a/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx b/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx index 2bc9b5a43d53..e8ea648f2df6 100644 --- a/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx +++ b/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx @@ -34,6 +34,7 @@ const loader = async () => { - `tag`: Tag to identify the cache, which can be used to invalidate the cache - `maxAge`: Cache validity period (milliseconds) - `revalidate`: Time window for revalidating the cache (milliseconds), similar to HTTP Cache-Control's stale-while-revalidate functionality + - `customKey`: Custom cache key function The type of the `options` parameter is as follows: @@ -42,6 +43,11 @@ interface CacheOptions { tag?: string | string[]; maxAge?: number; revalidate?: number; + customKey?: (options: { + params: Args; + fn: (...args: Args) => any; + generatedKey: string; + }) => string | symbol; } ``` @@ -153,6 +159,58 @@ revalidateTag('dashboard-stats'); // Invalidates the cache for both getDashboard ``` +#### `customKey` parameter + +The `customKey` parameter is used to customize the cache key. It is a function that receives an object containing the parameters of the cached function, the function itself, and the serialized result of the parameters, and returns a string or Symbol type as the cache key. This is very useful in some scenarios, such as when the function reference changes (such as hot update or dynamic import), but you want to still return the cached data. + +```ts +import { cache } from '@modern-js/runtime/cache'; +import { fetchUserData } from './api'; + +// Different function references, but share the same cache via customKey +const getUserA = cache( + fetchUserData, + { + maxAge: CacheTime.MINUTE * 5, + customKey: ({ params }) => { + // Return a stable string as the cache key + return `user-${params[0]}`; + }, + } +); + +// Even if the function reference changes, +// as long as customKey returns the same value, the cache will be hit +const getUserB = cache( + (...args) => fetchUserData(...args), // New function reference + { + maxAge: CacheTime.MINUTE * 5, + customKey: ({ params }) => { + // Return the same key as getUserA + return `user-${params[0]}`; + }, + } +); + +// You can also use Symbol as a cache key (usually used to share cache within the same application) +const USER_CACHE_KEY = Symbol('user-cache'); +const getUserC = cache( + fetchUserData, + { + maxAge: CacheTime.MINUTE * 5, + customKey: () => USER_CACHE_KEY, + } +); + +// You can utilize the generatedKey parameter +const getUserD = cache( + fetchUserData, + { + customKey: ({ generatedKey }) => `prefix-${generatedKey}`, + } +); +``` + ### Storage Currently, both client and server caches are stored in memory. diff --git a/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx index 0396afc61f79..8cd2af83ec8e 100644 --- a/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx +++ b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx @@ -33,6 +33,7 @@ const loader = async () => { - `tag`: 用于标识缓存的标签,可以基于这个标签使缓存失效 - `maxAge`: 缓存的有效期 (毫秒) - `revalidate`: 重新验证缓存的时间窗口(毫秒),与 HTTP Cache-Control 的 stale-while-revalidate 功能一致 + - `customKey`: 自定义缓存键生成函数,用于在函数引用变化时保持缓存 `options` 参数的类型如下: @@ -41,6 +42,11 @@ interface CacheOptions { tag?: string | string[]; maxAge?: number; revalidate?: number; + customKey?: (options: { + params: Args; + fn: (...args: Args) => any; + generatedKey: string; + }) => string | symbol; } ``` @@ -146,6 +152,61 @@ const getComplexStatistics = cache( revalidateTag('dashboard-stats'); // 会使 getDashboardStats 函数和 getComplexStatistics 函数的缓存都失效 ``` +#### `customKey` 参数 + +`customKey` 参数用于定制缓存的键,它是一个函数,接收一个包含被缓存函数的参数、函数本身及参数序列化后的结果的对象,返回值必须是字符串或 Symbol 类型,将作为缓存的键。这在某些场景下非常有用,比如当函数引用发生变化时,但你希望仍然返回缓存的数据。 + +```ts +import { cache } from '@modern-js/runtime/cache'; +import { fetchUserData } from './api'; + +// 不同的函数引用,但是通过 customKey 可以使它们共享一个缓存 +const getUserA = cache( + fetchUserData, + { + maxAge: CacheTime.MINUTE * 5, + customKey: ({ params }) => { + // 返回一个稳定的字符串作为缓存的键 + return `user-${params[0]}`; + }, + } +); + +// 在热更新或代码分割的情况下,即使函数引用变了,只要 customKey 返回相同的值,也会命中缓存 +const getUserB = cache( + (...args) => fetchUserData(...args), // 新的函数引用 + { + maxAge: CacheTime.MINUTE * 5, + customKey: ({ params }) => { + // 返回与 getUserA 相同的键 + return `user-${params[0]}`; + }, + } +); + +// 也可以使用 Symbol 作为缓存键(通常用于共享同一个应用内的缓存) +const USER_CACHE_KEY = Symbol('user-cache'); +const getUserC = cache( + fetchUserData, + { + maxAge: CacheTime.MINUTE * 5, + customKey: () => USER_CACHE_KEY, + } +); + +// 可以利用 generatedKey 参数 +const getUserD = cache( + fetchUserData, + { + customKey: ({ generatedKey }) => `prefix-${generatedKey}`, + } +); + +// 即使 getUserA 和 getUserB 是不同的函数引用,但由于它们的 customKey 返回相同的值 +// 所以当调用参数相同时,它们会共享缓存 +const dataA = await getUserA(1); +const dataB = await getUserB(1); // 这里会命中缓存,不会再次发起请求 +``` ### 存储 @@ -164,3 +225,4 @@ configureCache({ maxSize: CacheSize.MB * 10, // 10MB }); ``` + diff --git a/packages/toolkit/runtime-utils/src/universal/cache.ts b/packages/toolkit/runtime-utils/src/universal/cache.ts index 3ef0a945e30a..60708e5b3a11 100644 --- a/packages/toolkit/runtime-utils/src/universal/cache.ts +++ b/packages/toolkit/runtime-utils/src/universal/cache.ts @@ -20,6 +20,11 @@ interface CacheOptions { tag?: string | string[]; maxAge?: number; revalidate?: number; + customKey?: (options: { + params: Args; + fn: (...args: Args) => any; + generatedKey: string; + }) => string | symbol; } interface CacheConfig { @@ -36,21 +41,21 @@ const isServer = typeof window === 'undefined'; const requestCacheMap = new WeakMap>(); let lruCache: - | LRUCache<(...args: any[]) => any, Map>> + | LRUCache>> | undefined; let cacheConfig: CacheConfig = { maxSize: CacheSize.GB, }; -const tagFnMap = new Map Promise>>(); +const tagKeyMap = new Map>(); -function addTagFnRelation(tag: string, fn: (...args: any[]) => Promise) { - let fns = tagFnMap.get(tag); - if (!fns) { - fns = new Set(); - tagFnMap.set(tag, fns); +function addTagKeyRelation(tag: string, key: Function | string | symbol) { + let keys = tagKeyMap.get(tag); + if (!keys) { + keys = new Set(); + tagKeyMap.set(tag, keys); } - fns.add(fn); + keys.add(key); } export function configureCache(config: CacheConfig): void { @@ -63,7 +68,7 @@ export function configureCache(config: CacheConfig): void { function getLRUCache() { if (!lruCache) { lruCache = new LRUCache< - (...args: any[]) => any, + Function | string | symbol, Map> >({ maxSize: cacheConfig.maxSize, @@ -149,11 +154,15 @@ export function cache Promise>( tag = 'default', maxAge = CacheTime.MINUTE * 5, revalidate = 0, + customKey, } = options || {}; const store = getLRUCache(); const tags = Array.isArray(tag) ? tag : [tag]; - tags.forEach(t => addTagFnRelation(t, fn)); + + const getCacheKey = (args: Parameters, generatedKey: string) => { + return customKey ? customKey({ params: args, fn, generatedKey }) : fn; + }; return (async (...args: Parameters) => { if (isServer && typeof options === 'undefined') { @@ -189,15 +198,22 @@ export function cache Promise>( } } } else if (typeof options !== 'undefined') { - let tagCache = store.get(fn); - if (!tagCache) { - tagCache = new Map(); - } - const key = generateKey(args); - const cached = tagCache.get(key); const now = Date.now(); + const cacheKey = getCacheKey(args, key); + + tags.forEach(t => addTagKeyRelation(t, cacheKey)); + + let cacheStore = store.get(cacheKey); + if (!cacheStore) { + cacheStore = new Map(); + } + + const storeKey = + customKey && typeof cacheKey === 'symbol' ? 'symbol-key' : key; + + const cached = cacheStore.get(storeKey); if (cached) { const age = now - cached.timestamp; @@ -211,12 +227,13 @@ export function cache Promise>( Promise.resolve().then(async () => { try { const newData = await fn(...args); - tagCache!.set(key, { + cacheStore!.set(storeKey, { data: newData, timestamp: Date.now(), isRevalidating: false, }); - store.set(fn, tagCache); + + store.set(cacheKey, cacheStore!); } catch (error) { cached.isRevalidating = false; if (isServer) { @@ -235,13 +252,13 @@ export function cache Promise>( } const data = await fn(...args); - tagCache.set(key, { + cacheStore.set(storeKey, { data, timestamp: now, isRevalidating: false, }); - store.set(fn, tagCache); + store.set(cacheKey, cacheStore); return data; } else { @@ -267,10 +284,10 @@ export function withRequestCache< } export function revalidateTag(tag: string): void { - const fns = tagFnMap.get(tag); - if (fns) { - fns.forEach(fn => { - lruCache?.delete(fn); + const keys = tagKeyMap.get(tag); + if (keys) { + keys.forEach(key => { + lruCache?.delete(key); }); } } @@ -278,5 +295,5 @@ export function revalidateTag(tag: string): void { export function clearStore(): void { lruCache?.clear(); lruCache = undefined; - tagFnMap.clear(); + tagKeyMap.clear(); } diff --git a/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts b/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts index cb32b15d5f9e..ce1bcc9d3264 100644 --- a/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts +++ b/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts @@ -469,4 +469,86 @@ describe('cache function', () => { expect(generateKey([{}])).not.toBe(generateKey([[]])); }); }); + + describe('customKey', () => { + it('should share cache between different functions with same customKey', async () => { + const mockFn1 = jest.fn().mockResolvedValue('data1'); + const mockFn2 = jest.fn().mockResolvedValue('data2'); + + const cachedFn1 = cache(mockFn1, { + customKey: () => 'shared-key', + }); + + const cachedFn2 = cache(mockFn2, { + customKey: () => 'shared-key', + }); + + const result1 = await cachedFn1('param'); + expect(result1).toBe('data1'); + expect(mockFn1).toHaveBeenCalledTimes(1); + + const result2 = await cachedFn2('param'); + expect(result2).toBe('data1'); + expect(mockFn2).toHaveBeenCalledTimes(0); + }); + + it('should support Symbol as customKey return value', async () => { + const SYMBOL_KEY = Symbol('test-symbol'); + const mockFn = jest.fn().mockResolvedValue('symbol data'); + + const cachedFn = cache(mockFn, { + customKey: () => SYMBOL_KEY, + }); + + const result1 = await cachedFn('param1'); + expect(result1).toBe('symbol data'); + expect(mockFn).toHaveBeenCalledTimes(1); + + const result2 = await cachedFn('param2'); + expect(result2).toBe('symbol data'); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('should support customKey that depends on function arguments', async () => { + const mockFn = jest + .fn() + .mockImplementation(id => Promise.resolve(`data for ${id}`)); + + const cachedFn = cache(mockFn, { + customKey: ({ params }) => `user-${params[0]}`, + }); + + const result1 = await cachedFn(1); + const result2 = await cachedFn(2); + const result3 = await cachedFn(1); + + expect(result1).toBe('data for 1'); + expect(result2).toBe('data for 2'); + expect(result3).toBe('data for 1'); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should respect maxAge and work with tag revalidation', async () => { + const mockFn = jest.fn().mockResolvedValue('cached data'); + const cachedFn = cache(mockFn, { + tag: 'custom-tag', + customKey: () => 'test-key', + maxAge: CacheTime.SECOND, + }); + + await cachedFn('param'); + expect(mockFn).toHaveBeenCalledTimes(1); + + await cachedFn('param'); + expect(mockFn).toHaveBeenCalledTimes(1); + + revalidateTag('custom-tag'); + await cachedFn('param'); + expect(mockFn).toHaveBeenCalledTimes(2); + + jest.advanceTimersByTime(CacheTime.SECOND + 1); + await cachedFn('param'); + expect(mockFn).toHaveBeenCalledTimes(3); + }); + }); }); From c2e1e4f2185765f0672abbfe8c06999f8c6deb0e Mon Sep 17 00:00:00 2001 From: Ming <527990618@163.com> Date: Wed, 16 Apr 2025 20:14:04 +0800 Subject: [PATCH 2/5] docs: update the doc --- .../en/guides/basic-features/data/data-cache.mdx | 10 ++++++++-- .../zh/guides/basic-features/data/data-cache.mdx | 12 +++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx b/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx index e8ea648f2df6..66a7f250d524 100644 --- a/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx +++ b/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx @@ -161,7 +161,13 @@ revalidateTag('dashboard-stats'); // Invalidates the cache for both getDashboard #### `customKey` parameter -The `customKey` parameter is used to customize the cache key. It is a function that receives an object containing the parameters of the cached function, the function itself, and the serialized result of the parameters, and returns a string or Symbol type as the cache key. This is very useful in some scenarios, such as when the function reference changes (such as hot update or dynamic import), but you want to still return the cached data. +The `customKey` parameter is used to customize the cache key. It is a function that receives an object with the following properties and returns a string or Symbol type as the cache key: + +- `params`: Array of arguments passed to the cached function +- `fn`: Reference to the original function being cached +- `generatedKey`: Cache key automatically generated by the framework based on input parameters + +This is very useful in some scenarios, such as when the function reference changes , but you want to still return the cached data. ```ts import { cache } from '@modern-js/runtime/cache'; @@ -202,7 +208,7 @@ const getUserC = cache( } ); -// You can utilize the generatedKey parameter +// You can utilize the generatedKey parameter to modify the default key const getUserD = cache( fetchUserData, { diff --git a/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx index 8cd2af83ec8e..99a0c22250cc 100644 --- a/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx +++ b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx @@ -154,7 +154,13 @@ revalidateTag('dashboard-stats'); // 会使 getDashboardStats 函数和 getCompl #### `customKey` 参数 -`customKey` 参数用于定制缓存的键,它是一个函数,接收一个包含被缓存函数的参数、函数本身及参数序列化后的结果的对象,返回值必须是字符串或 Symbol 类型,将作为缓存的键。这在某些场景下非常有用,比如当函数引用发生变化时,但你希望仍然返回缓存的数据。 +`customKey` 参数用于定制缓存的键,它是一个函数,接收一个包含以下属性的对象,返回值必须是字符串或 Symbol 类型,将作为缓存的键: + +- `params`:调用缓存函数时传入的参数数组 +- `fn`:原始被缓存的函数引用 +- `generatedKey`:框架基于入参自动生成的原始缓存键 + +这在某些场景下非常有用,比如当函数引用发生变化时,但你希望仍然返回缓存的数据。 ```ts import { cache } from '@modern-js/runtime/cache'; @@ -172,7 +178,7 @@ const getUserA = cache( } ); -// 在热更新或代码分割的情况下,即使函数引用变了,只要 customKey 返回相同的值,也会命中缓存 +// 即使函数引用变了,只要 customKey 返回相同的值,也会命中缓存 const getUserB = cache( (...args) => fetchUserData(...args), // 新的函数引用 { @@ -194,7 +200,7 @@ const getUserC = cache( } ); -// 可以利用 generatedKey 参数 +// 可以利用 generatedKey 参数在默认键的基础上进行修改 const getUserD = cache( fetchUserData, { From 2d870b6bbc869305f45e33bf676efbe892576528 Mon Sep 17 00:00:00 2001 From: Ming <527990618@163.com> Date: Wed, 16 Apr 2025 20:17:12 +0800 Subject: [PATCH 3/5] docs: update the doc --- .../docs/zh/guides/basic-features/data/data-cache.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx index 99a0c22250cc..55f594a7abf7 100644 --- a/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx +++ b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx @@ -190,6 +190,11 @@ const getUserB = cache( } ); +// 即使 getUserA 和 getUserB 是不同的函数引用,但由于它们的 customKey 返回相同的值 +// 所以当调用参数相同时,它们会共享缓存 +const dataA = await getUserA(1); +const dataB = await getUserB(1); // 这里会命中缓存,不会再次发起请求 + // 也可以使用 Symbol 作为缓存键(通常用于共享同一个应用内的缓存) const USER_CACHE_KEY = Symbol('user-cache'); const getUserC = cache( @@ -207,11 +212,6 @@ const getUserD = cache( customKey: ({ generatedKey }) => `prefix-${generatedKey}`, } ); - -// 即使 getUserA 和 getUserB 是不同的函数引用,但由于它们的 customKey 返回相同的值 -// 所以当调用参数相同时,它们会共享缓存 -const dataA = await getUserA(1); -const dataB = await getUserB(1); // 这里会命中缓存,不会再次发起请求 ``` ### 存储 From 50a541c9df0b926bd85c26ff8ab4423ee710326e Mon Sep 17 00:00:00 2001 From: wangyiming Date: Sun, 27 Apr 2025 11:24:48 +0800 Subject: [PATCH 4/5] feat: support cache statistics --- .changeset/yellow-dolls-fail.md | 6 + .../guides/basic-features/data/data-cache.mdx | 64 +++++++- .../guides/basic-features/data/data-cache.mdx | 64 +++++++- .../runtime-utils/src/universal/cache.ts | 45 +++++- .../tests/universal/cache-server.test.ts | 138 ++++++++++++++++++ 5 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 .changeset/yellow-dolls-fail.md diff --git a/.changeset/yellow-dolls-fail.md b/.changeset/yellow-dolls-fail.md new file mode 100644 index 000000000000..e84ede542ef0 --- /dev/null +++ b/.changeset/yellow-dolls-fail.md @@ -0,0 +1,6 @@ +--- +'@modern-js/runtime-utils': patch +--- + +feat: support cache statistics +feat: 支持缓存命中率统计 diff --git a/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx b/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx index 66a7f250d524..a0892537c728 100644 --- a/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx +++ b/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx @@ -4,7 +4,7 @@ sidebar_position: 4 --- # Data Caching -The `cache` function allows you to cache the results of data fetching or computation. +The `cache` function allows you to cache the results of data fetching or computation, Compared to full-page [rendering cache](/guides/basic-features/render/ssr-cache), it provides more fine-grained control over data granularity and is applicable to various scenarios such as Client-Side Rendering (CSR), Server-Side Rendering (SSR), and API services (BFF). :::info X.65.5 and above versions are required @@ -217,6 +217,68 @@ const getUserD = cache( ); ``` +#### `onCache` Parameter + +The `onCache` parameter allows you to track cache statistics such as hit rate. It's a callback function that receives information about each cache operation, including the status, key, parameters, and result. + +```ts +import { cache, CacheTime } from '@modern-js/runtime/cache'; + +// Track cache statistics +const stats = { + total: 0, + hits: 0, + misses: 0, + stales: 0, + hitRate: () => stats.hits / stats.total +}; + +const getUser = cache( + fetchUserData, + { + maxAge: CacheTime.MINUTE * 5, + onCache({ status, key, params, result }) { + // status can be 'hit', 'miss', or 'stale' + stats.total++; + + if (status === 'hit') { + stats.hits++; + } else if (status === 'miss') { + stats.misses++; + } else if (status === 'stale') { + stats.stales++; + } + + console.log(`Cache ${status} for key: ${String(key)}`); + console.log(`Current hit rate: ${stats.hitRate() * 100}%`); + } + } +); + +// Usage example +await getUser(1); // Cache miss +await getUser(1); // Cache hit +await getUser(2); // Cache miss +``` + +The `onCache` callback receives an object with the following properties: + +- `status`: The cache operation status, which can be: + - `hit`: Cache hit, returning cached content + - `miss`: Cache miss, executing the function and caching the result + - `stale`: Cache hit but data is stale, returning cached content while revalidating in the background +- `key`: The cache key, which is either the result of `customKey` or the default generated key +- `params`: The parameters passed to the cached function +- `result`: The result data (either from cache or newly computed) + +This callback is only invoked when the `options` parameter is provided. When using the cache function without options (for SSR request-scoped caching), the `onCache` callback is not called. + +The `onCache` callback is useful for: +- Monitoring cache performance +- Calculating hit rates +- Logging cache operations +- Implementing custom metrics + ### Storage Currently, both client and server caches are stored in memory. diff --git a/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx index 55f594a7abf7..835a3fb58995 100644 --- a/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx +++ b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx @@ -4,7 +4,7 @@ sidebar_position: 4 --- # 数据缓存 -`cache` 函数可以让你缓存数据获取或计算的结果。 +`cache` 函数可以让你缓存数据获取或计算的结果,相比整页[渲染缓存](/guides/basic-features/render/ssr-cache),它提供了更精细的数据粒度控制,并且适用于客户端渲染(CSR)、服务端渲染(SSR)、API 服务(BFF)等多种场景。 :::info 需要 x.65.5 及以上版本 @@ -214,6 +214,68 @@ const getUserD = cache( ); ``` +#### `onCache` 参数 + +`onCache` 参数允许你跟踪缓存统计信息,例如命中率。这是一个回调函数,接收有关每次缓存操作的信息,包括状态、键、参数和结果。 + +```ts +import { cache, CacheTime } from '@modern-js/runtime/cache'; + +// 跟踪缓存统计 +const stats = { + total: 0, + hits: 0, + misses: 0, + stales: 0, + hitRate: () => stats.hits / stats.total +}; + +const getUser = cache( + fetchUserData, + { + maxAge: CacheTime.MINUTE * 5, + onCache({ status, key, params, result }) { + // status 可以是 'hit'、'miss' 或 'stale' + stats.total++; + + if (status === 'hit') { + stats.hits++; + } else if (status === 'miss') { + stats.misses++; + } else if (status === 'stale') { + stats.stales++; + } + + console.log(`缓存${status === 'hit' ? '命中' : status === 'miss' ? '未命中' : '陈旧'},键:${String(key)}`); + console.log(`当前命中率:${stats.hitRate() * 100}%`); + } + } +); + +// 使用示例 +await getUser(1); // 缓存未命中 +await getUser(1); // 缓存命中 +await getUser(2); // 缓存未命中 +``` + +`onCache` 回调接收一个包含以下属性的对象: + +- `status`: 缓存操作状态,可以是: + - `hit`: 缓存命中,返回缓存内容 + - `miss`: 缓存未命中,执行函数并缓存结果 + - `stale`: 缓存命中但数据陈旧,返回缓存内容同时在后台重新验证 +- `key`: 缓存键,可能是 `customKey` 的结果或默认生成的键 +- `params`: 传递给缓存函数的参数 +- `result`: 结果数据(来自缓存或新计算的) + +这个回调只在提供 `options` 参数时被调用。当使用不带选项的缓存函数(用于 SSR 请求范围内的缓存)时,不会调用 `onCache` 回调。 + +`onCache` 回调对以下场景非常有用: +- 监控缓存性能 +- 计算命中率 +- 记录缓存操作 +- 实现自定义指标 + ### 存储 目前不管是客户端还是服务端,缓存都存储在内存中,默认情况下所有缓存函数共享的存储上限是 1GB,当达到存储上限后,使用 LRU 算法移除旧的缓存。 diff --git a/packages/toolkit/runtime-utils/src/universal/cache.ts b/packages/toolkit/runtime-utils/src/universal/cache.ts index 60708e5b3a11..d81fd4ea13e6 100644 --- a/packages/toolkit/runtime-utils/src/universal/cache.ts +++ b/packages/toolkit/runtime-utils/src/universal/cache.ts @@ -16,6 +16,15 @@ export const CacheTime = { MONTH: 30 * 24 * 60 * 60 * 1000, } as const; +export type CacheStatus = 'hit' | 'stale' | 'miss'; + +export interface CacheStatsInfo { + status: CacheStatus; + key: string | symbol; + params: any[]; + result: any; +} + interface CacheOptions { tag?: string | string[]; maxAge?: number; @@ -25,6 +34,7 @@ interface CacheOptions { fn: (...args: Args) => any; generatedKey: string; }) => string | symbol; + onCache?: (info: CacheStatsInfo) => void; } interface CacheConfig { @@ -155,6 +165,7 @@ export function cache Promise>( maxAge = CacheTime.MINUTE * 5, revalidate = 0, customKey, + onCache, } = options || {}; const store = getLRUCache(); @@ -198,10 +209,11 @@ export function cache Promise>( } } } else if (typeof options !== 'undefined') { - const key = generateKey(args); + const genKey = generateKey(args); const now = Date.now(); - const cacheKey = getCacheKey(args, key); + const cacheKey = getCacheKey(args, genKey); + const finalKey = typeof cacheKey === 'function' ? genKey : cacheKey; tags.forEach(t => addTagKeyRelation(t, cacheKey)); @@ -211,17 +223,34 @@ export function cache Promise>( } const storeKey = - customKey && typeof cacheKey === 'symbol' ? 'symbol-key' : key; + customKey && typeof cacheKey === 'symbol' ? 'symbol-key' : genKey; const cached = cacheStore.get(storeKey); if (cached) { const age = now - cached.timestamp; if (age < maxAge) { + if (onCache) { + onCache({ + status: 'hit', + key: finalKey, + params: args, + result: cached.data, + }); + } return cached.data; } if (revalidate > 0 && age < maxAge + revalidate) { + if (onCache) { + onCache({ + status: 'stale', + key: finalKey, + params: args, + result: cached.data, + }); + } + if (!cached.isRevalidating) { cached.isRevalidating = true; Promise.resolve().then(async () => { @@ -252,6 +281,7 @@ export function cache Promise>( } const data = await fn(...args); + cacheStore.set(storeKey, { data, timestamp: now, @@ -260,6 +290,15 @@ export function cache Promise>( store.set(cacheKey, cacheStore); + if (onCache) { + onCache({ + status: 'miss', + key: finalKey, + params: args, + result: data, + }); + } + return data; } else { console.warn( diff --git a/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts b/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts index ce1bcc9d3264..a57e151684fd 100644 --- a/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts +++ b/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts @@ -551,4 +551,142 @@ describe('cache function', () => { expect(mockFn).toHaveBeenCalledTimes(3); }); }); + + describe('cache statistics', () => { + beforeEach(() => { + jest.useFakeTimers(); + clearStore(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should call onCache with hit status when cache hit', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const onCacheMock = jest.fn(); + + const cachedFn = cache(mockFn, { + maxAge: CacheTime.MINUTE, + onCache: onCacheMock, + }); + + await cachedFn('param1'); + expect(onCacheMock).toHaveBeenCalledTimes(1); + expect(onCacheMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: 'miss', + params: ['param1'], + result: 'test data', + }), + ); + + onCacheMock.mockClear(); + await cachedFn('param1'); + expect(onCacheMock).toHaveBeenCalledTimes(1); + expect(onCacheMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: 'hit', + params: ['param1'], + result: 'test data', + }), + ); + }); + + it('should call onCache with stale status when in revalidate window', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const onCacheMock = jest.fn(); + + const cachedFn = cache(mockFn, { + maxAge: CacheTime.SECOND, + revalidate: CacheTime.SECOND, + onCache: onCacheMock, + }); + + await cachedFn('param1'); + onCacheMock.mockClear(); + + jest.advanceTimersByTime(CacheTime.SECOND + 10); + + await cachedFn('param1'); + expect(onCacheMock).toHaveBeenCalledTimes(1); + expect(onCacheMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: 'stale', + params: ['param1'], + result: 'test data', + }), + ); + + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should include correct key in onCache callback', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const onCacheMock = jest.fn(); + + // Case 1: Default key (function reference) + const cachedFn1 = cache(mockFn, { + onCache: onCacheMock, + }); + + await cachedFn1('param1'); + expect(onCacheMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: 'miss', + key: JSON.stringify(['param1']), + }), + ); + + onCacheMock.mockClear(); + + // Case 2: Custom string key + const CUSTOM_KEY = 'my-custom-key'; + const cachedFn2 = cache(mockFn, { + customKey: () => CUSTOM_KEY, + onCache: onCacheMock, + }); + + await cachedFn2('param1'); + expect(onCacheMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: 'miss', + key: CUSTOM_KEY, + }), + ); + + onCacheMock.mockClear(); + + // Case 3: Custom symbol key + const SYMBOL_KEY = Symbol('test-key'); + const cachedFn3 = cache(mockFn, { + customKey: () => SYMBOL_KEY, + onCache: onCacheMock, + }); + + await cachedFn3('param1'); + expect(onCacheMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: 'miss', + key: SYMBOL_KEY, + }), + ); + }); + + it('should not call onCache when no options are provided', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const onCacheMock = jest.fn(); + + const cachedFn = cache(mockFn); + + const handler = withRequestCache(async (req: Request) => { + const result1 = await cachedFn('param1'); + const result2 = await cachedFn('param1'); + return { result1, result2 }; + }); + + await handler(new MockRequest() as unknown as Request); + expect(onCacheMock).not.toHaveBeenCalled(); + }); + }); }); From 868ce29324d77badc87374c77a58f839fde4b324 Mon Sep 17 00:00:00 2001 From: wangyiming Date: Sun, 27 Apr 2025 14:24:00 +0800 Subject: [PATCH 5/5] feat: unstable_shouldDisable --- .changeset/swift-cows-brush.md | 6 + .../runtime-utils/src/universal/cache.ts | 43 ++++- .../tests/universal/cache-server.test.ts | 158 ++++++++++++++++++ 3 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 .changeset/swift-cows-brush.md diff --git a/.changeset/swift-cows-brush.md b/.changeset/swift-cows-brush.md new file mode 100644 index 000000000000..a6b74a228565 --- /dev/null +++ b/.changeset/swift-cows-brush.md @@ -0,0 +1,6 @@ +--- +'@modern-js/runtime-utils': patch +--- + +feat: support unstable_shouldDisable +feat: 支持 unstable_shouldDisable diff --git a/packages/toolkit/runtime-utils/src/universal/cache.ts b/packages/toolkit/runtime-utils/src/universal/cache.ts index d81fd4ea13e6..69aada7e012c 100644 --- a/packages/toolkit/runtime-utils/src/universal/cache.ts +++ b/packages/toolkit/runtime-utils/src/universal/cache.ts @@ -39,6 +39,11 @@ interface CacheOptions { interface CacheConfig { maxSize: number; + unstable_shouldDisable?: ({ + request, + }: { + request: Request; + }) => boolean | Promise; } interface CacheItem { @@ -180,6 +185,17 @@ export function cache Promise>( const storage = getAsyncLocalStorage(); const request = storage?.useContext()?.request; if (request) { + let shouldDisableCaching = false; + if (cacheConfig.unstable_shouldDisable) { + shouldDisableCaching = await Promise.resolve( + cacheConfig.unstable_shouldDisable({ request }), + ); + } + + if (shouldDisableCaching) { + return fn(...args); + } + let requestCache = requestCacheMap.get(request); if (!requestCache) { requestCache = new Map(); @@ -225,8 +241,19 @@ export function cache Promise>( const storeKey = customKey && typeof cacheKey === 'symbol' ? 'symbol-key' : genKey; + let shouldDisableCaching = false; + if (isServer && cacheConfig.unstable_shouldDisable) { + const storage = getAsyncLocalStorage(); + const request = storage?.useContext()?.request; + if (request) { + shouldDisableCaching = await Promise.resolve( + cacheConfig.unstable_shouldDisable({ request }), + ); + } + } + const cached = cacheStore.get(storeKey); - if (cached) { + if (cached && !shouldDisableCaching) { const age = now - cached.timestamp; if (age < maxAge) { @@ -282,13 +309,15 @@ export function cache Promise>( const data = await fn(...args); - cacheStore.set(storeKey, { - data, - timestamp: now, - isRevalidating: false, - }); + if (!shouldDisableCaching) { + cacheStore.set(storeKey, { + data, + timestamp: now, + isRevalidating: false, + }); - store.set(cacheKey, cacheStore); + store.set(cacheKey, cacheStore); + } if (onCache) { onCache({ diff --git a/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts b/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts index a57e151684fd..3bf1be809bfb 100644 --- a/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts +++ b/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts @@ -689,4 +689,162 @@ describe('cache function', () => { expect(onCacheMock).not.toHaveBeenCalled(); }); }); + + describe('unstable_shouldDisable', () => { + beforeEach(() => { + jest.useFakeTimers(); + clearStore(); + }); + + afterEach(() => { + jest.useRealTimers(); + configureCache({ maxSize: CacheSize.GB }); + }); + + it('should bypass cache when unstable_shouldDisable returns true', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const cachedFn = cache(mockFn, { maxAge: CacheTime.MINUTE }); + + configureCache({ + maxSize: CacheSize.GB, + unstable_shouldDisable: () => true, + }); + + const handler = withRequestCache(async (req: Request) => { + const result1 = await cachedFn('param1'); + const result2 = await cachedFn('param1'); + return { result1, result2 }; + }); + + await handler(new MockRequest() as unknown as Request); + + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should use cache when unstable_shouldDisable returns false', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const cachedFn = cache(mockFn, { maxAge: CacheTime.MINUTE }); + + configureCache({ + maxSize: CacheSize.GB, + unstable_shouldDisable: () => false, + }); + + const handler = withRequestCache(async (req: Request) => { + const result1 = await cachedFn('param1'); + const result2 = await cachedFn('param1'); + return { result1, result2 }; + }); + + await handler(new MockRequest() as unknown as Request); + + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('should support dynamic decision based on request', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const cachedFn = cache(mockFn, { tag: 'testTag' }); + + configureCache({ + maxSize: CacheSize.GB, + unstable_shouldDisable: ({ request }) => { + return request.url.includes('no-cache'); + }, + }); + + const handlerWithCache = withRequestCache(async (req: Request) => { + const result1 = await cachedFn('param1'); + const result2 = await cachedFn('param1'); + return { result1, result2 }; + }); + + const handlerWithoutCache = withRequestCache(async (req: Request) => { + const result1 = await cachedFn('param1'); + const result2 = await cachedFn('param1'); + return { result1, result2 }; + }); + + await handlerWithCache( + new MockRequest('http://example.com') as unknown as Request, + ); + + await handlerWithoutCache( + new MockRequest('http://example.com/no-cache') as unknown as Request, + ); + + expect(mockFn).toHaveBeenCalledTimes(3); + }); + + it('should affect no-options cache as well', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const cachedFn = cache(mockFn); + configureCache({ + maxSize: CacheSize.GB, + unstable_shouldDisable: () => true, + }); + + const handler = withRequestCache(async (req: Request) => { + const result1 = await cachedFn('param1'); + const result2 = await cachedFn('param1'); + return { result1, result2 }; + }); + + await handler(new MockRequest() as unknown as Request); + + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should support async decision function', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const cachedFn = cache(mockFn, { maxAge: CacheTime.MINUTE }); + + configureCache({ + maxSize: CacheSize.GB, + unstable_shouldDisable: async () => { + return Promise.resolve(true); + }, + }); + + const handler = withRequestCache(async (req: Request) => { + const result1 = await cachedFn('param1'); + const result2 = await cachedFn('param1'); + return { result1, result2 }; + }); + + await handler(new MockRequest() as unknown as Request); + + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should still trigger onCache callback even when cache is disabled', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const onCacheMock = jest.fn(); + + const cachedFn = cache(mockFn, { + maxAge: CacheTime.MINUTE, + onCache: onCacheMock, + }); + + configureCache({ + maxSize: CacheSize.GB, + unstable_shouldDisable: () => true, + }); + + const handler = withRequestCache(async (req: Request) => { + const result1 = await cachedFn('param1'); + const result2 = await cachedFn('param1'); + return { result1, result2 }; + }); + + await handler(new MockRequest() as unknown as Request); + + expect(mockFn).toHaveBeenCalledTimes(2); + expect(onCacheMock).toHaveBeenCalledTimes(2); + expect(onCacheMock).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'miss', + }), + ); + }); + }); });