Back返回博客

Next.js 的可组合式缓存方案

深入了解 'use cache' 的 API 设计及其优势

我们正在为 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)
        // 糟糕!如何将 id 包含在缓存键中?
        .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 属性获取当前用户的服务端组件:

async function Profile({ id, children }) {
  'use cache';
  const user = await getUser(id);
 
  return (
    <>
      <h1>{user.name}</h1>
      {/* 更改 children 不会破坏缓存...为什么? */}
      {children}
    </>
  );
}

逐步解析其工作原理:

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

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

似曾相识...

如果您觉得"这感觉与服务端和客户端组合的模式相同"——完全正确。这有时被称为"甜甜圈"模式:

  • 外层甜甜圈是处理数据获取或重型逻辑的服务端组件
  • 中间孔洞是可能具有交互性的子组件
app/page.tsx
export default function Page() {
  return (
    <ServerComponent>
      {/* 创建一个通向客户端的孔洞 */}
      <ClientComponent />
    <ServerComponent />
  );
}

'use cache' 同理。甜甜圈是外层组件的缓存值,孔洞是在运行时填充的引用。这就是为什么更改 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 中仍处于实验阶段。我们期待您试用后的早期反馈。

查阅文档了解更多