跳到内容
返回博客

2025 年 1 月 3 日,星期五

使用 Next.js 的可组合缓存

发布者

我们正在为 Next.js 开发一个简单而强大的缓存模型。在之前的文章中,我们讨论了我们的缓存之旅,以及我们如何最终确定 'use cache' 指令。

这篇文章将讨论 'use cache' 的 API 设计和优势。

什么是 'use cache'

'use cache' 通过根据需要缓存数据或组件,使您的应用程序更快。

它是一个 JavaScript “指令”—您在代码中添加的字符串字面量—它向 Next.js 编译器发出信号,指示其进入不同的“边界”。例如,从服务器到客户端。

这与 React 指令(如 'use client''use server')的想法类似。指令是编译器指令,用于定义代码应在何处运行,从而允许框架为您优化和编排各个部分。

它是如何工作的?

让我们从一个简单的例子开始

async function getUser(id) {
  'use cache';
  let res = await fetch(`https://api.vercel.app/user/${id}`);
  return res.json();
}

在幕后,由于 'use cache' 指令,Next.js 将此代码转换为服务器函数。在编译期间,会找到此缓存条目的“依赖项”,并将其用作缓存键的一部分。

例如,id 成为缓存键的一部分。如果我们多次调用 getUser(1),我们将返回来自缓存服务器函数的记忆化输出。更改此值将在缓存中创建一个新条目。

让我们看一个示例,在服务器组件中使用缓存函数和一个闭包

function Profile({ id }) {
  async function getNotifications(index, limit) {
    'use cache';
    return await db
      .select()
      .from(notifications)
      .limit(limit)
      .offset(index)
      .where(eq(notifications.userId, id));
  }
 
  return <User notifications={getNotifications} />;
}

这个例子更难。您能找出所有需要成为缓存键一部分的依赖项吗?

参数 indexlimit 是有意义的——如果这些值发生变化,我们会选择通知的不同切片。但是,用户 id 呢?它的值来自父组件。

编译器能够理解 getNotifications 也依赖于 id,并且它的值会自动包含在缓存键中。这防止了由于缓存键中不正确或缺失的依赖项而导致的一整类缓存问题。

为什么不使用缓存函数?

让我们回顾一下最后一个例子。我们可以使用 cache() 函数而不是指令吗?

function Profile({ id }) {
  async function getNotifications(index, limit) {
    return await cache(async () => {
      return await db
        .select()
        .from(notifications)
        .limit(limit)
        .offset(index)
        // Oops! Where do we include id in the cache key?
        .where(eq(notifications.userId, id));
    });
  }
 
  return <User notifications={getNotifications} />;
}

cache() 函数无法查看闭包并看到 id 值应该成为缓存键的一部分。您需要手动指定 id 是您的键的一部分。如果您忘记这样做,或者错误地这样做,您可能会冒缓存冲突或陈旧数据的风险。

闭包可以捕获各种局部变量。一种幼稚的方法可能会意外地烘焙(或省略)您不打算使用的变量。这可能导致缓存错误的数据,或者如果敏感信息泄露到缓存键中,则可能存在缓存中毒的风险。

'use cache' 为编译器提供了足够的上下文来安全地处理闭包并正确生成缓存键。仅运行时解决方案(如 cache())将要求您手动完成所有操作——而且很容易犯错误。相比之下,指令可以进行静态分析,以可靠地处理所有底层依赖项。

如何处理非序列化的输入值?

我们有两种不同类型的输入值需要缓存

  • 可序列化:这里,“可序列化”意味着输入可以转换为稳定的、基于字符串的格式,而不会丢失含义。虽然许多人首先想到 JSON.stringify,但我们实际上使用 React 的序列化(例如,通过服务器组件)来处理更广泛的输入—包括 Promise、循环数据结构和其他复杂对象。这超出了纯 JSON 可以做到的范围。
  • 不可序列化:这些输入不属于缓存键的一部分。当我们尝试缓存这些值时,我们返回服务器“引用”。然后,Next.js 使用此引用在运行时恢复原始值。

假设我们记得将 id 包含在缓存键中

await cache(async () => {
  return await db
    .select()
    .from(notifications)
    .limit(limit)
    .offset(index)
    .where(eq(notifications.userId, id));
}, [id, index, limit]);

如果输入值可以序列化,则此方法有效。但是,如果 id 是 React 元素或更复杂的值,我们将不得不手动序列化输入键。考虑一个服务器组件,该组件根据 id prop 获取当前用户

async function Profile({ id, children }) {
  'use cache';
  const user = await getUser(id);
 
  return (
    <>
      <h1>{user.name}</h1>
      {/* Changing children doesn’t break the cache... why? */}
      {children}
    </>
  );
}

让我们逐步了解它是如何工作的

  1. 在编译期间,Next.js 看到 'use cache' 指令,并将代码转换为创建支持缓存的特殊服务器函数。编译期间不会发生缓存,而是 Next.js 正在设置运行时缓存所需的机制。
  2. 当您的代码调用“缓存函数”时,Next.js 会序列化该函数的参数。任何不能直接序列化的内容(如 JSX)都将替换为“引用”占位符。
  3. Next.js 检查给定序列化参数是否存在缓存结果。如果未找到结果,则该函数会计算要缓存的新值。
  4. 函数完成后,返回值将被序列化。返回值的不可序列化部分将变回引用。
  5. 调用缓存函数的代码反序列化输出并评估引用。这允许 Next.js 将引用与其真实对象或值交换,这意味着像 children 这样的不可序列化输入可以保留其原始的、未缓存的值。

这意味着我们可以安全地缓存 <Profile> 组件,而不缓存 children。在后续渲染中,不会再次调用 getUser()children 的值可能是动态的,也可能是具有不同缓存生命周期的单独缓存元素。这就是可组合缓存。

这看起来很熟悉…

如果您认为“这感觉与服务器和客户端组合的模式相同”—您绝对正确。这有时被称为“甜甜圈”模式

  • 甜甜圈的外部部分是处理数据获取或繁重逻辑的服务器组件。
  • 中间的是可能具有某些交互性的子组件
app/page.tsx
export default function Page() {
  return (
    <ServerComponent>
      {/* Create a hole to the client */}
      <ClientComponent />
    <ServerComponent />
  );
}

'use cache' 也是如此。甜甜圈是外部组件的缓存值,孔是在运行时填充的引用。这就是为什么更改 children 不会使整个缓存输出失效的原因。children 只是稍后填充的一些引用。

那么标记和失效呢?

您可以使用不同的配置文件定义缓存的生命周期。我们包含一组默认配置文件,但如果需要,您可以定义自己的自定义值。

async function getUser(id) {
  'use cache';
  cacheLife('hours');
  let res = await fetch(`https://api.vercel.app/user/${id}`);
  return res.json();
}

要使特定缓存条目失效,您可以标记缓存,然后调用 revalidateTag()。一种强大的模式是,您可以在获取数据(例如,来自 CMS)之后标记缓存

async function getPost(postId) {
  'use cache';
  let res = await fetch(`https://api.vercel.app/blog/${postId}`);
  let data = await res.json();
  cacheTag(postId, data.authorId);
  return data;
}

简单而强大

我们使用 'use cache' 的目标是使编写缓存逻辑既简单强大。

  • 简单: 您可以使用本地推理创建缓存条目。您无需担心全局副作用,例如忘记的缓存键条目或对代码库其他部分的意外更改。
  • 强大: 您可以缓存的不仅仅是静态可分析的代码。例如,可能在运行时更改的值,但您仍然希望在评估后缓存输出结果。

'use cache 在 Next.js 中仍然是实验性的。我们希望您在试用时尽早提供反馈。

在文档中了解更多信息.