上一课完成了拾光书架的视觉骨架。现在页面能显示内容,但我们还没有认真回答一个决定 Next.js 项目结构的问题:一段代码究竟应该在服务器执行,还是应该交给浏览器?
这一课会把书籍数据留在服务器,只把“收藏”按钮送到浏览器。完成后,你会得到一张真正可交互的书籍卡片,也会看清 React Server Components、客户端水合、server-only 和 'use client' 之间的关系。

App Router 中的页面和布局默认是 Server Component。它们可以直接读取文件、访问数据库、调用只在服务器使用的 SDK,也不会把这些实现代码打进浏览器的 JavaScript。
不过,“Client Component 只在浏览器渲染”也是一种过度简化。用户第一次打开页面时,Next.js 仍会在服务器生成一份可立即显示的 HTML;同时,React 会生成一份 RSC Payload,用来描述 Server Component 的结果、Client Component 的位置以及它们所需的 JavaScript。浏览器先显示 HTML,再用这些信息完成客户端水合。
你可以把这次请求拆成四段:
Next.js 在服务器执行页面、布局和其他 Server Component。读取数据、拼装组件树等工作发生在这里。
React 把 Server Component 的结果编码进 RSC Payload。Client Component 的实现不会被塞进这份服务器组件结果,而是以模块引用的形式保留下来。
Next.js 依据组件结果生成首屏 HTML。浏览器即使还没有下载完交互代码,也能先看到标题、书名和按钮外观。
浏览器加载 Client Component 对应的 JavaScript,React 再把点击事件和状态接到现有 HTML 上。这个过程叫水合,也就是 hydration。
因此,“服务器”和“客户端”描述的是代码能够使用的能力与最终会被发送到哪里,不只是描述页面最初在哪里出现。
'use server' 不是“声明 Server Component”的指令。页面和布局本来就是 Server Component;'use server' 标记的是可以由客户端调用的 Server Function。我们会在表单课程中再使用它。
第一次拆组件时,很容易从“这一整块看起来像一张卡片”出发,把所有内容放进同一个文件。更稳妥的判断方式是逐项检查它需要什么能力。
拾光书架的书卡有两部分:书名、作者、摘要和链接都来自服务器数据,不需要浏览器状态;收藏按钮需要响应点击,还要读写 Web Storage。于是边界应该切在按钮上,而不是切在整张卡片上。
BookCard(Server Component)
├── 封面、作者、摘要、评分
├── 详情页链接
└── FavoriteButton(Client Component)
├── 读取 localStorage
├── 响应点击
└── 同步同一浏览器中的收藏状态这样拆分后,书卡的大部分实现仍留在服务器,浏览器只接收收藏所需的那一小段 JavaScript。
'use client' 会建立模块边界。这个文件直接或间接导入的运行时代码都会进入客户端模块图,所以应尽量把指令放在真正需要交互的叶子组件上。
我们先整理数据层。这里有两个目标:让页面知道一条书籍数据有哪些字段,同时确保读取 JSON 文件的代码绝不会被客户端组件误用。
创建 src/lib/types.ts,写入项目使用的两个数据类型:
export type BookStatus = "reading" | "finished" | "planned";
export type Book = {
slug: string;
title: string;
author: string;
category: string;
status: BookStatus;
accent: "coral" | "sage" | "blue"
这个文件不读取文件、不访问密钥,也不调用服务器 API。Server Component 和 Client Component 都可以从这里使用类型。后面你会看到 import type { Book },type 关键字会让这次导入在编译后消失,不会因此增加客户端运行时代码。
BookStatus 是三个字符串组成的联合类型,使用它的字段不能随意写成 "done" 或其他拼写。accent 也用联合类型把数据值限制为六种已经设计好的封面主题。tags: string[] 表示标签数量可以变化,但每一项都必须是字符串;ReadingNote.bookSlug 则约定读后感通过 slug 关联一本书。
这些声明的输入是开发者写下的数据形状,输出是编辑器提示和编译错误,运行时不会生成一个名为 Book 的 JavaScript 对象。尤其要记住:TypeScript 类型不会自动检查从文件读取的 JSON。稍后的 as Book[] 只是告诉编译器“相信这里是 Book 数组”,不是验证;如果数据来自不可信接口,必须使用 Zod 等运行时校验工具确认字段后再交给页面。
类型只是约定,页面还需要真实数据。先在项目根目录创建 data 文件夹,再创建 data/books.json:
[
{
"slug": "the-long-river",
"title": "长河入夜",
"author": "林见川",
"category": "小说",
"status": "reading",
"accent": "coral",
"rating": 4.8,
"summary": "一名修船匠沿河寻找失散多年的旧友,也重新认识自己生活的城市。",
"quote": "天黑以后,河面会替人收好那些来不及说完的话。",
"progress":
slug 是书籍的稳定标识,后面会进入详情页网址;status 与 accent 只能使用类型中列出的值。JSON 字符串必须使用双引号,最后一个属性或数组项后面不能多写逗号。
每个字段都服务于后续页面:slug 负责地址与关联,title/author/category 负责基础展示,summary/quote 进入详情正文,rating/progress/chapters 形成数字信息,accent 选择封面主题,tags 参与搜索。六条记录的 slug 必须唯一;否则详情查找只会返回第一条,列表的 React key 也会重复。progress 还应保持在 0 到 100,类型只能保证它是数字,不能保证范围。
JSON 由 JSON.parse 在运行时读取,所以不能写注释、单引号或尾逗号。TypeScript 不会直接检查这个独立文件与 Book 是否一致;手动数据适合课程起步,面对用户输入或外部接口时还要增加运行时 schema。把这些约束集中在数据边界,比等到 JSX 显示 undefined 才排查更可靠。
再创建 data/notes.json,先放一条读后感,让首页可以显示真实计数和摘录:
[
{
"id": "seed-note-1",
"bookSlug": "slow-observation",
"reader": "阿禾",
"reflection": "读完后试着不带耳机走了一段路,第一次听清早市收摊时的声音。",
"createdAt": "2026-07-12T08:30:00.000Z"
}
]bookSlug 对应 books.json 中的一本书,createdAt 使用 ISO 8601 时间字符串,后续可以直接按字符串排序。
这种关联没有数据库外键替我们检查,所以修改书籍 slug 时也要同步修改书摘。时间字符串能够直接排序的前提是所有记录都使用同样完整的 ISO 格式和时区;这里统一保存为带 Z 的 UTC 时间。如果混用未标注时区的时间、缺少补零或采用不同格式,字符串顺序就不一定等于真实时间顺序。
接着创建 src/lib/data.ts。这一课先写无缓存版本,专注理解边界;第 6 课会在相同函数上加入 Next.js 16 的缓存指令,函数名称和返回类型都不会改变。
server-only 不是生成器默认依赖。先在项目目录安装并锁定课程使用的版本:
npm install --save-exact server-only@0.0.1这条命令把构建护栏加入 dependencies,同时更新锁文件。这里使用 --save-exact,让其他读者按锁文件安装时得到同一版本。不要用 npm install -g server-only;数据模块由项目构建器解析,依赖必须记录在项目清单中。
import "server-only";
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { Book, BookStatus, ReadingNote } from "@/lib/types";
async function readDataFile<T>(filename: string): Promise<T> {
const filePath = path.join(process.cwd(),
第一行的 import "server-only" 没有导入一个供我们调用的函数。它是一道构建期护栏:如果某个 Client Component 直接或间接导入了这个模块,Next.js 会报错,而不是把 node:fs 一类服务器代码悄悄送进客户端构建。
readDataFile<T> 负责定位 data 目录、读取文本并把 JSON 转成指定类型。其余函数只表达业务意图:获取全部书籍、按 slug 找一本书、筛选书籍、按时间排列读后感。页面不用知道文件具体放在哪里。
把读取函数逐行拆开,就能看清它的输入输出:
filename 是 books.json 或 notes.json,泛型 T 描述调用者期望得到的类型。process.cwd() 返回启动 Next.js 时的项目目录,path.join() 再用当前操作系统正确的分隔符拼出 data/文件名。readFile(..., "utf8") 异步返回文本;省略编码会得到 Buffer,不能直接交给 JSON.parse。JSON.parse(source) as T 把文本转换成 JavaScript 值并做类型断言,Promise 最终兑现为 T。文件不存在或 JSON 语法错误时,函数不会返回空数组,而是让 Promise 拒绝;错误继续向上传到页面,并在第 5 课由错误边界显示失败界面。这比悄悄吞掉损坏数据更容易发现问题。但 as T 不会检查字段,若数据来源变成表单、接口或数据库,应该在这一层补运行时验证。
getBook 读取数组后用 find 返回第一条 slug 相同的记录,找不到时输出 undefined,所以调用者必须处理 404。findBooks 先用 optional chaining 读取可选 query,trim() 去掉首尾空白,?? "" 把缺失值变成空串,再用同一中文区域规则转成小写。每本书把标题、作者、分类和标签合成一个 haystack,同时满足状态与文字条件才保留。
getNotes 使用 toSorted 返回一个新数组,不修改刚读取的原数组;比较函数让较新的 ISO 时间排在前面。不要改用会原地修改数组的 sort() 后又假设原顺序还在,也不要在页面中重复 readFile 与过滤细节。数据模块的价值正是把存储方式封装起来,后续加缓存或改数据库时,Page 仍然只调用相同业务函数。
server-only 能阻止错误导入,但它不会替你完成权限检查,也不会把传给 Client Component 的值自动变安全。密钥、内部字段和未授权数据都不应出现在客户端 props 中。
收藏状态属于浏览器自身,并保存在 localStorage。它不在 React 组件树内部,其他标签页也可能修改它,所以这是一个“外部存储”。React 为这类状态提供了 useSyncExternalStore。
创建 src/components/favorite-button.tsx,写入以下代码:
"use client";
import { useSyncExternalStore } from "react";
const storageKey = "favorite-books";
const changeEvent = "favorite-books-changed";
function subscribe(callback: () => void) {
window.addEventListener("storage", callback);
window.addEventListener(changeEvent, callback);
return () => {
window.
这段代码看起来比一个 useState 按钮稍长,但每一部分都有明确作用。
readFavorites 先把存储文本解析成 unknown,再确认它真的是纯字符串数组。用户清理或手动修改存储、旧版本写入不同结构、JSON 损坏时,函数都会安全回到空数组,而不是让整张书卡崩溃。writeFavorites 也处理浏览器禁用存储、容量不足等写入异常;失败时不派发变化事件,所以界面不会假装已经保存成功。正式产品还可以把错误反馈显示在按钮旁,本课先在控制台保留诊断信息。
storage 事件用于接收同源的其他页面对 Web Storage 的修改。浏览器不会把这个事件回发给刚刚执行 localStorage.setItem 的当前页面,所以代码又定义了 favorite-books-changed 自定义事件。当前页面修改收藏后主动派发它,页面里的其他书卡便能一起更新。
subscribe 必须返回清理函数。组件卸载时,React 会调用它移除两个监听器,避免重复订阅。
useSyncExternalStore 的三个参数依次是订阅函数、客户端快照和服务端快照:
const favorite = useSyncExternalStore(
subscribe,
() => readFavorites().includes(slug),
() => false,
);服务器没有 localStorage,所以第三个函数返回稳定的 false。首屏 HTML 会显示“收藏 ♡”;水合完成后,React 再读取浏览器中的真实收藏列表。如果这本书已经收藏,按钮会更新为“已收藏 ♥”。
直接在组件渲染阶段无条件读取 window 或 localStorage,会让服务器渲染失败。让服务端快照与首屏 HTML 保持一致,也能避免水合时两边内容对不上。
点击按钮时,Set 用来添加或删除当前 slug,然后把结果序列化回 localStorage。最后派发自定义事件,useSyncExternalStore 会重新读取快照并触发必要的渲染。
aria-pressed 表明这是一个可切换状态的按钮。屏幕阅读器能知道按钮当前是否按下,而不必只依赖心形符号。
第一次请求时,服务器无法访问 Web Storage,第三个快照函数返回 false,首屏 HTML 因此稳定显示“收藏 ♡”。浏览器加载客户端代码后,React 建立两个事件订阅并调用客户端快照;如果存储里已经包含当前 slug,按钮会更新为“已收藏 ♥”。这个短暂更新是“不把浏览器私有状态伪造进服务器 HTML”的明确取舍。
用户点击后,事件处理器先读取并校验当前数组,再用 Set 添加或删除 slug。写入成功后,自定义事件通知当前文档重新取快照;其他同源标签页则由浏览器原生 storage 事件通知。快照的布尔值改变,React 才重新渲染按钮文字与 aria-pressed。刷新页面会重新经历服务端快照与水合,但收藏值仍由持久的 Web Storage 恢复。
这条链路中最常见的错误有四个:在 Server Component 直接读取 window、只监听不会通知当前页面的 storage、订阅时忘记返回清理函数,以及不验证就断言 JSON.parse 的结果。另一个容易忽略的细节是 type="button";如果书卡将来放进表单,它能防止收藏点击被浏览器当成表单提交。
Server Component 可以渲染 Client Component。边界并不要求父组件也改成客户端组件,只要求传过去的 props 能被 React 序列化。这里的 slug 是普通字符串,正适合跨越边界。
创建 src/components/book-card.tsx:
import Link from "next/link";
import { FavoriteButton } from "@/components/favorite-button";
import type { Book } from "@/lib/types";
const statusLabels = {
reading: "正在读",
finished: "已读完",
planned: "想读",
} as const;
export function BookCard({ book }:
这个文件没有 'use client'。BookCard 的封面和文字仍由服务器处理,只有导入的 FavoriteButton 位于客户端边界内。Book 使用类型导入,也不会把数据模块带进浏览器。
statusLabels 把数据层稳定的英文枚举映射成中文界面文字,book.status 受到 BookStatus 约束,所以索引一定落在三个已知键之一。组件输入是一条完整 Book,输出是一篇语义 article:封面区域显示分类、作者和标题,正文区域显示状态、评分、摘要、详情链接与收藏按钮。
cover-${book.accent} 是普通 CSS 类的动态组合。它能够工作,是因为上一课已经明确写出了 .cover-coral、.cover-sage 等六条规则;若这里想拼 Tailwind 类 bg-${book.accent},Tailwind 扫描源码时看不到最终字符串,就不会自动生成样式。line-clamp-3 把列表摘要限制为三行,完整内容仍会在详情页显示。
服务器数据流是 Book 对象 → BookCard JSX → 首屏 HTML。到 <FavoriteButton slug={book.slug} /> 时,React 只序列化一个普通字符串跨越客户端边界;文件读取函数和整条书籍对象都不需要进入按钮脚本。父页面在 map() 中负责 key,因为只有父层知道这张卡片在列表中的身份;把 key 藏进 BookCard 内部不会帮助 React 比较同级项。
一张 Server Component 书卡完全可以包含一个 Client Component 按钮。是否包含交互子组件,不会自动把整个父组件变成 Client Component。
数据函数和书卡组件已经准备好,现在把它们接回页面。页面负责调用数据函数,再用 map 把每本书变成 BookCard。数据仍留在服务器,传入书卡的是已经读取好的普通对象。
打开 src/app/page.tsx,替换为下面的完整内容:
import Link from "next/link";
import { BookCard } from "@/components/book-card";
import { getBooks, getNotes } from "@/lib/data";
export default async function HomePage() {
const [books, notes] = await Promise.all([getBooks(), getNotes()]);
const featuredBooks = books.slice
HomePage 是异步 Server Component,可以直接调用 getBooks() 与 getNotes()。Promise.all 让两份互不依赖的数据同时等待,第 5 课会详细解释为什么这比串行读取更合适。
books.slice(0, 3) 只取前三本作为本周书单,页面统计则来自数组真实长度。以后增加数据时,计数会自动变化,不必再修改 JSX 中的数字。
这一版首页的输入从静态常量升级为两份服务器数据。页面函数开始执行时同时启动 getBooks() 和 getNotes(),两者都完成后按传入顺序解构为数组;slice 再派生前三本,而不会改动 books。随后 books.length、notes.length 流入统计定义列表,三条 Book 对象流入 BookCard。
dl、dt、dd 分别表示统计列表、指标名称和指标值,比三个没有关系的 div 更能表达“当前收录—6 本”这种键值关系。featuredBooks.map 生成三张服务器书卡,每张卡只把 slug 交给收藏客户端岛。页面本身没有 useState、事件或浏览器 API,因此即使包含可点击按钮,仍不需要写 'use client'。
引语使用 featuredBooks[1]?.quote 和可选链,当前六本种子数据保证第二本存在;如果数组少于两本,页面不会崩溃,但会显示空引号与空书名。面对会变化的数据,应该在渲染前判断是否存在推荐书,或提供明确空状态,而不是依赖固定下标。常见错误是忘记把 Page 声明为 async、先后分别等待两份独立数据,或为了按钮把整个首页改成 Client Component。
创建 src/app/books/page.tsx。这一阶段先完成全部书籍列表;第 7 课会在缓存基础上加入 URL 搜索、Suspense 和 Route Handler。
import type { Metadata } from "next";
import { BookCard } from "@/components/book-card";
import { getBooks } from "@/lib/data";
export const metadata: Metadata = {
title: "书架",
description: "查看拾光书架收录的全部书籍。",
};
export default async function BooksPage() {
const books = await
map 中的 key 使用稳定的 book.slug。不要用数组下标代替它,因为书籍排序或插入以后,下标会变化,React 可能把旧状态错误地对应到另一张卡片。
书架 Page 的执行过程更直接:路由匹配 /books,服务器等待 getBooks(),数量进入汇总文字,每一条 Book 再进入 BookCard。Metadata 是静态导出,不必等书籍数据就能生成标题。响应式网格默认一列,md: 变两列,lg: 变三列;变化的是排列,不是数据顺序。
当前 JSON 保证至少有一本书,因此直接输出网格。以后数据来源可能为空时,应在 books.length === 0 分支显示“书架还没有书”,否则用户只会看到大片空白。不要在 BookCard 内再次调用 getBook:父页面已经拥有完整对象,重复读取既浪费工作,也让组件职责变得含糊。
开发服务器会自动增量编译新增模块。如果从第 2 课保留的进程仍在运行,不要再启动第二个;只有已经停止时,才执行下面的命令。npm run dev 会调用 next dev,Next.js 16 默认使用 Turbopack。
npm run dev在浏览器打开 http://localhost:3000。首页会从两份 JSON 数据得到“6 本”“1 条”的统计,“本周书单”显示前三张书卡,每张卡片右下角都有收藏按钮。
下面是 http://localhost:3000/books 在本章结束时的真实结果。先用 1440×900 视口触发桌面布局,点击《长河入夜》的“收藏 ♡”,刷新页面,再进行全页捕获。请观察六张卡片都来自 JSON,第一张按钮刷新后仍显示“已收藏 ♥”,其余按钮保持未收藏;这同时证明服务器列表与浏览器收藏状态在一张卡片中完成组合。

截图左下角的黑色 N 是 next dev 提供的开发指示器,用来打开编译与路由诊断面板,不是拾光书架的页面组件。执行生产构建后,它不会出现在交付页面中。
依次做下面几件事:
点击一本书的“收藏 ♡”。按钮应立即变成“已收藏 ♥”,页面不会跳转。
刷新页面。收藏状态应继续保留,因为它已经写入 localStorage,而不是只存在于一次组件渲染中。
打开 http://localhost:3000/books。页面应显示六张书卡,首页与书架中的同一本书共享收藏状态。
再打开一个同源页面并修改同一本书的收藏状态。另一个页面会通过 storage 事件收到变化。
在浏览器中查看页面源代码,还能找到书名和按钮初始文字。这说明首屏 HTML 已由服务器准备好;随后加载的客户端代码只负责让收藏按钮能够响应操作。
页面能点不等于边界一定正确。我们再用类型检查和 ESLint 查两类问题:错误的组件导入、浏览器与服务器 API 的误用。
先在 package.json 的 scripts 中加入类型检查命令。create-next-app 默认不会生成这条脚本;修改后的完整 scripts 属性如下。只合并这一项,不要用下面的对象覆盖整个 package.json:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit"
}
}JSON 属性之间必须有逗号,最后一项不需要尾逗号。typecheck 通过项目安装的 TypeScript 执行 tsc,--noEmit 表示只检查类型,不输出 JavaScript;lint 保留生成器提供的 eslint,ESLint 9 会从项目目录和扁平配置开始检查;dev、build 与 start 仍是开发、生产构建和生产运行所需脚本。若误删它们,后续章节的运行命令会直接失效。
保存后运行类型检查。这个脚本只验证类型,不生成新的 JavaScript 文件。
npm run typecheck没有错误时,命令会安静结束。接着运行 ESLint,它会检查 React、Next.js 和常规代码规则:
npm run lint如果两条命令都正常结束,这一阶段的组件边界和类型关系就通过了静态检查。
想亲眼看看 server-only 护栏,可以阅读下面的实验,不必把错误代码保留在项目中。
假设在 favorite-button.tsx 中加入这行:
import { getBooks } from "@/lib/data";会发生什么?
你现在已经建立了这门课后面会反复使用的默认策略:页面、布局和数据读取先留在服务器;遇到状态、事件或浏览器 API,再切出尽可能小的客户端岛。下一课会沿着书卡链接继续前进,让每一本书拥有异步生成的动态详情页。
点击“翻开看看”。前两本书会进入第 2 课的占位详情,其余尚未列入占位集合的 slug 会进入 404;下一课会用真实数据统一替换这些阶段性结果。