数据缓存

cache 函数可以让你缓存数据获取或计算的结果,相比整页渲染缓存,它提供了更精细的数据粒度控制,并且适用于客户端渲染(CSR)、服务端渲染(SSR)、API 服务(BFF)等多种场景。

INFO

需要 x.65.5 及以上版本

基本用法

NOTE

如果在 BFF 中使用 cache 函数,应该从 @modern-js/server-runtime/cache 导入相关函数

import { cache } from '@modern-js/server-runtime/cache'

import { cache } from '@modern-js/runtime/cache';
import { fetchUserData } from './api';

const getUser = cache(fetchUserData);

const loader = async () => {
  const user = await getUser(user); // 函数入参发生变化时,函数会重新执行
  return {
    user,
  };
};

参数

  • fn: 需要缓存的数据获取或计算的函数
  • options(可选): 缓存配置
    • tag: 用于标识缓存的标签,可以基于这个标签使缓存失效
    • maxAge: 缓存的有效期 (毫秒)
    • revalidate: 重新验证缓存的时间窗口(毫秒),与 HTTP Cache-Control 的 stale-while-revalidate 功能一致
    • getKey: 简化的缓存键生成函数,根据函数参数生成缓存键
    • customKey: 自定义缓存键生成函数,用于在函数引用变化时保持缓存

options 参数的类型如下:

interface CacheOptions {
  tag?: string | string[];
  maxAge?: number;
  revalidate?: number;
  getKey?: <Args extends any[]>(...args: Args) => string;
  customKey?: <Args extends any[]>(options: {
    params: Args;
    fn: (...args: Args) => any;
    generatedKey: string;
  }) => string | symbol;
}

返回值

cache 函数会返回一个新的函数,该函数有缓存的能力,多次调用该函数,不会重复执行 fn 函数。

使用范围

与 react 的 cache 函数只能在 server component 组件中使用不同, EdenX 提供的 cache 函数可以在任意的前端或服务端的代码中使用。

详细用法

options 参数

当无 options 参数传入时,主要可以用于 SSR 项目,缓存的生命周期是单次 ssr 渲染的请求,如可以在多个 data loader 中调用同一个 cachedFn 时,不会重复执行 cachedFn 函数。这样可以在不同的 data loader 中共享数据,同时避免重复的请求,EdenX 会在每次收到服务端请求时,重新执行 fn 函数。

INFO

options 参数时,可以看作是 react cache 函数的替代品,可以在任意服务端代码中使用(比如可以在 SSR 项目的 data loader 中),不局限于 server component。

import { cache } from '@modern-js/runtime/cache';
import { fetchUserData } from './api';

const getUser = cache(fetchUserData);

const loader = async () => {
  const user = await getUser();
  return {
    user,
  };
};

options 参数

maxAge 参数

每次计算完成后,框架会记录写入缓存的时间,当再次调用该函数时,会根据 maxAge 参数判断缓存是否过期,如果过期,则重新执行 fn 函数,否则返回缓存的数据。

import { cache, CacheTime } from '@modern-js/runtime/cache';

const getDashboardStats = cache(
  async () => {
    return await fetchComplexStatistics();
  },
  {
    maxAge: CacheTime.MINUTE * 2,  // 在 2 分钟内调用该函数会返回缓存的数据
  }
);

revalidate 参数

revalidate 参数用于设置缓存过期后,重新验证缓存的时间窗口,可以和 maxAge 参数一起使用,类似与 HTTP Cache-Control 的 stale-while-revalidate 模式。

如以下示例,在缓存未过期的 2分钟内,如果调用 getDashboardStats 函数,会返回缓存的数据,如果缓存过期,2分到3分钟内,收到的请求会先返回旧数据,然后后台会重新请求数据,并更新缓存。

import { cache, CacheTime } from '@modern-js/runtime/cache';

const getDashboardStats = cache(
  async () => {
    return await fetchComplexStatistics();
  },
  {
    maxAge: CacheTime.MINUTE * 2,
    revalidate: CacheTime.MINUTE * 1,
  }
);

tag 参数

tag 参数用于标识缓存的标签,可以传入一个字符串或字符串数组,可以基于这个标签使缓存失效,多个缓存函数可以使用一个标签。

import { cache, revalidateTag } from '@modern-js/runtime/cache';

const getDashboardStats = cache(
  async () => {
    return await fetchDashboardStats();
  },
  {
    tag: 'dashboard',
  }
);

const getComplexStatistics = cache(
  async () => {
    return await fetchComplexStatistics();
  },
  {
    tag: 'dashboard',
  }
);

await revalidateTag('dashboard-stats'); // 会使 getDashboardStats 函数和 getComplexStatistics 函数的缓存都失效

getKey 参数

getKey 参数用于简化缓存键的生成方式,例如你可能只需要依赖函数参数的一部分来区分缓存。它是一个函数,接收与原始函数相同的参数,返回一个字符串。

它的返回值会作为参数部分参与最终缓存键的生成,但最终的键仍然会包含函数的唯一标识,因此缓存是函数级别的。

import { cache, CacheTime } from '@modern-js/runtime/cache';
import { fetchUserData } from './api';

const getUser = cache(
  async (userId, options) => {
    // 这里 options 可能包含很多配置,但我们只想根据 userId 缓存
    return await fetchUserData(userId, options);
  },
  {
    maxAge: CacheTime.MINUTE * 5,
    // 只使用第一个参数(userId)作为缓存键
    getKey: (userId, options) => userId,
  }
);

// 下面两次调用会共享缓存,因为 getKey 只使用了 userId
await getUser(123, { language: 'zh' });
await getUser(123, { language: 'en' }); // 命中缓存,不会重新请求

// 不同的 userId 会使用不同的缓存
await getUser(456, { language: 'zh' }); // 不会命中缓存,会重新请求

你也可以使用 Modern.js 提供的 generateKey 函数配合 getKey 生成缓存的键:

INFO

Modern.js 中的 generateKey 函数确保即使对象属性顺序发生变化,也能生成一致的唯一键值,保证稳定的缓存

import { cache, CacheTime, generateKey } from '@modern-js/runtime/cache';
import { fetchUserData } from './api';

const getUser = cache(
  async (userId, options) => {
    return await fetchUserData(userId, options);
  },
  {
    maxAge: CacheTime.MINUTE * 5,
    getKey: (userId, options) => generateKey(userId),
  }
);

customKey 参数

customKey 参数用于完全定制缓存的键,它是一个函数,接收一个包含以下属性的对象,返回值必须是字符串类型。

它的返回值将直接作为最终的缓存键,覆盖了默认的函数标识和参数组合。这允许你创建全局唯一的缓存键,从而实现跨函数共享缓存。

  • params:调用缓存函数时传入的参数数组
  • fn:原始被缓存的函数引用
  • generatedKey:框架基于入参自动生成的原始缓存键
INFO

一般在以下场景,缓存会失效:

  1. 函数的入参发生变化
  2. 不满足 maxAge 条件
  3. 调用了 revalidateTag

默认情况下,框架会基于函数的字符串表示生成稳定的函数 ID,并与生成的参数键组合。customKey 可以用于完全控制缓存键的生成,特别适用于在不同函数实例间共享缓存。如果只是需要自定义参数如何转换为缓存键,推荐使用 getKey

这在某些场景下非常有用,比如当你希望在不同函数实例间共享缓存,或者需要可预测的缓存键用于外部缓存管理时。

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]}`;
    },
  }
);

// 现在你可以在不同的函数实现间共享缓存
await getUserA(123); // 获取数据并使用键 "user-123" 缓存
await getUserB(123); // 缓存命中,返回缓存的数据

// 可以利用 generatedKey 参数在默认键的基础上进行修改
const getUserC = cache(
  fetchUserData,
  {
    customKey: ({ generatedKey }) => `prefix-${generatedKey}`,
  }
);

// 用于可预测的缓存键,便于外部管理
const getUserD = cache(
  async (userId: string) => {
    return await fetchUserData(userId);
  },
  {
    maxAge: CacheTime.MINUTE * 5,
    customKey: ({ params }) => `app:user:${params[0]}`,
  }
);

onCache 参数

onCache 参数允许你跟踪缓存统计信息,例如命中率。这是一个回调函数,接收有关每次缓存操作的信息,包括状态、键、参数和结果。

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 参数时被调用。当使用无 options 的缓存函数时,不会调用 onCache 回调。

onCache 回调对以下场景非常有用:

  • 监控缓存性能
  • 计算命中率
  • 记录缓存操作
  • 实现自定义指标

存储

默认存储

目前不管是客户端还是服务端,缓存都存储在内存中,默认情况下所有缓存函数共享的存储上限是 1GB,当达到存储上限后,使用 LRU 算法移除旧的缓存。

INFO

考虑到 cache 函数缓存的结果内容不会很大,所以目前默认都存储在内存中

可以通过 configureCache 函数指定缓存的存储上限:

import { configureCache, CacheSize } from '@modern-js/runtime/cache';

configureCache({
  maxSize: CacheSize.MB * 10, // 10MB
});

自定义存储器

除了默认的内存存储,你还可以使用自定义的存储容器,例如 Redis、文件系统、数据库等。这样可以实现跨进程、跨服务器的缓存共享。

Container 接口

自定义存储容器需要实现 Container 接口:

interface Container {
  get: (key: string) => Promise<string | undefined | null>;
  set: (key: string, value: string, options?: { ttl?: number }) => Promise<any>;
  has: (key: string) => Promise<boolean>;
  delete: (key: string) => Promise<boolean>;
  clear: () => Promise<void>;
}
基本使用
import { configureCache } from '@modern-js/runtime/cache';

// 使用自定义存储容器
configureCache({
  container: customContainer,
});
使用customKey 确保缓存键稳定性

:::warning 重要建议 当使用自定义存储容器(如 Redis)时,建议配置 customKey 来确保缓存键的稳定性。这样可以确保:

  1. 跨进程共享:不同服务器实例能够共享相同的缓存
  2. 应用重启后缓存有效:重启应用后仍能命中之前的缓存
  3. 代码部署后缓存保持:代码更新后相同逻辑的缓存仍然有效 :::

默认的缓存键生成机制基于函数引用,在分布式环境中可能不够稳定。建议使用 customKey 提供稳定的缓存键:

import { cache, configureCache } from '@modern-js/runtime/cache';

// 配置 Redis 容器
configureCache({
  container: redisContainer,
});

// 推荐:使用 customKey 确保键的稳定性
const getUser = cache(
  async (userId: string) => {
    return await fetchUserData(userId);
  },
  {
    maxAge: CacheTime.MINUTE * 5,
    // 使用被缓存函数相关的稳定标识符作为缓存键
    customKey: () => `fetchUserData`,
  }
);
Redis 存储示例

以下是一个使用 Redis 作为存储后端的示例:

import { Redis } from 'ioredis';
import { Container, configureCache } from '@modern-js/runtime/cache';

class RedisContainer implements Container {
  private client: Redis;

  constructor(client: Redis) {
    this.client = client;
  }

  async get(key: string): Promise<string | null> {
    return this.client.get(key);
  }

  async set(
    key: string,
    value: string,
    options?: { ttl?: number },
  ): Promise<'OK'> {
    if (options?.ttl) {
      return this.client.set(key, value, 'EX', options.ttl);
    }
    return this.client.set(key, value);
  }

  async has(key: string): Promise<boolean> {
    const result = await this.client.exists(key);
    return result === 1;
  }

  async delete(key: string): Promise<boolean> {
    const result = await this.client.del(key);
    return result > 0;
  }

  async clear(): Promise<void> {
    // 注意:在生产环境中要谨慎使用,这会清空整个 Redis 数据库
    // 更好的实现方式是使用键前缀,然后删除匹配该前缀的所有键
    await this.client.flushdb();
  }
}

// 配置 Redis 存储
const redisClient = new Redis({
  host: 'localhost',
  port: 6379,
});

configureCache({
  container: new RedisContainer(redisClient),
});
注意事项
  1. 序列化:所有的缓存数据都会被序列化为字符串存储,容器只需要处理字符串的存取操作。

  2. TTL 支持:如果你的存储后端支持 TTL(生存时间),可以在 set 方法中使用 options.ttl 参数(单位为秒)。