数据缓存

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> {
    const value = await this.redis.get(key);
    return value ? JSON.parse(value) : null;
  }

  async set(
    key: string,
    value: string,
    options?: { ttl?: number },
  ): Promise<'OK'> {
    if (options?.ttl) {
      return this.client.set(key, JSON.stringify(value), 'EX', options.ttl);
    }
    return this.client.set(key, JSON.stringify(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 参数(单位为秒)。