数据获取与缓存
这一课你将能做到
- 在 Server Component 里直接
async/await fetch 拉取后端数据并渲染,不再需要 useEffect
- 说出 Next.js 三层缓存的分工,会配置
revalidate、no-store 与按需失效
- 把「对接 Spring Boot REST API」的完整取数链路写出来(本站 platform 的真实做法)
为什么要学
老 React 取数据的标准姿势是:组件挂载 → useEffect 发请求 → 三个状态变量(data/loading/error)→ 处理竞态。每个页面重复一遍,代码又长又容易出 bug。
App Router 把这套东西砍了:组件就是 async 函数。
// 过去:useState + useEffect 三件套,20 行
// 现在:
const res = await fetch("http://localhost:8080/api/tracks");
const tracks = await res.json();
数据在服务器上取好、和 HTML 一起送达,用户端没有 loading 闪烁,爬虫直接拿到内容。代价是你要理解缓存——服务端取数意味着每次渲染都打一次后端,不打缓存的话,高流量下后端会被打爆。所以「数据获取」和「缓存策略」在本课是同一件事的两面。
面试中「Next.js 的缓存机制」「fetch 什么时候重新请求」「SSG 数据更新了怎么办」是持续高频的题目,本课给出能背的标准答案。
先建立图像
先看完整链路,了解数据从哪来到哪去(本站同款架构):
浏览器 ──请求 /tracks/xxx──→ Next.js 服务器
│
├─ ① 查 Next.js 数据缓存(Data Cache)
│ 命中 → 直接用,不打后端
│ 未命中 ↓
├─ ② fetch → Spring Boot /api/tracks/xxx
│ 拿到 JSON,按策略写入缓存
↓
渲染成 HTML → 返回浏览器
fetch 缓存策略速查表(重点背这张):
| 写法 | 行为 | 适用 |
|---|
fetch(url) | Next 14 默认缓存;Next 15 起默认不缓存 | 视版本 |
fetch(url, { cache: "force-cache" }) | 强制缓存(构建时/首次取后复用) | 内容稳定:课程目录、配置 |
fetch(url, { next: { revalidate: 3600 } }) | 缓存 3600 秒,过期后台刷新(ISR) | 定期更新:文章、榜单 |
fetch(url, { cache: "no-store" }) | 每次请求都现打后端 | 实时数据:用户进度、库存 |
三层缓存的分工(面试标准图):
Request Memoization 请求记忆:同一次渲染内,相同 fetch 只发一次(自动,无需配置)
Data Cache 数据缓存:跨请求持久,revalidate 控制寿命
Router Cache 路由缓存:浏览器端缓存已访问过的路由段,点返回键秒开
跟着做
步骤 1:最基础的 Server 端取数
新建 src/app/tracks-demo/page.tsx:
interface Track {
slug: string;
title: string;
lessonCount: number;
}
export default async function TracksPage() {
const res = await fetch("http://localhost:8080/api/tracks");
if (!res.ok) throw new Error("后端接口异常");
const tracks: Track[] = await res.json();
return (
<main className="p-6">
<h1 className="text-2xl font-bold">课程列表</h1>
<ul className="mt-4 space-y-2">
{tracks.map((t) => (
<li key={t.slug} className="rounded border p-3">
<span className="font-medium">{t.title}</span>
<span className="ml-2 text-sm text-gray-500">{t.lessonCount} 课时</span>
</li>
))}
</ul>
</main>
);
}
启动一个能返回 /api/tracks 的后端(或先用假接口),访问页面。没有 loading、没有 useEffect——数据是「渲染时就有」的。
步骤 2:像本站一样封装 API 层
本站 platform 用 src/lib/api.ts 统一封装所有后端调用,页面永远不裸写 fetch。仿写一份 src/lib/api.ts:
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { "Content-Type": "application/json" },
...init,
});
if (!res.ok) {
throw new Error(`API ${res.status}: ${path}`);
}
return res.json() as Promise<T>;
}
export function getTracks() {
return request<{ slug: string; title: string }[]>("/api/tracks", {
next: { revalidate: 300 }, // 目录类数据:缓存 5 分钟
});
}
export function getTrackDetail(slug: string, token?: string) {
return request(`/api/tracks/${slug}`, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
cache: "no-store", // 带登录态的数据:绝不缓存
});
}
import { getTracks } from "@/lib/api";
export default async function TracksPage() {
const tracks = await getTracks();
// ...渲染
}
好处:缓存策略集中在 lib 层管理、后端地址换环境不用改页面、TS 类型一处定义。第 11 课读 platform 源码时你会看到一模一样的模式。
步骤 3:验证缓存效果
// 服务端打印,注意别在 build 时误触发
export default async function Page() {
console.log("页面渲染于", new Date().toISOString());
const tracks = await getTracks();
return <p>共 {tracks.length} 门课</p>;
}
带 revalidate: 300 的版本:反复刷新,后端 5 分钟内只会收到一次请求;no-store 版本:每次刷新后端都收到请求。肉眼验证一次,缓存概念就落地了。
步骤 4:按需失效——revalidateTag
场景:管理员改了课程数据,希望立刻生效,而不是等 5 分钟。给请求打标签:
export function getTracks() {
return request("/api/tracks", {
next: { revalidate: 300, tags: ["tracks"] },
});
}
在变更发生的地方(通常是 Route Handler 或 Server Action)触发失效:
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
// src/app/api/admin/refresh/route.ts
export async function POST() {
revalidateTag("tracks"); // 所有带 tags:["tracks"] 的缓存立即作废
return NextResponse.json({ ok: true });
}
下一次访问页面就会重新打后端。revalidatePath("/tracks") 则是按路径失效整个页面。这一对 API 是「SSG 页面如何更新」的标准答案。
步骤 5:请求记忆(Request Memoization)的实战意义
// layout.tsx 里
const user = await getUser(token);
// page.tsx 里(同一个请求周期)
const user2 = await getUser(token); // 不会重复发请求
Next 自动对同一渲染内的相同 GET fetch去重。所以你可以在 layout 和 page 各自声明自己需要的数据,不用层层传 props——去重交给框架。注意:它只作用于单次渲染,和 Data Cache 是两个机制。
讲清楚要点
- 服务端取数的默认心智:数据获取发生在渲染时(服务器上),直接
await,拿到即可渲染;loading 态交给 loading.tsx 骨架页,而不是组件内的布尔值。
- 缓存策略必须在写代码时就明确:默认行为随版本变过(14 缓存、15 不缓存),所以工程惯例是显式声明——要么
revalidate 要么 no-store,不要裸 fetch 赌默认值。本站对「公开课程数据」用缓存、对「用户进度」用 no-store,就是按数据新鲜度分级。
- 带 Cookie/Token 的请求一律 no-store:缓存是「按 URL」共享的,如果 A 用户的登录态响应被缓存给了 B 用户,就是数据泄露事故。个人数据、鉴权后数据、强实时数据(支付、库存)都用
cache: "no-store"。
- 失效三板斧:时间到自动失效(
revalidate: N);代码主动失效(revalidateTag/revalidatePath);部署即全量失效(每次 npm run build 产物全新)。生产排错先问「这页的缓存哪一层没失效」。
- 错误处理:服务端取数失败用
throw + 上层 error.tsx 兜底,或对非关键数据 try/catch 给默认值降级。notFound() 用于「数据不存在」的 404 语义。
你可能会懵的地方
-
Next 15 默认不缓存了,那这门课讲的缓存还有意义吗?
有。15 只是改了「默认值」,force-cache/revalidate/revalidateTag 全部可用且仍是性能主力。面试问的是机制,写代码时显式声明策略即可无视版本默认值差异。
-
revalidate 到期后,下一个用户要等刷新吗?
不等。Next 用 stale-while-revalidate 策略:过期后第一个请求立即返回旧缓存,同时后台异步重新拉取,拿到新数据后替换缓存——后续请求者见新数据。用户几乎无感。
-
useEffect 取数据是不是彻底没用了?
Server 组件里用不了它;但纯 Client 场景仍然用:依赖用户输入的搜索联想、轮询、下拉分页这类「浏览器事件驱动」的取数,依然走 useEffect 或 SWR/React Query。原则:首屏需要的数据服务端取,交互后需要的数据客户端取。
-
缓存会缓存我的登录态请求吗?
只要你显式 no-store 就不会。另外 Next 会自动给带 Cookie/Authorization 头的 fetch 关闭缓存(动态渲染),但别依赖自动行为,显式声明最安全。
-
为什么本站首页要区分 NEXT_PUBLIC_API_URL 和 API_INTERNAL_URL?
浏览器端代码(Client 组件)只能走公网域名;服务端 SSR 取数时走容器内网地址,少一层 Nginx/SSL 开销且更稳。platform 的 src/lib/apiBase.ts 判断 typeof window === "undefined" 来分流——服务端环境变量体系的一个真实应用。
错法对照
| 错法 | 问题 | 正解 |
|---|
在 Client 组件里 await fetch | Client 不能 async | 首屏数据上移到 Server 组件 |
| 带 token 的请求用了默认缓存 | 可能串号:A 的数据给 B | cache: "no-store" |
| 改了后端数据,页面死活不更新 | 被缓存挡住 | 加 revalidate 或用 revalidateTag 主动失效 |
fetch 后不检查 res.ok | 后端 500 时拿到的是错误 JSON,页面渲染出诡异空白 | if (!res.ok) throw new Error(...) |
页面里到处裸写 fetch("http://localhost:8080") | 环境一换全炸 | 集中封装到 lib/api.ts,用环境变量 |
卡点急救
- 开发环境数据改了页面不更新:dev 模式也有缓存(尤其加了 revalidate)。临时办法:重启 dev;正式办法:该请求改
no-store 或用 revalidateTag。
- 报错
fetch failed(ECONNREFUSED):后端没起或地址错。检查 Spring Boot 是否跑在 8080、环境变量是否写了 http:// 前缀、容器里是否能访问该主机名。
- 页面 TS 报错
Type 'any' is not assignable:fetch 的返回默认 any。给接口写 interface(步骤 1 的 Track),或让 request<T> 泛型兜住——顺便这也是面试里「你们项目怎么做类型安全」的素材。
面试常问
-
Next.js 有哪几层缓存?分别解决什么问题?
四层:Request Memoization 在单次渲染内去重相同 GET 请求;Data Cache 跨请求缓存 fetch 结果,由 revalidate 控制寿命,解决「别把后端打爆」;Full Route Cache 缓存整页渲染结果,配合 ISR 更新,解决「别每次都重新渲染」;Router Cache 在浏览器缓存路由段,解决「前进后退与导航秒开」。失效手段统一是 revalidateTag / revalidatePath。
-
fetch 的缓存怎么配置?数据更新了如何让页面立刻刷新?
显式传 cache: "force-cache" | "no-store" 或 next: { revalidate: 秒数, tags: [...] }。立即刷新用 revalidateTag("tag") 精确失效某类数据,或 revalidatePath("/path") 失效整页。revalidate 过期后走 SWR 语义:先回旧值、后台刷新。
-
你们项目前后端怎么配合取数?缓存怎么分级?
Next.js 作为前端层通过 REST 调 Spring Boot;lib 层统一封装 fetch 与类型。公开且低频变化的数据(课程目录)加 revalidate 缓存;用户维度数据(进度、权限、练习记录)带 token 且 no-store;SSR 服务端走内网 API 地址降低延迟。这个回答直接对应本站 platform 的真实实现。
自测三问
revalidate: 300 和 cache: "no-store" 分别用在什么数据上?各举一个本站例子。
- 管理员改完课程标题,有哪些手段让前台立刻生效?
- Request Memoization 和 Data Cache 的区别是什么?
小结一句话
Server Component 让取数变成一行 await,而工程难点转移到了缓存:显式声明策略、公开数据缓存、个人数据不缓存、用 revalidateTag 精确失效。
延伸