我在做车间环境监测面板的时候,第一次真切体会到 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 传递,而是通过路由参数把sensorId和range注入目标组件的params和searchParams。这种方式的好处是:页面可收藏、可分享、可刷新,浏览器行为完全正常。
我建议把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> ); }SensorList和TemperatureCard在服务器组件内部直接用 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.ts的getTemperatureHistory里临时加一行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,只要传感器数据变化,页面上的对应区块就能精确刷新,不用整页回源。这也是我自己从踩坑里换来的经验:一开始图省事,结果缓存过期策略越改越乱,后来老老实实给数据打上标签,问题一下就清晰了。