Next.js 14服务器组件数据传递:RSC传值、序列化与缓存实战
2026/9/10 1:57:45 网站建设 项目流程

我在做车间环境监测面板的时候,第一次真切体会到 Next.js 14 的服务器组件(Server Component)跟以前 Pages Router 的“页面级数据获取”完全是两套思维。需求本身不复杂:十几个温湿度传感器,列表页展示实时温度,点进详情页能看到一小时、一天、一周的历史曲线。麻烦的是,温度数据要在不同的服务器组件之间传来传去——列表页传给卡片组件、卡片组件传给详情页、详情页内部还要根据查询参数动态取历史数据。一旦全部切到 App Router + RSC 写法,我发现很多过去根本不用关心的边界问题:函数传不了、Date 会变成字符串、服务器组件间重复请求同一份温度数据没人帮你“合并”。这篇文章把我在真实项目里踩过的坑、验证过的传值路径和一份可直接抄的完整实例整理出来,给正在迁移到 Next.js 14 服务器组件的人做个参考。

1. 服务器组件模型下,“传温度”不再只是 props 的事

1.1 一个温度面板引发的疑问

老项目用 Pages Router 的时候,数据流很直观:getServerSideProps在页面入口一次性把温度数据查好,然后整棵页面树随便传。到了 Next.js 14 App Router 里,组件默认是服务器组件,理论上依然可以“从上往下传 props”,但渲染模型已经变了:每个服务器组件都可以自己async、自己await数据,不再强制“数据在页面入口统一取好再下发”。

这个变化听着是解放,实际用起来却是新问题。我维护的温度监控面板里,存在这么三种“服务器组件间传递”的场景:

  • 列表页要同时渲染几十张温度卡片,每张卡片都需要传感器信息和最新温度。
  • 点击某张卡片进入详情页,详情页要拿到传感器 ID 和时间范围,再查历史曲线。
  • 详情页里有“实时温度”“今日均值”“告警次数”三个区块,它们内部依赖同一份底层数据,但被拆成了三个独立组件。

按 Pages Router 的惯性,我会写一个getServerSideProps,把这三个场景的数据全查好,再一级一级传下去。但在 RSC 模型下,三个区块完全可以各自await getSensorData(sensorId),让框架层面去“记忆化”重复请求。这时候的核心矛盾就变了:不是怎么组织数据获取,而是怎么控制和共享数据获取。温度数据作为串联全场的载体,正好能把这些机制逼出来。

1.2 RSC 渲染模型给“数据传递”带来的三个根本变化

第一个变化是 props 必须可序列化。服务器组件之间传递的数据,最终会参与 React Server Component 的序列化协议。字符串、数字、普通对象、数组没问题;函数、类实例这类带运行时行为的东西不能当 props 传。这个约束很多人刚接触时不适应,因为在 Pages Router 里服务端代码随便传引用,根本不报错。

第二个变化是数据获取可以“就近声明,全局复用”。服务器组件树在同一个请求内渲染时,fetch默认会被记忆化(memoization)。多个组件请求同一个 URL,实际只发一次。自定义查询函数只要用 React 的cache()包一层,也能达到同样效果。这相当于把“手动提升状态”这件事自动化了。

第三个变化是传值路径变多了。以前无非是 props、context、全局 store;现在多了 URL 路由参数、cookies()headers()、数据缓存、请求记忆化。每种路径的生命周期和可见范围都不同——请求级、用户级、跨请求级错开。把温度数据放错地方,轻则数据不新鲜,重则把整个页面拖成动态渲染,性能雪崩。

所以标题里说“温度传递”,其实涵盖了这整条链路:服务器组件之间怎么传、传一份还是传多份、传到什么边界为止、缓存多久。下面我用实际代码逐个拆。

2. 三种最常见的服务器组件传值路径,我用温度场景逐一验证

2.1 组件树内传 props:传感器卡片组件如何拿到温度

最直接的传递方式,是把数据从外层服务器组件传给内层服务器组件。拿我的仪表盘来说,设备列表和温度卡片都是服务器组件:

// app/dashboard/page.tsx import SensorList from "./SensorList"; import { getSensorsWithTemperature } from "@/lib/telemetry"; export default async function DashboardPage() { const sensors = await getSensorsWithTemperature(); return ( <main> <h1>车间温度总览</h1> <SensorList sensors={sensors} /> </main> ); }
// app/dashboard/SensorList.tsx import Link from "next/link"; import TemperatureCard from "./TemperatureCard"; import type { SensorWithTemperature } from "@/lib/telemetry"; export default function SensorList({ sensors, }: { sensors: SensorWithTemperature[]; }) { return ( <ul className="grid gap-4"> {sensors.map((sensor) => ( <li key={sensor.id}> <TemperatureCard sensor={sensor} /> </li> ))} </ul> ); }

这里有个容易忽略的细节:SensorList即使不async也能接收 props,但TemperatureCard如果内部要直接查更细的数据,它自身可以声明成async组件再await。RSC 允许“任意深度的组件自行取数”,而这在 Pages Router 里做不到——那时只有页面级方法能碰服务端数据。

// app/dashboard/TemperatureCard.tsx import { getLatestTemperature } from "@/lib/telemetry"; export default async function TemperatureCard({ sensor, }: { sensor: SensorWithTemperature; }) { // 这里可以再补一次实时温度查询 const current = await getLatestTemperature(sensor.id); return ( <div className="rounded-lg border p-4"> <p className="font-medium">{sensor.name}</p> <p className="text-2xl">{current.value.toFixed(1)} °C</p> </div> ); }

组件树内传 props 没什么玄学,但要注意一点:props 只能是可序列化的数据,不能传“获取器”。比如“传一个getTemperature函数让子组件自己调”在服务器组件之间是不成立的,且没有必要——子组件自己await就行。

2.2 路由参数传值:从设备列表到温度详情页

温度卡片本身是一个Link包裹的入口,点击后进入详情页。很多初学者会下意识想“怎么把温度数据直接通过跳转带过去?”,最优雅的方式其实是把标识符和查询条件放进 URL,由服务器组件在目标页面自行拉取。

// app/dashboard/TemperatureCard.tsx 中的 Link 部分 <Link href={{ pathname: `/dashboard/${sensor.id}`, query: { range: "24h" }, }} > 查看历史曲线 </Link>
// app/dashboard/[sensorId]/page.tsx import { getSensorDetail, getHistory } from "@/lib/telemetry"; export default async function SensorDetailPage({ params, searchParams, }: { params: { sensorId: string }; searchParams: { range?: string }; }) { const sensor = await getSensorDetail(params.sensorId); const range = searchParams.range ?? "1h"; const history = await getHistory(sensor.id, range); return ( <section> <h2>{sensor.name}</h2> <p>当前温度:{history.latest} °C</p> <p>当前查询范围:{range}</p> </section> ); }

这里的传值路径是“URL 驱动的服务端传值”。两个服务器组件之间没有直接 props 传递,而是通过路由参数把sensorIdrange注入目标组件的paramssearchParams。这种方式的好处是:页面可收藏、可分享、可刷新,浏览器行为完全正常。

我建议把searchParams当着“请求级配置项”来设计。温度监控里常见的“时间范围”就是一个典型例子。用户切换 1h/24h/7d,本质上是改变 URL 查询参数,而不是维护一段内存状态。RSC 渲染时天然拿到最新值。

2.3 跨组件共享数据:用 fetch 记忆化取代“层层透传”

详情页里三个区块如果各自调用getHistory(sensorId, range),最朴素的做法会触发三次数据库查询。RSC 提供了一层“请求内记忆化”,fetch在同一个渲染请求里对相同 URL 只执行一次:

// lib/telemetry.ts const TELEMETRY_API = process.env.TELEMETRY_API_URL; export async function getHistory(sensorId: string, range: string) { const res = await fetch( `${TELEMETRY_API}/sensors/${sensorId}/history?range=${range}`, { next: { revalidate: 60 } } ); if (!res.ok) { throw new Error(`Failed to fetch history for sensor ${sensorId}`); } return res.json(); }

当渲染树中两个不同的服务器组件同时调用getHistory("sensor-01", "24h")时,由于内部使用了同一个fetch,Next.js 会在这一个请求周期内复用结果。底层数据库只被打一次。

如果查询走的是数据库客户端(比如 Prisma),而不是fetch,这个自动记忆化就失效了。这时需要用 React 的cache手动包一层:

// lib/telemetry.ts import { cache } from "react"; import { db } from "./db"; export const getHistoryFromDb = cache(async (sensorId: string, range: string) => { return db.temperature.findMany({ where: { sensorId, range, }, }); });

cache()包裹后,同一个请求内重复调用getHistoryFromDb会返回同一个 Promise,真正的查询只执行一次。这是“服务器组件间共享数据”最重要的一招,比层层透传 props 干净得多。

3. 温度数据流动时的序列化边界:能传的与不能传的

3.1 Server Component props 的 JSON 序列化约束

在写“温度面板”时,我一开始遇到报错还觉得莫名奇妙。比如我定义了一个TemperatureSample类:

// 这段代码在服务器组件间传类实例会报错 export default async function Parent() { const sample = new TemperatureSample(25.6, new Date()); return <Child sample={sample} />; }

React 在序列化 props 时,只支持普通对象和基础类型。类实例会丢失原型链,函数更是一传就炸:

// 传给服务器子组件的 props 不能是函数 export default async function Parent() { const getLabel = () => "预热区"; return <Child getLabel={getLabel} />; }
Error: Functions cannot be passed directly to Client Components unless you expose this function as a Server Action.

即使目标是纯服务器组件,传函数本质上也没意义——子组件自己就是服务端代码,直接 import 工具函数就完了,没必要通过 props 绕一圈。正确的做法是“传数据,不传行为”。

我在实际项目里尽量做到:props 只传 JSON 可序列化的值。需要额外逻辑就让子组件自己import或者await取数,绝不在服务器组件之间传递函数、类实例、流、Buffer 这类带运行时状态的东西。

3.2 函数为什么传不过去:“渲染”发生在服务端,不是浏览器

这里要理解 RSC 的序列化协议。服务器组件的输出不是 HTML 字符串,而是一个个描述性的节点树,包含组件类型和 props 数据。这些数据要通过 JSON 序列化后发送给客户端。JSON 里没有函数、没有类、没有循环引用,所以 props 里出现这些类型时,服务端就会直接抛错。

有些开发者会问:那我传给“客户端组件”的函数为什么又能用?因为 Next.js 对客户端组件有两条特殊通道:一条是"use client"边界下,序列化器允许传递某些被标记为 Server Action 的引用;另一条是通过children把“服务器组件渲染好的 React 节点”作为 props 传给客户端组件。这些是 RSC 协议里的例外,不代表服务器组件之间可以随便传函数。

我在文章中反复强调一个判断标准:服务器组件传值,脑子里要默认“我在准备一份 JSON 消息”。这份消息要能被序列化、被传输、甚至被缓存在 CDN 上,而不是活生生的 JS 对象。

3.3 传“时间范围”这样的小对象时,别踩 Date 的坑

日期时间在温度数据里太常见了:采样时间、历史曲线起始点、均值计算窗口。但 Date 对象在 RSC 序列化后不会原样还原,通常变成字符串。举个例子:

export default async function Parent() { const range = { start: new Date("2024-11-01T00:00:00Z"), end: new Date("2024-11-02T00:00:00Z"), }; return <Child range={range} />; }

到了 Child 组件里,range.start已经不是Date实例,而是类似"2024-11-01T00:00:00.000Z"的字符串。如果子组件直接range.start.getTime(),运行时就报getTime is not a function

你可能会想,传给服务器子组件也这样吗?是的。服务器组件在同一个 Node.js 进程里运行时,如果完全不走序列化边界,理论上可以直接传 Date 对象;但一旦你的组件树里有任何跨越 RSC 序列化的环节,Date 就会被打成字符串。为了保险,我统一在数据层做转换:

// lib/telemetry.ts export function normalizeSample(sample: RawSample) { return { ...sample, recordedAt: sample.recordedAt instanceof Date ? sample.recordedAt.toISOString() : sample.recordedAt, }; }

消费端再new Date(recordedAt)还原。这种做法也让我们能放心把数据写进缓存或存到日志里,不会因为对象类型不统一而出问题。

4. 数据缓存与请求记忆化:温度数据的“保鲜期”怎么控制

4.1 fetch 的 request memoization:同一个请求内只查一次数据库

很多教程把 RSC 的“自动缓存”说得神乎其神,实际上第一层只是请求记忆化(Request Memoization)。它发生在单个页面渲染请求的生命周期内。页面渲染完成后,这层缓存就被丢弃了。

它的价值是防止“同一个请求内、同一份数据被重复获取”。我有一个很典型的例子:详情页顶部要显示当前温度,底部历史曲线也要引用当前温度做基准线。这两个数据分别由两个独立组件获取,没人协调的话就会查两次。用fetch请求同一个 URL,Next.js 的 memoization 自动帮我们合并。

但请求记忆化不等于“跨用户跨时间共享缓存”。用户 A 请求完,用户 B 再来一次,还是会重新执行fetch。要跨请求共享,就得靠下一层 Data Cache。

4.2 Data Cache:跨请求缓存,控制温度数据多久过期

Next.js 14 的 Data Cache 把fetch的结果持久化在服务端,可以跨请求复用。控制它有两个入口:

// 方式一:fetch 时声明 revalidate fetch(`${TELEMETRY_API}/sensors/${sensorId}/history?range=${range}`, { next: { revalidate: 60 }, }); // 方式二:标签式失效,适合数据更新时精确清理 fetch(`${TELEMETRY_API}/sensors/${sensorId}/history?range=${range}`, { next: { tags: [`sensor-${sensorId}`] }, });

配合 Server Action 或 Route Handler 里的revalidateTag,可以在温度传感器上报新数据时立刻让旧缓存失效:

// app/actions/revalidate.ts "use server"; import { revalidateTag } from "next/cache"; export async function refreshSensor(sensorId: string) { // 传感器上报后,这里可以写库并刷新相关缓存 revalidateTag(`sensor-${sensorId}`); }

温度数据是典型的“高写入、低延迟敏感”数据:每次都实时查不划算,但延迟 5 分钟又能接受,revalidate60 秒就是比较合理的折中。对于告警这种更需要实时的数据,可以把 fetch 的cache设为no-store,旁边放一个 WebSocket 通道推送实时告警。

4.3 缓存、动态渲染、cookies() 三者的边界

这里我吃过一次亏。给温度详情页加用户偏好“温度单位(摄氏度/华氏度)”时,我直接在组件里读 cookies:

import { cookies } from "next/headers"; export default async function SensorDetailPage({ params, }: { params: { sensorId: string }; }) { const cookieStore = cookies(); const unit = cookieStore.get("temperature-unit")?.value ?? "celsius"; // ... }

问题随之而来:读取 cookies 会让路由变成动态渲染。动态渲染下,之前依赖静态缓存的fetch行为会受牵连,稍不留神整个页面就变成了每次请求都回源执行。我的next: { revalidate: 60 }在这些动态路由里并不能按预期生效,因为 Node.js 那边拿不到“预渲染时的 cookie”。

处理方案是拆分数据层:把“用户偏好”通过客户端组件传入详情页,或者明确把页面声明为dynamic = "force-dynamic"。千万不要在纯静态营销页面里读cookies()还期望它能缓存。

4.4 我踩过的坑:把请求级数据放到模块作用域,服务重启前永远不更新

还有一次,我想让多个服务器组件共享“传感器配置”,图省事写了个全局变量:

// lib/sensor-registry.ts let sensorRegistry: Record<string, SensorConfig> | null = null; export async function getSensorRegistry() { if (!sensorRegistry) { sensorRegistry = await loadSensorConfig(); } return sensorRegistry; }

开发环境一切正常,部署后却发现温度列表页显示的还是“三天前的传感器名单”。原因很直接:模块作用域变量在 Node.js 进程生命周期内一直存活。生产环境又不会频繁重启进程,数据自然永不更新。这不是 Next.js 的问题,是所有服务端模块级缓存的通病。

现在我的准则是:模块作用域只放无限期有效的静态配置;请求级数据必须用 Reactcache();跨请求数据必须走 Data Cache 并且设置明确的失效策略。三者各管一段,才不会出现“改了传感器配置,服务重启前看不到变化”的尴尬。

5. 一个完整可复现的温度传递实例(可直接抄)

5.1 项目结构和数据层

我按实战项目的习惯,把数据访问收敛到lib/里,页面组件只负责组装渲染。以下代码基于 Next.js 14 App Router,采用 TypeScript:

app/ dashboard/ page.tsx // 温度总览列表页(服务器组件) [sensorId]/ page.tsx // 温度详情页(服务器组件) TemperatureChart.tsx // 客户端组件,接收历史曲线数据 layout.tsx lib/ telemetry.ts // 数据获取 + 缓存逻辑

telemetry.ts里同时用上fetch的 revalidate 和 Reactcache(),让两个细节都落地:

// lib/telemetry.ts import { cache } from "react"; const API = process.env.TELEMETRY_API_URL; export type SensorInfo = { id: string; name: string; location: string; }; export type TemperatureSample = { recordedAt: string; value: number; }; export const getSensorInfo = cache(async (sensorId: string): Promise<SensorInfo> => { const res = await fetch(`${API}/sensors/${sensorId}`, { next: { revalidate: 300 }, }); if (!res.ok) throw new Error(`Sensor ${sensorId} not found`); return res.json(); }); export const getLatestTemperature = cache( async (sensorId: string): Promise<number> => { const res = await fetch(`${API}/sensors/${sensorId}/latest`, { next: { revalidate: 10 }, }); if (!res.ok) throw new Error(`Failed to load latest temperature`); const data = await res.json(); return data.value; } ); export const getTemperatureHistory = cache( async (sensorId: string, range: string): Promise<TemperatureSample[]> => { const res = await fetch( `${API}/sensors/${sensorId}/history?range=${range}`, { next: { revalidate: 60, tags: [`sensor-${sensorId}`] } } ); if (!res.ok) throw new Error(`Failed to load history`); return res.json(); } );

5.2 核心代码:列表页、详情页、共享数据层

总览页从数据层拿到传感器列表,再为每个传感器读取实时温度。如果不想在列表页做一次性聚合,可以把压力分摊给各卡片组件:

// app/dashboard/page.tsx import SensorList from "./SensorList"; import { getSensorList } from "@/lib/telemetry"; export const metadata = { title: "车间温度总览", }; export default async function DashboardPage() { const sensors = await getSensorList(); return ( <main> <h1>车间温度总览</h1> <SensorList sensors={sensors} /> </main> ); }

SensorListTemperatureCard在服务器组件内部直接用 props 传递传感器信息,最内层卡片再根据sensor.id拉最新温度:

// app/dashboard/TemperatureCard.tsx import Link from "next/link"; import { getLatestTemperature } from "@/lib/telemetry"; import type { SensorInfo } from "@/lib/telemetry"; export default async function TemperatureCard({ sensor }: { sensor: SensorInfo }) { const latest = await getLatestTemperature(sensor.id); return ( <Link href={`/dashboard/${sensor.id}?range=24h`}> <div> <span>{sensor.name}</span> <span>{latest.toFixed(1)} °C</span> </div> </Link> ); }

详情页通过params拿传感器 ID,通过searchParams拿时间范围,再传给客户端图表组件:

// app/dashboard/[sensorId]/page.tsx import { getSensorInfo, getLatestTemperature, getTemperatureHistory, } from "@/lib/telemetry"; import TemperatureChart from "./TemperatureChart"; export default async function SensorDetailPage({ params, searchParams, }: { params: { sensorId: string }; searchParams: { range?: string }; }) { const range = searchParams.range ?? "1h"; const [sensor, latest, history] = await Promise.all([ getSensorInfo(params.sensorId), getLatestTemperature(params.sensorId), getTemperatureHistory(params.sensorId, range), ]); return ( <section> <h1>{sensor.name}</h1> <p>{sensor.location}</p> <p>当前温度:{latest.toFixed(1)} °C</p> <TemperatureChart history={history} range={range} /> </section> ); }

5.3 如何验证“真的只在服务端传递”

写完代码后怎么确认“这些数据确实在服务端流动,没经过浏览器”?两个方法比较直接。

第一个方法是禁用浏览器 JavaScript 后刷新页面。如果页面内容能完整渲染出传感器列表和温度值,说明数据完全由服务端输出到了 HTML。第二个方法是在详情页返回的 HTML 里搜索温度值。按Ctrl/Cmd + U查看页面源代码,能看到25.6这类具体温度已经直接出现在 HTML 里,而不是出现在某个__NEXT_DATA__之类的 JSON blob 中(RSC 序列化数据里也会有,但你已经能看到纯文本版)。

如果你还怀疑重复请求问题,可以在lib/telemetry.tsgetTemperatureHistory里临时加一行console.log,然后在页面渲染时观察服务端日志:同一个页面请求只输出一次,说明 memoization 生效了。

5.4 扩展:如何把温度数据下发给客户端组件

真实图表肯定要交互,这里就用上了客户端组件。服务器组件把历史曲线作为 props 传给"use client"组件,其中包含的是纯数组和字符串,完全可以序列化:

// app/dashboard/[sensorId]/TemperatureChart.tsx "use client"; import type { TemperatureSample } from "@/lib/telemetry"; export default function TemperatureChart({ history, range, }: { history: TemperatureSample[]; range: string; }) { // 这里可以做图表渲染 return ( <div> <p>范围:{range}</p> <ul> {history.map((sample) => ( <li key={sample.recordedAt}> {sample.recordedAt}: {sample.value} °C </li> ))} </ul> </div> ); }

注意,类型定义TemperatureSample会被客户端组件引用,但它只是一个普通 TypeScript 类型,没有运行时逻辑,所以不会破坏客户端边界。如果将来要传 Date、Buffer 这类类型,就要在进入客户端组件之前做一次“数据净化”,把它们转成纯字符串和数字。

6. 实战中值得记住的几个判断准则

6.1 先问数据属于哪个生命周期

我在设计传值方案前,会先给数据分类:

数据生命周期传递/共享方式典型场景
单次请求内共享Reactcache()+ fetch memoization当前温度、历史曲线
跨请求共享Data Cache + revalidate传感器配置、小时级均值
用户级/会话级cookies、session温度单位偏好、告警订阅
永久静态模块常量、静态生成传感器类型枚举、单位换算表

这个表格几乎能覆盖所有“服务器组件间传数据”的场景。先定位生命周期,再选技术方案,比记一堆 API 更可靠。

6.2 能少传就少传:服务器组件之间传值的带宽成本

服务器组件之间传 props 并不是零成本。虽然它不经过浏览器协商,但仍然要参与 RSC 序列化,数据量过大会拖慢首屏。我的经验是:只传当前渲染真正需要的最小字段。别把整个温度传感器对象原封不动往下传,更别为了“方便后续扩展”传一个巨型对象。

有一个我特别看不惯的写法:把数据库返回的完整行记录直接往下传,里面十几列全部用不上,却白白浪费序列化和网络传输的时间。建议在数据层就用map或 select 挑选字段,只保留 UI 需要的内容。

6.3 从 Pages Router 迁移过来最容易错的两件事

第一件事是“以为服务器组件里不能 fetch”。正好相反,服务器组件里能直接async/await,这是 RSC 的核心能力,不需要再套getServerSideProps。第二件事是“到处写"use client"”。一旦标记客户端组件,它内部就不能直接await服务端数据,也拿不到cookies()之类的服务端上下文。我那批温度卡片一开始全标上了"use client",结果每次还要靠 props 把数据从页面入口传下来,绕了一圈又回到了 Pages Router 的老路。

正确的做法是:默认不写"use client",能用服务器组件就用服务器组件,只有在需要 useState、onClick、浏览器 API 时才标记客户端组件。通过children和 props 把服务端取好的数据“注入”给客户端组件,边界非常清晰。

最后再分享一个我的小技巧:在服务器组件之间传数据时,我给每个fetch或查询函数都加上明确的tags。虽然初期多写两行,但后续做定向清理、局部刷新时会非常省心。尤其是温度监控这种“设备随时上报”的场景,有了revalidateTag,只要传感器数据变化,页面上的对应区块就能精确刷新,不用整页回源。这也是我自己从踩坑里换来的经验:一开始图省事,结果缓存过期策略越改越乱,后来老老实实给数据打上标签,问题一下就清晰了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询