
我在做车间环境监测面板的时候第一次真切体会到 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 classNamegrid 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 classNamerounded-lg border p-4 p classNamefont-medium{sensor.name}/p p classNametext-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)时由于内部使用了同一个fetchNext.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请求同一个 URLNext.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() 三者的边界这里我吃过一次亏。给温度详情页加用户偏好“温度单位摄氏度/华氏度”时我直接在组件里读 cookiesimport { 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: Recordstring, 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采用 TypeScriptapp/ 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): PromiseSensorInfo { 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): Promisenumber { 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): PromiseTemperatureSample[] { 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}?range24h} 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只要传感器数据变化页面上的对应区块就能精确刷新不用整页回源。这也是我自己从踩坑里换来的经验一开始图省事结果缓存过期策略越改越乱后来老老实实给数据打上标签问题一下就清晰了。