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 的序列化(例如,通过服务器组件)来处理更广泛的输入——包括 Promises、循环数据结构和其他复杂对象。这超出了纯 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>
{/* 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 中仍处于实验阶段。我们期待您在测试时提供早期反馈。