Lesson 18:Server Components — 颠覆认知的组件模型
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 3(实战篇)
- 推荐时长:90~150 分钟(首次学习)
- 先修要求:完成 L17,能够运行 Next.js App Router 项目
- 学习产出:实现商品详情与本地交互,解释服务端和客户端边界
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:理解 React Server Components (RSC) 的运行位置,掌握
"use client"边界与模块依赖的关系。📦 本节产出:实现服务端渲染的商品详情页,并在其中嵌入客户端交互组件;L19 再接入数据库。
一、React Server Components 是什么?
RSC 让一部分组件逻辑留在服务器上执行。
Phase 1 和 Phase 2 的纯 CSR 项目: 组件在浏览器中渲染。React 也支持传统 SSR。 Next.js App Router: 页面和布局默认是 Server Components。"use client" 声明客户端模块边界,该文件及其客户端依赖可在浏览器执行;初次访问时 Client Components 通常也会在服务端预渲染。
1.1 能力差异
| 能力 | Server Component | Client Component |
|---|---|---|
| 直接查数据库 | ✅ await prisma.product.findMany() | ❌ |
| 使用 useState/useEffect | ❌ | ✅ |
| 使用事件处理 (onClick) | ❌ | ✅ |
| 发送到浏览器 JS Bundle | ❌ 组件实现不计入客户端 JS | ✅ 会计入 Bundle 大小 |
| 访问后端环境变量/密钥 | ✅ 可读取,但不能把密钥传给客户端 | ❌ 不应访问服务端密钥 |
| 可以是 async 函数 | ✅ async function Page() | ❌ |
| 读取文件系统 (fs) | ✅ Node.js 运行时可用,受部署文件系统限制 | ❌ |
Server Component 的实现及仅由它使用的依赖不进入客户端 JS,但渲染结果和传给 Client Components 的数据会发送给浏览器。服务器模块仍应只输出公开字段;数据库和密钥模块可加 import 'server-only',防止被误导入客户端。
二、实战:商品详情页
2.1 创建动态路由
src/app/products/[id]/page.tsx ← [id] = 动态路由段本节先复用 L17 的模拟商品,保证不依赖尚未配置的 Prisma 或购物车 store。创建服务端数据模块(这里的 price 统一以人民币“分”为单位,显示时除以 100):
// src/lib/products.ts
export const products = [
{ id: '1', name: 'React 19 实战手册', price: 9900, category: 'book', description: 'React 入门与实战', stock: 20 },
{ id: '2', name: 'TypeScript 进阶指南', price: 12900, category: 'book', description: '类型系统练习', stock: 15 },
{ id: '3', name: 'Next.js 全栈开发', price: 15900, category: 'book', description: '全栈项目示例', stock: 10 },
]
export async function getProduct(id: string) {
return products.find(product => product.id === id) ?? null
}// src/app/products/[id]/page.tsx
// 🚀 这是一个 Server Component(默认)—— 代码只在服务器上运行!
import { getProduct } from '@/lib/products'
import { notFound } from 'next/navigation'
import AddToCartButton from './AddToCartButton'
import FavoriteButton from './FavoriteButton'
// Next.js 15 中 params 是 Promise
export default async function ProductDetail({
params
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
// 服务端读取数据;L19 将此调用替换成 Prisma 查询。
const product = await getProduct(id)
// 如果商品不存在,返回 404 页面
if (!product) {
notFound()
}
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<div className="grid md:grid-cols-2 gap-12">
{/* 左:图片区 */}
<div className="bg-gray-100 rounded-2xl flex items-center justify-center text-9xl h-80">
📦
</div>
{/* 右:信息区 (Server Component 渲染静态信息) */}
<div>
<span className="text-xs bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full">
{product.category}
</span>
<h1 className="text-3xl font-extrabold mt-2">{product.name}</h1>
<p className="mt-4 text-gray-500 text-lg">{product.description}</p>
<p className="mt-6 text-4xl font-bold text-indigo-600">¥{(product.price / 100).toFixed(2)}</p>
<p className="mt-2 text-sm text-gray-400">库存:{product.stock} 件</p>
{/* 交互区 —— 必须是 Client Component! */}
<div className="mt-8 flex gap-3">
<AddToCartButton
productId={product.id}
name={product.name}
price={product.price}
/>
<FavoriteButton productId={product.id} />
</div>
</div>
</div>
</div>
)
}完成下面两个按钮后,访问 /products/1 测试详情页。再在 L17 的列表文件导入 Link from 'next/link',把卡片的 <article> / </article> 换成 <Link href={/products/${product.id}}> / </Link>(保留原有 key 和 className),即可从列表进入详情。
2.2 划出 Client 边界
// src/app/products/[id]/AddToCartButton.tsx
'use client' // 声明客户端模块边界
import { useState } from 'react'
export default function AddToCartButton({
productId, name, price
}: {
productId: string; name: string; price: number
}) {
const [added, setAdded] = useState(false)
return (
<button
onClick={() => setAdded(value => !value)}
aria-pressed={added}
title={`${name}(${productId}),¥${(price / 100).toFixed(2)}`}
className={`flex-1 py-3 rounded-xl font-bold text-lg transition-all ${
added
? 'bg-green-500 text-white scale-95'
: 'bg-indigo-600 text-white hover:bg-indigo-700'
}`}
>
{added ? '✅ 已选择(演示)' : '🛒 选择商品(演示)'}
</button>
)
}这个按钮只演示本地状态,不会持久化或生成订单。L23 接入完整购物车时替换其实现。
// src/app/products/[id]/FavoriteButton.tsx
'use client'
import { useState } from 'react'
export default function FavoriteButton({ productId }: { productId: string }) {
const [isFav, setIsFav] = useState(false)
return (
<button
onClick={() => setIsFav(value => !value)}
aria-pressed={isFav}
aria-label={isFav ? `取消收藏商品 ${productId}` : `收藏商品 ${productId}`}
className={`w-12 h-12 rounded-xl border-2 text-xl transition-all ${
isFav ? 'border-red-300 bg-red-50' : 'border-gray-200 hover:bg-gray-50'
}`}
>
{isFav ? '❤️' : '🤍'}
</button>
)
}三、🧠 深度专题:RSC 架构的关键规则
3.1 组件树的"Server/Client 切割"
3.2 ⚠️ "use client" 的传染性
"use client" 建立模块依赖边界:该文件导入的客户端依赖会进入客户端模块图,不能在其中直接导入 Prisma 等服务端模块。类型导入会被擦除,未使用的代码可能被 tree-shaking 移除;"use server" 模块中的 Server Functions 则有专门的远程引用机制(L20 介绍)。
在客户端组件中使用大型工具库,可能增加浏览器下载量。这里判断的是 import 依赖,不是 JSX 的所有后代:Server Component 可以通过 children 把服务端渲染结果传入 Client Component,后者不需要导入该服务端组件。
最佳实践: 把 "use client" 尽量下推到组件树的叶子节点(最小的交互单元)。
// ❌ 不好:整个页面都变成 Client(原本能在服务端完成的查询也被拖到客户端了)
'use client'
export default function ProductPage() {
// prisma.product.findUnique 不能在客户端调用!
}
// ✅ 好:只把需要交互的小按钮标记为 Client
// page.tsx (Server Component,可以查 DB)
// ├── ProductInfo.tsx (Server Component,纯展示)
// ├── AddToCartButton.tsx ('use client',只有按钮交互)
// └── FavoriteButton.tsx ('use client',只有按钮交互)3.3 数据从 Server 流向 Client 的序列化约束
Server Component 可以通过 Props 传数据给 Client Component。 但这些数据必须属于 React 支持的可序列化类型,并不等同于 JSON。普通对象、数组、Date、Map、Set 等都受支持;普通函数、任意类实例(例如 Prisma 的 Decimal)不能直接跨边界传递。标记为 Server Function 的函数和 JSX 有专门支持,见 React 的可序列化类型清单。
// 数据形状示意,不是上面 AddToCartButton 的 props 定义。
const clientData = {
name: 'React 19 实战手册',
price: 9900,
tags: ['book', 'tech'],
createdAt: new Date(), // React 支持 Date,无需为 RSC 强制转成字符串
}NOTE
只把客户端需要的公开字段传过去。普通回调不能从服务端传给客户端;应在 Client Component 内定义事件处理器。如果通过 JSON API 返回日期,则需遵守 JSON 的规则,接收端拿到的是字符串。关于边界与组合方式,参见 Next.js 15 官方文档。
3.4 边界划定决策图
四、练习
- 在商品详情页底部添加“相关商品推荐”。先筛选模拟数据中同分类的其他商品,用 Server Component 渲染;完成 L19 后再改为 Prisma 查询。
- 尝试在 Server Component 里写
useState,观察 Next.js 给出的错误信息并记住它。 - 将商品的
createdAt日期传递给一个 Client Component 显示"上架时间:X天前",使用 React 支持的 Date 类型,并避免服务端和浏览器因时区不同生成不一致的初始文本。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 创建了 Server Component 商品详情页 | RSC 默认在服务端运行,可以 async |
| 在服务端读取模拟商品数据 | 可在 async 组件中等待数据,L19 再接数据库 |
划出了 "use client" 的交互按钮 | Client Component 的标记规则 |
| — | "use client" 建立模块依赖边界 |
| — | 数据从 Server → Client 必须属于 React 支持的可序列化类型 |