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} />;
}
这个例子更难。您能找出所有需要成为缓存键一部分的依赖项吗?
参数 index
和 limit
是有意义的——如果这些值发生变化,我们会选择通知的不同切片。但是,用户 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}
</>
);
}
让我们逐步了解它是如何工作的
- 在编译期间,Next.js 看到
'use cache'
指令,并将代码转换为创建一个支持缓存的特殊服务器函数。编译期间不会发生缓存,而是 Next.js 正在设置运行时缓存所需的机制。 - 当您的代码调用“缓存函数”时,Next.js 会序列化函数的参数。任何无法直接序列化的内容(如 JSX)都会被替换为“引用”占位符。
- Next.js 检查给定序列化参数是否已存在缓存结果。如果未找到结果,则该函数计算要缓存的新值。
- 函数完成后,返回值将被序列化。返回值的不可序列化部分将转换回引用。
- 调用缓存函数的代码反序列化输出并评估引用。这允许 Next.js 将引用与其实际对象或值交换,这意味着像
children
这样的不可序列化输入可以保持其原始的、未缓存的值。
这意味着我们可以安全地仅缓存 <Profile>
组件,而不是子组件。在后续渲染中,不会再次调用 getUser()
。children
的值可能是动态的,也可能是具有不同缓存生命周期的单独缓存元素。这就是可组合缓存。
这看起来很熟悉……
如果您认为“这感觉就像服务器和客户端组合的相同模型”——您绝对正确。这有时被称为“甜甜圈”模式
- 甜甜圈的外层部分是一个服务器组件,用于处理数据获取或繁重的逻辑。
- 中间的孔是一个子组件,可能具有一些交互性
export default function Page() {
return (
<ServerComponent>
{/* Create a hole to the client */}
<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 中仍然是实验性的。当您对其进行测试时,我们很乐意听到您的早期反馈。