CHAPTER 07 · 前端
渲染模式与元框架
第 06 章里 React 只在浏览器中运行。真实的全栈应用中,同一套 React 组件还会在服务器上、甚至在构建时运行。本章回答三个问题:在哪里渲染、什么时候渲染、给浏览器发多少 JS。答案决定了首屏速度、SEO、以及你的密钥是否安全。
- 比较 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 渲染、语法高亮) | 交互:按钮、表单、输入框、动画 |
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.js | TanStack Start | |
|---|---|---|
| 成熟度与资料 | 最高;Agent 最熟悉 | 较新;资料在快速增长 |
| 服务器组件 | 完整支持,是默认模式 | v1 后逐步加入 |
| 类型安全路由 | 基础支持 | 核心卖点:参数、查询、loader 全链路推导 |
| 心智模型 | 约定多、隐式行为较多(缓存、边界) | 显式;更接近“一个带 SSR 的 SPA” |
| 构建工具 | Turbopack | Vite(生态插件通用) |
| 部署 | 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 区域互不等待。
- 首页 / 介绍页:静态生成(SSG),CDN 直出。
- 收藏列表:SSR + 流式;筛选条件在 URL 里;每个用户数据不同,不在 CDN 缓存。
- 收藏详情:服务器组件渲染摘要(Markdown 渲染库不进浏览器),删除按钮是客户端组件。
- “问我的收藏”聊天界面:客户端组件为主(流式交互,第 11 章),服务器负责调用 LLM。
本章词汇:服务器组件 / 客户端组件、客户端边界、可序列化 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。