Skip to content

Lesson 18:Server Components — 颠覆认知的组件模型 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 3(实战篇)
  • 推荐时长:90~150 分钟(首次学习)
  • 先修要求:完成 L17,能够运行 Next.js App Router 项目
  • 学习产出:实现商品详情与本地交互,解释服务端和客户端边界
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 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 ComponentClient 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):

ts
// 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
}
tsx
// 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 边界 ​

tsx
// 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 接入完整购物车时替换其实现。

tsx
// 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" 尽量下推到组件树的叶子节点(最小的交互单元)。

tsx
// ❌ 不好:整个页面都变成 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 的可序列化类型清单。

ts
// 数据形状示意,不是上面 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 边界划定决策图 ​


四、练习 ​

  1. 在商品详情页底部添加“相关商品推荐”。先筛选模拟数据中同分类的其他商品,用 Server Component 渲染;完成 L19 后再改为 Prisma 查询。
  2. 尝试在 Server Component 里写 useState,观察 Next.js 给出的错误信息并记住它。
  3. 将商品的 createdAt 日期传递给一个 Client Component 显示"上架时间:X天前",使用 React 支持的 Date 类型,并避免服务端和浏览器因时区不同生成不一致的初始文本。

📌 本节小结 ​

你做了什么你学到了什么
创建了 Server Component 商品详情页RSC 默认在服务端运行,可以 async
在服务端读取模拟商品数据可在 async 组件中等待数据,L19 再接数据库
划出了 "use client" 的交互按钮Client Component 的标记规则
—"use client" 建立模块依赖边界
—数据从 Server → Client 必须属于 React 支持的可序列化类型

项目驱动 · 边写边学 · React 19