|
| 1 | +--- |
| 2 | +source-updated-at: 2025-05-29T18:05:49.000Z |
| 3 | +translation-updated-at: 2025-05-29T19:43:25.738Z |
| 4 | +title: Next.js 的可组合式缓存方案 |
| 5 | +description: 深入了解 'use cache' 的 API 设计及其优势 |
| 6 | +author: |
| 7 | + - name: Lee Robinson |
| 8 | + image: /static/team/lee.jpg |
| 9 | +date: 2025-01-03T14:00:00.507Z |
| 10 | +image: >- |
| 11 | + https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/static/blog/composable-caching/twitter-card.png |
| 12 | +--- |
| 13 | + |
| 14 | +我们正在为 Next.js 开发一套简单而强大的缓存模型。在之前的文章中,我们探讨了[缓存探索之旅](/blog/our-journey-with-caching)以及如何最终实现 `'use cache'` 指令。 |
| 15 | + |
| 16 | +本文将重点讨论 `'use cache'` 的 API 设计及其优势。 |
| 17 | + |
| 18 | +[什么是 `'use cache'`?](#what-is-use-cache) |
| 19 | +-------------------------------------------- |
| 20 | + |
| 21 | +`'use cache'` 通过按需缓存数据或组件来提升应用性能。 |
| 22 | + |
| 23 | +这是一个 JavaScript "指令"——您添加到代码中的字符串字面量——它会告知 Next.js 编译器进入不同的"边界"。例如,从服务端切换到客户端。 |
| 24 | + |
| 25 | +这与 React 的 `'use client'` 和 `'use server'` 等指令理念相似。指令是定义代码运行位置的编译器指示,让框架能为您优化和编排各个代码片段。 |
| 26 | + |
| 27 | +[工作原理](#how-does-it-work) |
| 28 | +-------------------------------------- |
| 29 | + |
| 30 | +从一个简单示例开始: |
| 31 | + |
| 32 | +``` |
| 33 | +async function getUser(id) { |
| 34 | + 'use cache'; |
| 35 | + let res = await fetch(`https://api.vercel.app/user/${id}`); |
| 36 | + return res.json(); |
| 37 | +} |
| 38 | +``` |
| 39 | + |
| 40 | +在底层,由于 `'use cache'` 指令,Next.js 会将此代码转换为服务端函数。编译期间,会找到该缓存条目的"依赖项"并将其作为缓存键的一部分。 |
| 41 | + |
| 42 | +例如,`id` 成为缓存键的一部分。如果我们多次调用 `getUser(1)`,会返回缓存服务端函数的记忆化输出。更改此值将在缓存中创建新条目。 |
| 43 | + |
| 44 | +再看一个在服务端组件中使用缓存函数和[闭包](https://v0.dev/chat/5kD47RIecQK?b=b_rCP4CvfbFFW)的例子: |
| 45 | + |
| 46 | +``` |
| 47 | +function Profile({ id }) { |
| 48 | + async function getNotifications(index, limit) { |
| 49 | + 'use cache'; |
| 50 | + return await db |
| 51 | + .select() |
| 52 | + .from(notifications) |
| 53 | + .limit(limit) |
| 54 | + .offset(index) |
| 55 | + .where(eq(notifications.userId, id)); |
| 56 | + } |
| 57 | + |
| 58 | + return <User notifications={getNotifications} />; |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +这个例子更复杂。您能找出需要作为缓存键的所有依赖项吗? |
| 63 | + |
| 64 | +参数 `index` 和 `limit` 很直观——如果这些值改变,我们会选择通知的不同分片。但用户 `id` 呢?它的值来自父组件。 |
| 65 | + |
| 66 | +编译器能理解 `getNotifications` 也依赖于 `id`,其值会自动包含在缓存键中。这避免了因缓存键中依赖项错误或缺失导致的整个缓存问题类别。 |
| 67 | + |
| 68 | +[为何不使用缓存函数?](#why-not-use-a-cache-function) |
| 69 | +-------------------------------------------------------------- |
| 70 | + |
| 71 | +回顾上一个例子。我们是否可以用 `cache()` 函数替代指令? |
| 72 | + |
| 73 | +``` |
| 74 | +function Profile({ id }) { |
| 75 | + async function getNotifications(index, limit) { |
| 76 | + return await cache(async () => { |
| 77 | + return await db |
| 78 | + .select() |
| 79 | + .from(notifications) |
| 80 | + .limit(limit) |
| 81 | + .offset(index) |
| 82 | + // 糟糕!如何将 id 包含在缓存键中? |
| 83 | + .where(eq(notifications.userId, id)); |
| 84 | + }); |
| 85 | + } |
| 86 | + |
| 87 | + return <User notifications={getNotifications} />; |
| 88 | +} |
| 89 | +``` |
| 90 | + |
| 91 | +`cache()` 函数无法查看闭包并识别 `id` 值应作为缓存键的一部分。您需要手动指定 `id` 为键的一部分。如果忘记或操作不当,可能导致缓存冲突或数据过时。 |
| 92 | + |
| 93 | +闭包可以捕获各种局部变量。简单处理可能会意外包含(或遗漏)您未预期的变量。这会导致缓存错误数据,或者如果敏感信息泄露到缓存键中,可能引发缓存污染风险。 |
| 94 | + |
| 95 | +`'use cache'` 为编译器提供了足够上下文来安全处理闭包并正确生成缓存键。纯运行时方案(如 `cache()`)需要您手动完成所有操作——很容易出错。相比之下,指令可通过静态分析可靠处理所有底层依赖。 |
| 96 | + |
| 97 | +[如何处理不可序列化的输入值?](#how-are-non-serialized-input-values-handled) |
| 98 | +-------------------------------------------------------------------------------------------- |
| 99 | + |
| 100 | +我们有两种不同类型的缓存输入值: |
| 101 | + |
| 102 | +* **可序列化**:指输入可转换为稳定、基于字符串的格式且不丢失意义。虽然很多人首先想到 `JSON.stringify`,但我们实际使用 React 的序列化(如通过服务端组件)处理更广泛的输入——包括 Promise、循环数据结构和其它复杂对象。这超出了普通 JSON 的能力范围。 |
| 103 | +* **不可序列化**:这些输入不是缓存键的一部分。尝试缓存这些值时,我们会返回服务端"引用"。Next.js 在运行时使用该引用恢复原始值。 |
| 104 | + |
| 105 | +假设我们记得在缓存键中包含 `id`: |
| 106 | + |
| 107 | +``` |
| 108 | +await cache(async () => { |
| 109 | + return await db |
| 110 | + .select() |
| 111 | + .from(notifications) |
| 112 | + .limit(limit) |
| 113 | + .offset(index) |
| 114 | + .where(eq(notifications.userId, id)); |
| 115 | +}, [id, index, limit]); |
| 116 | +``` |
| 117 | + |
| 118 | +如果输入值可序列化,这能正常工作。但如果 `id` 是 React 元素或更复杂的值,我们就需要手动序列化输入键。考虑一个基于 `id` 属性获取当前用户的服务端组件: |
| 119 | + |
| 120 | +``` |
| 121 | +async function Profile({ id, children }) { |
| 122 | + 'use cache'; |
| 123 | + const user = await getUser(id); |
| 124 | + |
| 125 | + return ( |
| 126 | + <> |
| 127 | + <h1>{user.name}</h1> |
| 128 | + {/* 更改 children 不会破坏缓存...为什么? */} |
| 129 | + {children} |
| 130 | + </> |
| 131 | + ); |
| 132 | +} |
| 133 | +``` |
| 134 | + |
| 135 | +逐步解析其工作原理: |
| 136 | + |
| 137 | +1. 编译期间,Next.js 看到 `'use cache'` 指令并将代码转换为支持缓存的特殊服务端函数。编译时不进行缓存,而是 Next.js 建立运行时缓存所需的机制。 |
| 138 | +2. 当代码调用"缓存函数"时,Next.js 序列化函数参数。任何不能直接序列化的内容(如 JSX)会被替换为"引用"占位符。 |
| 139 | +3. Next.js 检查给定序列化参数是否存在缓存结果。如果未找到结果,函数会计算新值进行缓存。 |
| 140 | +4. 函数完成后,返回值被序列化。返回值的不可序列化部分会转换回引用。 |
| 141 | +5. 调用缓存函数的代码反序列化输出并评估引用。这使得 Next.js 能用实际对象或值替换引用,意味着像 `children` 这样的不可序列化输入可以保持其原始、未缓存的值。 |
| 142 | + |
| 143 | +这意味着我们可以安全地仅缓存 `<Profile>` 组件而不缓存子组件。在后续渲染中,不会再次调用 `getUser()`。`children` 的值可能是动态的,或是具有不同缓存生命周期的单独缓存元素。这就是可组合式缓存。 |
| 144 | + |
| 145 | +[似曾相识...](#this-seems-familiar) |
| 146 | +-------------------------------------------- |
| 147 | + |
| 148 | +如果您觉得"这感觉与服务端和客户端组合的模式相同"——完全正确。这有时被称为"甜甜圈"模式: |
| 149 | + |
| 150 | +* **外层**甜甜圈是处理数据获取或重型逻辑的服务端组件 |
| 151 | +* **中间**孔洞是可能具有交互性的子组件 |
| 152 | + |
| 153 | +```tsx filename="app/page.tsx" |
| 154 | +export default function Page() { |
| 155 | + return ( |
| 156 | + <ServerComponent> |
| 157 | + {/* 创建一个通向客户端的孔洞 */} |
| 158 | + <ClientComponent /> |
| 159 | + <ServerComponent /> |
| 160 | + ); |
| 161 | +} |
| 162 | +``` |
| 163 | + |
| 164 | +`'use cache'` 同理。甜甜圈是外层组件的缓存值,孔洞是在运行时填充的引用。这就是为什么更改 `children` 不会使整个缓存输出失效。子组件只是稍后填充的引用。 |
| 165 | + |
| 166 | +[标签与失效机制](#what-about-tagging-and-invalidation) |
| 167 | +---------------------------------------------------------------------------- |
| 168 | + |
| 169 | +您可以通过不同[配置文件](/docs/app/api-reference/functions/cacheLife)定义缓存生命周期。我们提供一组默认配置,但您也可以根据需要定义自定义值。 |
| 170 | + |
| 171 | +``` |
| 172 | +async function getUser(id) { |
| 173 | + 'use cache'; |
| 174 | + cacheLife('hours'); |
| 175 | + let res = await fetch(`https://api.vercel.app/user/${id}`); |
| 176 | + return res.json(); |
| 177 | +} |
| 178 | +``` |
| 179 | + |
| 180 | +要使特定缓存条目失效,您可以[标记缓存](/docs/app/api-reference/functions/cacheTag)然后调用 `revalidateTag()`。一个强大模式是您可以在获取数据后(例如从 CMS)标记缓存: |
| 181 | + |
| 182 | +``` |
| 183 | +async function getPost(postId) { |
| 184 | + 'use cache'; |
| 185 | + let res = await fetch(`https://api.vercel.app/blog/${postId}`); |
| 186 | + let data = await res.json(); |
| 187 | + cacheTag(postId, data.authorId); |
| 188 | + return data; |
| 189 | +} |
| 190 | +``` |
| 191 | + |
| 192 | +[简单而强大](#simple-and-powerful) |
| 193 | +------------------------------------------- |
| 194 | + |
| 195 | +我们设计 `'use cache'` 的目标是让缓存逻辑编写既简单又强大。 |
| 196 | + |
| 197 | +* **简单**:您可以通过局部推理创建缓存条目。无需担心全局副作用,如遗忘缓存键条目或意外更改代码库其它部分。 |
| 198 | +* **强大**:您可以缓存不仅仅是静态可分析代码。例如,运行时可能变化的值,但您仍希望缓存评估后的输出结果。 |
| 199 | + |
| 200 | +`'use cache` 在 Next.js 中仍处于**实验阶段**。我们期待您试用后的早期反馈。 |
| 201 | + |
| 202 | +[查阅文档了解更多](/docs/app/api-reference/directives/use-cache)。 |
0 commit comments