N05第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

服务端与客户端组件

服务端与客户端组件 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

App Router 里每个组件都要回答一个问题:服务端还是客户端?读完你应该能按「要不要数据、要不要交互」划边界,验证秘密只留在服务端,并把客户端依赖压到最小。

问题场景

课程详情页:上半部分是课程信息和进度统计(来自数据库),下半部分是一个「收藏」按钮(点击有反馈)。两种偷懒写法都不对——整页 "use client",就得把数据获取搬到浏览器、把数据库代码暴露在请求链路上;全用服务端,收藏按钮点了没反应。

核心解释

App Router 的默认是服务端组件:只在服务器执行,产出 HTML。它的能力边界来自物理位置——能直接访问数据库、文件、密钥,没有网络往返;代价是不能用 state 和事件,用户看不到它「动」。

客户端组件(文件顶部 "use client")会被打包发给浏览器,能 useState、能监听事件;代价是代码和它引用的依赖全进 JS 包,而且不能再碰任何秘密。

划分边界的两条判据:

判据一:数据留在服务端。 数据库查询、API 密钥、环境变量只在服务端组件(或数据层)出现。服务端组件把渲染结果(HTML + 序列化的 props)发给浏览器,秘密不会跟着走——前提是你没把它当 props 传下去。

// app/courses/[slug]/page.tsx —— 服务端组件(默认)
import { getCourse } from "@/lib/queries";
import { FavoriteButton } from "@/components/favorite-button";

export default async function CoursePage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const course = await getCourse(slug); // 进程内查库,密钥不出服务端
  return (
    <main>
      <h1>{course.title}</h1>
      <p>已完成 {course.doneCount} 节</p>
      <FavoriteButton initialCount={course.favorites} /> {/* 可序列化 props 下传 */}
    </main>
  );
}

判据二:交互下沉到客户端。 只有真正需要 state / 事件的部分才 "use client",并且做成叶子小组件:FavoriteButton 拿一个数字做初始值,自己管理本地状态。服务端外壳照常渲染,浏览器只多下载一个按钮的代码。

// components/favorite-button.tsx
"use client";
import { useState } from "react";

export function FavoriteButton({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
  return <button onClick={() => setCount(count + 1)}>收藏({count})</button>;
}

反模式要认清:在客户端组件里 process.env.DB_PASSWORD 会得到 undefined 甚至泄漏风险;在服务端组件里写 onClick 是无效属性;「从客户端组件 import 服务端模块」直接报错——边界是单向的,服务端可以引客户端,反过来不行(但可以把服务端组件当 children 传给客户端组件)。

示例拆解

实验区是一个概念模拟页(纯 HTML/JS),验证「秘密不下山」:

  • 左栏模拟服务端渲染:一个 renderOnServer() 函数持有 DB_PASSWORD,但只把课程列表拼进返回的 HTML;
  • 页面用脚本逐字检查返回结果:确认其中不含密码字符串——这就是「服务端组件产出不包含秘密」的直观版;
  • 右栏模拟客户端组件:一个收藏按钮用 addEventListener 响应点击、更新计数——交互只能发生在浏览器;
  • 对照结论:数据渲染在服务端完成、秘密从不进入输出;带交互的那一小块单独存在,相当于被 "use client" 标记的叶子组件。

动手任务

完成三个小任务:

  1. 在项目里实现「服务端详情页 + 客户端收藏按钮」组合,props 只传 initialCount 这类可序列化值。
  2. 在收藏按钮所在文件顶部加 "use client",然后故意删掉它——记录浏览器控制台和终端的报错,理解报错信息在说边界什么规则。
  3. 跑完实验区的概念演示,用文字复述「为什么返回的 HTML 里查不到 DB_PASSWORD」。

常见错误

  • 整页统一 "use client" 图省事:数据获取被迫搬到浏览器(多一次网络往返),数据库依赖进包,秘密防线后撤;按叶子组件下沉。
  • 把敏感值当 props 传给客户端组件:props 会序列化进 HTML/数据流,浏览器看得到;敏感值只在服务端消费,输出只留结论。
  • 在服务端组件里传函数 props:函数无法序列化,客户端组件只接受可序列化的 props;要传行为就传「数据 + 客户端自己处理」。
  • 在客户端组件里 import 数据库模块:构建直接报错;需要数据时让服务端组件取好再传,或走 Server Actions(后续课程)。

检查与复盘

完成下方检查题确认理解。自查清单:

  • 能按「数据 / 交互」两条判据给任意组件定边
  • 秘密只在服务端出现,客户端组件的 props 全部可序列化且无敏感值
  • 客户端依赖收敛在叶子组件,服务端外壳默认无 "use client"

参考资料

动手实验
动手实验
预览
点击「运行」在隔离沙箱中预览
检查题共 3 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1多选中等(多选,全部选对才算正确)

关于服务端组件与客户端组件的边界,下列说法正确的有?(多选)

Q2单选简单

要让一个组件成为客户端组件,'use client' 指令应该写在哪里?

Q3故障定位较难

bug 现场:统计页构建报错,提示 node:sqlite 无法在客户端环境使用。两个文件如下。原因和修法是?

// app/stats/stats-client.tsx
'use client';
import { getStats } from '@/lib/stats';

export default function StatsClient() {
  const data = getStats(); // 想同步拿数据
  return <p>共 {data.notes} 条笔记</p>;
}

// lib/stats.ts(顶部)
import { DatabaseSync } from 'node:sqlite';

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...