CHAPTER 07 · 前端

渲染模式与元框架

第 06 章里 React 只在浏览器中运行。真实的全栈应用中,同一套 React 组件还会在服务器上、甚至在构建时运行。本章回答三个问题:在哪里渲染、什么时候渲染、给浏览器发多少 JS。答案决定了首屏速度、SEO、以及你的密钥是否安全。

⏱ 约 70 分钟原理(渲染模式、水合、服务端/客户端边界)会用(Next.js、TanStack Start)
读完本章,你应该能够:
  • 比较 CSR、SSR、SSG、流式渲染的时间线,并为 LinkNote 的每个页面选择合适的模式。
  • 解释什么是水合(hydration),以及“水合不匹配”报错的成因。
  • 区分服务器组件与客户端组件,知道 'use client' 和 'use server' 各标记了什么,以及哪一个是安全敏感的。
  • 读懂 Next.js App Router 和 TanStack Start 的项目结构,说出两者的主要取舍。

开场:'use client' 到底是什么

开场问题

在一个 Next.js 项目里,Agent 写的组件直接 await db.query(...) 读数据库,没有 API 层。之后你想给这个组件加一个点击按钮,Agent 在文件顶部加了 'use client',结果报错:async/await is not yet supported in Client Components,同时还提示数据库模块无法在浏览器中使用。一个组件怎么能直接读数据库?加了一行字符串为什么整个组件的性质都变了?

答案方向:在 Next.js 的 App Router 中,组件默认是服务器组件(Server Component):它只在服务器上执行,所以可以直接访问数据库,而且它的代码不会被发送到浏览器。'use client' 是一条边界声明:从这个文件开始,组件及其导入的模块都要被打包发给浏览器,并在浏览器中运行——浏览器里当然不能连数据库。正确的做法不是把整个组件变成客户端组件,而是把“需要交互的按钮”单独拆成一个小的客户端组件,由服务器组件渲染它。

三个问题决定一种渲染模式

模式HTML 在哪里生成何时生成典型用途
CSR 客户端渲染浏览器用户打开页面后登录后的后台、强交互工具
SSR 服务端渲染服务器每次请求时个性化、实时数据的页面
SSG 静态生成构建机器构建(部署)时文档、博客、营销页(就像本教程)
ISR / 缓存渲染服务器首次请求时生成,之后缓存一段时间数据偶尔变化的页面
流式 SSR服务器每次请求,分块发送部分数据很慢的页面

下面的时间线对比了用户看到内容、以及能够交互的时间点。点击“播放”:

把时间线和第 05 章的“页面加载全过程”对照看:CSR 的第 ⑤ 步拿到的是空壳,必须等 JS 下载执行、再请求数据,才有内容;SSR 的第 ⑤ 步已经带着完整内容,第 ⑧ 步就能看见,但要等 JS 加载完成并“水合”后才能交互;流式渲染先发出页面外壳,慢的部分在服务器上继续计算,完成后通过同一个响应接着发送。

水合:让服务器的 HTML“活”过来 原理

服务器生成的 HTML 只是静态标记,按钮上没有绑定任何事件。浏览器加载 JS 后,React 会在客户端用同样的数据再渲染一遍组件,把得到的结果与已有的 DOM 对应起来,并绑定事件处理函数。这个过程叫水合(hydration)。

水合要求:客户端的第一次渲染结果必须与服务器生成的 HTML 完全一致。否则 React 会报“水合不匹配(hydration mismatch)”。常见原因:

处理方法:只依赖浏览器的值放到 Effect 里在挂载后读取;时间类数据由服务器决定并作为 props 传下去;实在需要时,把某个组件标记为仅在客户端渲染。遇到水合报错时,第一个问题永远是:“这段代码在服务器和浏览器上会得到不同的结果吗?”

React Server Components 原理

SSR 解决了“首屏看得到”的问题,但没有解决“JS 太多”的问题:传统 SSR 中,所有组件的代码仍然要发送到浏览器用于水合,即使某个组件只是显示一段静态文字。

React Server Components(RSC)把组件分成两类:

服务器组件(默认)客户端组件('use client')
在哪里执行只在服务器(或构建时)服务器预渲染一次 + 浏览器中运行
代码是否发给浏览器否是
可以 async、直接读数据库、读密钥✓✗
可以用 useState、useEffect、onClick✗✓
适合取数据、布局、静态内容、大依赖(Markdown 渲染、语法高亮)交互:按钮、表单、输入框、动画
Layout服务器 · 读当前用户 LinkPage服务器 · await db 查询 UserMenu客户端 · 下拉菜单交互 Summary服务器 · Markdown 渲染 DeleteButton客户端 · onClick props: { id: "abc" } ← 必须可序列化 服务器组件:代码留在服务器,0 KB JS 客户端组件:代码打包发送到浏览器 浏览器只下载黄色部分的代码。 'use client' 是一扇“门”,门后的所有 import 都进浏览器。
服务器组件树中嵌入少量客户端组件(常被称为“交互孤岛”)。跨越边界传递的 props 必须能被序列化:字符串、数字、普通对象可以;函数、类实例、数据库连接不行。

RSC 和 SSR 不是一回事

这是面试和实践中最常见的混淆。SSR 是“在服务器上把组件渲染成 HTML”,所有组件的代码仍然要发给浏览器水合。RSC 是“有些组件只在服务器上存在”,它们的渲染结果以一种特殊的序列化格式发给浏览器,代码不发送。两者通常一起使用:服务器组件 + 客户端组件的首屏都通过 SSR 输出 HTML,但只有客户端组件需要水合。

'use server':一个隐形的 API 端点

客户端组件需要触发服务器上的操作(如删除收藏)时,可以调用一个标记了 'use server' 的函数。看起来像普通函数调用,实际上框架会为它生成一个 HTTP 端点,浏览器通过网络调用它。这叫 Server Function(在 Next.js 中用于表单时常叫 Server Action)。

安全要点

'use server' 函数是公开的网络接口,任何人都可以用任意参数直接调用它,不一定来自你的界面。因此每个 Server Function 都必须:① 校验参数(Zod);② 检查登录状态;③ 检查权限(这条收藏属于当前用户吗?)。Agent 生成的 Server Action 经常漏掉第 ③ 条。

另外,对只能在服务器使用的模块(数据库客户端、带密钥的 SDK),在文件顶部 import "server-only",一旦被客户端组件间接导入,构建会直接失败。

Next.js App Router 会用

Next.js 用文件系统表示路由。app/ 目录下的文件夹结构就是 URL 结构,特定文件名有特定含义:

关于缓存:Next.js 的缓存机制经过了几轮较大调整。Next 16 的方向是默认动态、显式缓存:通过 "use cache" 指令声明哪些函数或组件的结果可以缓存,再配合 revalidatePath / revalidateTag 等 API 让缓存失效。版本间差异较大,这部分以当前官方文档为准,并在给 Agent 的说明里写清楚你的 Next.js 版本。

TanStack Start 会用

TanStack Start 基于 TanStack Router 和 Vite,主打端到端类型安全和显式:没有隐式缓存,路由参数、查询参数、loader 返回值、服务器函数的输入输出全都有完整的类型推导。它默认采用 SSR + 流式渲染,客户端导航时表现得像 SPA。服务器组件支持在 v1 之后以非破坏性方式陆续加入。

注意这里的 createServerFn 和 Next.js 的 'use server' 一样,本质上是一个网络端点,同样需要校验和鉴权——inputValidator 负责第一步,鉴权需要你自己加(通常用中间件)。

怎么选

Next.jsTanStack Start
成熟度与资料最高;Agent 最熟悉较新;资料在快速增长
服务器组件完整支持,是默认模式v1 后逐步加入
类型安全路由基础支持核心卖点:参数、查询、loader 全链路推导
心智模型约定多、隐式行为较多(缓存、边界)显式;更接近“一个带 SSR 的 SPA”
构建工具TurbopackVite(生态插件通用)
部署Vercel 最佳;其他平台依赖适配器(如 OpenNext)Vite 生态,部署到 Cloudflare、Node、Vercel 等较中立

其他值得知道的:React Router v7(Remix 合并进来后的全栈模式,loader/action 风格);Astro(内容站点首选,默认零 JS,按需嵌入交互组件);如果将来接触 Vue 或 Svelte,对应的元框架是 Nuxt 和 SvelteKit,概念与本章一致。知道

请求瀑布与 Suspense

服务器组件让“在组件里直接 await 数据”变得很自然,但也容易造成请求瀑布:父组件 await 完才渲染子组件,子组件再 await 自己的数据,总耗时是各层之和。

Suspense 划出一个“可以稍后到达”的区域:页面外壳先流式发送给浏览器,显示骨架屏;每个区域的数据就绪后,对应的 HTML 片段通过同一个响应继续流入并替换骨架。两个 Suspense 区域互不等待。

LinkNote 中的落点
  • 首页 / 介绍页:静态生成(SSG),CDN 直出。
  • 收藏列表:SSR + 流式;筛选条件在 URL 里;每个用户数据不同,不在 CDN 缓存。
  • 收藏详情:服务器组件渲染摘要(Markdown 渲染库不进浏览器),删除按钮是客户端组件。
  • “问我的收藏”聊天界面:客户端组件为主(流式交互,第 11 章),服务器负责调用 LLM。
对 Agent 怎么说
模糊
精确
“详情页加个删除按钮。”
“详情页保持为服务器组件。新增一个客户端组件 DeleteButton,只接收 id。删除逻辑写成 Server Action:Zod 校验 id、要求登录、只删除属于当前用户的记录,成功后 revalidate /links。不要把整个页面改成客户端组件。”
“页面报 hydration 错误,修一下。”
“页面出现水合不匹配,报错信息如下。先找出哪些值在服务器和浏览器上计算结果不同(时间、随机数、window/localStorage),列出来并说明修复方式,不要用 suppressHydrationWarning 掩盖。”

本章词汇:服务器组件 / 客户端组件、客户端边界、可序列化 props、水合 / 水合不匹配、流式渲染、Suspense 边界、请求瀑布、静态 / 动态渲染、缓存失效(revalidate)。

面试角
  • SSR 和 CSR 各有什么优缺点?从首屏可见时间、可交互时间、SEO、服务器成本、实现复杂度五方面比较。
  • RSC 和 SSR 有什么区别?SSR 是“渲染成 HTML 的时间和地点”;RSC 是“组件本身在哪里存在、代码是否发送给浏览器”。两者互补。
  • 什么是水合?什么情况下会出错?客户端复用服务器 HTML 并绑定事件;首次渲染结果与服务器不一致时报错。
  • Server Action 有什么安全隐患?它是公开端点,必须做参数校验、认证和授权;闭包捕获的变量也可能被序列化发送到客户端。

自检

1. 一个客户端组件 import { db } from "@/server/db" 只是为了使用其中导出的一个 TypeScript 类型。会有什么问题?怎么写才对?

普通的 import 会让打包器把 db 模块(可能包括数据库驱动和连接字符串)打包进浏览器 JS,或者直接构建失败。只需要类型时应写 import type { ... },它在编译后会被完全删除。对服务器模块加 import "server-only" 可以让这类错误在构建时暴露。

2. 一个博客文章页,内容每天更新一次,访问量很大,不需要登录。你会选择哪种渲染模式?为什么不是 SSR?

静态生成或带定时失效的缓存渲染(ISR / “use cache” 配合定时失效)。SSR 会在每个请求时重复做完全相同的计算,增加服务器成本和延迟;而内容一天才变一次,完全可以缓存或预生成后由 CDN 直出。

3. 服务器组件把 onDelete={() => deleteLink(id)} 作为 props 传给客户端组件,会发生什么?

报错:普通函数无法序列化,不能跨越服务器/客户端边界。可以传递的是 Server Function 本身(标记了 'use server' 的函数会被序列化为一个引用),或者只传 id,由客户端组件自己导入并调用 Server Function。