书架现在已经可以读,也可以搜索,但“共读墙”还缺少真正的写入能力。读者需要选择一本书、留下昵称和一段读后感;服务器要拒绝无效数据,成功写入后,页面还要立刻显示新内容。
浏览器表单从 Web 诞生早期就能把数据提交给服务器。Server Actions 没有推翻这个模型,它把接收表单的服务器函数直接接到 React 的 action 属性上,并与重新渲染和提交状态连接起来。我们仍然要认真处理输入、权限和存储,只是少写了一层手工 HTTP 胶水。
本课完成后,/notes 会形成一条完整写入链:Zod 校验输入,Server Action 原子替换数据文件并返回成功 state,useActionState 先把结果交给表单,成功 Effect 再调用 router.refresh() 重读服务器界面。useFormStatus 则在请求期间禁用提交按钮。书摘每次都从文件读取,因此客户端刷新路由后能直接得到刚写入的内容。
Server Action 是运行在服务器的异步函数。把它传给 <form action={...}> 后,浏览器提交表单,Next.js 在服务器调用函数,再把新的界面和结果送回客户端。
“运行在服务器”不等于“只有我们的页面能调用”。被客户端引用的 Server Action 会形成可请求的服务器入口,因此要用和公开 API 相同的标准审视它:
FormData 的任何值。Next.js 会对 Server Action 请求做同源检查,并只允许 POST 调用。这些是有用的框架保护,但不能代替业务授权。当前共读墙刻意允许匿名留言;如果以后改成私人读书会,身份与成员权限就必须在 createNote 内检查。
先把一次提交画成有顺序的事务,而不是直接写 JSX:
从 FormData 取出 bookSlug、reader 和 reflection,使用运行时 schema 校验。
确认书目仍然存在。通过格式校验的 slug 也可能指向已经删除的书。
读取旧书摘,生成带唯一 id 和服务器时间的新数组。
先写临时文件,再用重命名替换正式文件,避免留下半份 JSON。
写入成功后先返回成功 state;客户端 Effect 清空表单并调用 router.refresh(),让书摘列表重新读取文件。
把失败分成两类也很重要。字段不合格、书目不存在属于“预期失败”,适合返回字段消息;磁盘不可写属于基础设施失败,Action 会记录真实错误并只返回克制提示。进程中断等无法完成返回的故障仍由框架错误处理和服务日志接管。
我们将实现下面这条单向数据流:

图中的“按需重新验证”在这个项目里具体指:表单收到成功 state 后调用客户端 router.refresh(),让未缓存的书摘列表重新读取文件;它不是任何数据缓存标签的失效。
无效输入不会进入写入代码;有效输入只有在文件替换成功后才返回成功 state。表单先显示“书摘已经放进共读墙”,再由客户端刷新服务器界面,不会让树更新抢在成功反馈前面。
TypeScript 类型会在编译后消失。即使函数参数写成 string,FormData.get() 在运行时仍可能得到字符串、文件或 null。Zod 的 schema 会在服务器真正检查值,并返回可以展示给用户的字段错误。
这份 schema 同时承担三件事:
trim() 去掉昵称和读后感两端的空白。客户端的 required、minLength 或 maxLength 可以改善输入体验,但访问者能够绕过客户端,所以服务器 schema 才是最终边界。
安装依赖后,在 src/lib/validation.ts 定义 schema:
npm install --save-exact zod@4.4.3import { z } from "zod";
export const noteSchema = z.object({
bookSlug: z.string().trim().min(1, "请选择一本书"),
reader: z
.string()
.trim()
.min(2, "昵称至少需要 2 个字")
.max(20,
--save-exact 把已经完成整课验证的 Zod 4.4.3 精确写进 package.json 与锁文件,避免不同读者安装到行为可能变化的后续版本。依赖仍要通过 npm audit 和升级流程维护,精确版本不是永远不升级。
链式校验按从左到右的顺序执行。z.string() 先拒绝 File、null 等非字符串值;trim() 生成去除两端空白的新字符串;随后 min 和 max 才对规范化结果计算长度。因此只输入空格不会绕过最短长度。bookSlug 也使用 trim(),这样手工构造请求时夹带的空白不会进入书目查找。
z.infer 从 schema 推导 TypeScript 类型,避免再手写一份可能漂移的 NoteInput。它的输出只存在于编译阶段;真正让未知输入变得可信的是后面的 safeParse。常见错误是先把 FormData 断言成 NoteInput 再使用,那只会关闭类型警告,不会执行任何校验。
下面两组数据会得到不同结果:
noteSchema.safeParse({
bookSlug: "the-long-river",
reader: "小河",
reflection: "这段关于城市和河流的描写让我停下来读了两遍。",
}).success;
// true
noteSchema.safeParse({
bookSlug: "the-long-river",
reader: "小河",
reflection: "很好看",
}).success;
// false第二组读后感只有三个字,会得到 reflection 字段错误“读后感至少需要 10 个字”。错误已经按字段组织,表单不必解析一段模糊的异常文本。
"use server" 放在文件顶部时,这个文件是 Server Actions 模块。Next.js 16 要求它对外导出的值都是异步函数。类型会在编译时消失,可以导出类型;对象、字符串、schema 或普通同步函数不能作为运行时导出。
很容易写出下面这种错误结构:
"use server";
export const initialState = { status: "idle", message: "" };
export async function createNote() {}initialState 是对象,不是异步函数,生产构建会拒绝这个模块。解决方法不是把对象伪装成 Promise,而是按职责拆文件:
action-state.ts 是普通模块,放状态类型和初始对象。actions.ts 是 "use server" 模块,只导出可被调用的异步 Action。创建 src/lib/action-state.ts:
import type { NoteInput } from "@/lib/validation";
export type NoteFormState = {
status: "idle" | "error" | "success";
message: string;
fieldErrors?: Partial<Record<keyof NoteInput, string[]>>;
};
export const initialNoteFormState: NoteFormState =
这段类型可以从里向外读:keyof NoteInput 得到 bookSlug | reader | reflection;Record<..., string[]> 表示每个字段对应一组错误消息;Partial 允许只返回实际出错的字段。输入是 Server Action 的校验结果,输出是客户端能够稳定渲染的状态对象。
status 表达一次提交处于初始、失败或成功,message 放整次提交的摘要,fieldErrors 放控件级错误。不要把异常对象直接塞进 state:它通常不可序列化,也可能泄露服务器路径与堆栈。
import type 只为类型检查服务,不会把 action-state.ts 变成 Action 模块。客户端表单稍后可以安全地从这个普通模块导入 initialNoteFormState。src/lib/actions.ts 不在这里创建半份文件;完成校验、写入和清理策略以后,我们会一次写入可直接运行的完整版本。
最终的导出边界很清楚:
src/lib/action-state.ts
├─ NoteFormState 类型
└─ initialNoteFormState 普通对象
src/lib/actions.ts
└─ createNote async Server Action如果构建提示“use server 文件只能导出 async function”,先检查文件级指令下面是否导出了初始 state、schema 或工具对象。把这些值移到普通模块,比删除 "use server" 更符合边界设计。
useActionState 会把上一次状态作为第一个参数传给 Action,把浏览器生成的 FormData 作为第二个参数传入。因此 createNote 的签名不是常见的 (formData),而是:
(previousState, formData) => nextState当前逻辑不需要旧状态,所以参数名写成 _previousState。下划线说明我们有意不使用它,也能通过 lint。
safeParse 不抛异常。失败时,我们用 flatten().fieldErrors 得到按字段分组的字符串数组;成功时,parsed.data 已经过 schema 校验和 trim,后续代码只使用这份数据。
格式正确仍不代表业务对象存在。bookSlug 可能指向刚被移除的书,所以 Action 还要读取书单,并用 some 确认至少有一本书的 slug 相同。
先不要创建一份缺少成功返回值的半成品 Action。把即将写入完整函数的失败分支列成验收表:
完整 Action 会在“让 Action 先返回成功 state”一节一次写入。这样任意保存点都不会留下一个声明返回 Promise<NoteFormState>、成功路径却没有 return 的函数。
实现时不要把原始 formData 继续传给数据层。通过校验后统一使用 parsed.data,这样后续代码不会不小心绕过 schema。
提交空表单时,Action 返回:
{
"status": "error",
"message": "请检查标出的字段",
"fieldErrors": {
"bookSlug": ["请选择一本书"],
"reader": ["昵称至少需要 2 个字"],
"reflection": ["读后感至少需要 10 个字"]
}
}提交一个格式正确但已经不存在的 bookSlug 时,返回业务错误“这本书已经不在书架上”,并把“请选择仍在书架中的书”放进 fieldErrors.bookSlug。两种情况都没有触碰写入文件。
直接对 notes.json 调用 writeFile 有一个风险:如果进程在写到一半时中断,正式文件可能只剩半段 JSON。更稳妥的教学实现是先在同一目录写完临时文件,再用 rename 替换正式文件。
重命名在同一文件系统中通常是原子操作。读取者看到的是旧文件或完整的新文件,不会看到正在写到一半的版本。临时文件名使用 randomUUID(),也避免两个请求写到同一个临时路径。
但“原子替换”不等于“并发事务”。两个请求仍可能同时读到相同旧数组,分别追加一条记录,后完成的替换覆盖先完成的结果。这份文件方案适合学习和单实例低并发演示;多人写入、多个进程或无服务器部署应换成支持事务的数据库。
在写完整代码前,先把四个路径与值确定下来:正式路径是 data/notes.json;临时文件放在同一个 data 目录;新 id 与临时文件名分别调用一次 randomUUID();创建时间由服务器调用 new Date().toISOString() 生成。
同目录非常关键。若把临时文件放进另一个挂载点,rename 可能跨文件系统而失去原子替换保证。JSON.stringify(value, null, 2) 生成便于检查的两空格缩进,末尾的 \n 让文件符合常见文本文件习惯。
完整实现还会用 try/catch/finally:try 完成读取、临时写入和替换;catch 记录服务器错误并返回克制消息;finally 使用 rm(..., { force: true }) 清理可能残留的临时文件。无论在哪一步失败,正式文件都不会被半段 JSON 直接覆盖。
createdAt 和 id 都在服务器生成,不能接受客户端提供的“发布时间”或主键,否则访问者可以伪造排序或尝试覆盖已有记录。
成功写入后,data/notes.json 多出一条结构完整的记录:
{
"id": "b90d973e-503f-4ebd-a273-6d2f4bfcaa72",
"bookSlug": "the-long-river",
"reader": "小河",
"reflection": "这段关于城市和河流的描写让我停下来读了两遍。",
"createdAt": "2026-07-16T02:35:18.442Z"
}实际 id 和时间每次都不同。正式文件始终是可以完整解析的 JSON;如果临时写入失败,旧文件仍然保留。
书摘由 Server Action 写入同一个 notes.json,读取端需要立刻看见文件的新版本。这里选择让 getNotes() 保持未缓存:每次 Server Component 重新执行,它都会读取当前文件并按时间排序。书目变化较少,getBooks() 与 getBook() 仍然沿用前一课的缓存,两类数据不必采用相同策略。
刷新时机要和表单反馈分开。若 Server Action 在返回前主动替换路由树,新的服务器界面可能先合并,useActionState 的成功消息就没有稳定机会显示。更可靠的顺序是:Action 只负责校验与写入,然后返回成功 state;Client Component 收到 success 后,再调用 router.refresh()。
这也让职责更清楚。服务器决定“写入是否成功”,客户端决定“看到成功后怎样更新交互界面”。路由刷新不会成为写入成功的前置条件。
先确认 src/lib/data.ts 中的书摘读取没有缓存指令:
export async function getNotes(): Promise<ReadingNote[]> {
const notes = await readDataFile<ReadingNote[]>("notes.json");
return notes.toSorted((a, b) =>
b.createdAt.localeCompare(a.createdAt),
);
}src/lib/actions.ts 不再导入任何刷新函数,也不在 rename 后触发路由更新。完整代码如下:
"use server";
import { randomUUID } from "node:crypto";
import { rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { NoteFormState } from "@/lib/action-state";
import { getBooks, getNotes } from "@/lib/data";
import type { ReadingNote } from "@/lib/types";
import { noteSchema } from "@/lib/validation";
这份文件的职责是守住完整写入边界。"use server" 必须位于模块顶部;客户端导入 createNote 时,Next.js 只传递一个可调用的服务器引用,不会把 node:fs 打进浏览器。randomUUID 同时生成记录 id 与互不冲突的临时文件名;ReadingNote 让新对象在写入前接受类型检查。
输入首先是不可相信的 FormData。schema 成功后,数据流只使用经过 trim 和长度校验的 parsed.data。接着 getBooks() → some(...) 验证业务对象仍存在。只有这两道门都通过,函数才读取旧书摘、把新记录放在数组最前面、写临时文件并替换正式文件。
try 的输出有两种:替换成功返回 success state;任何文件错误进入 catch,服务器日志保留真实错误,浏览器只得到不含路径和堆栈的消息。finally 无论成功失败都执行,force: true 让不存在的临时文件也能安全清理。不要在 catch 中返回 String(error),也不要删掉 finally 后让 .tmp 文件长期堆积。
这仍不是并发事务。两个请求可能同时读取相同旧数组,后替换者覆盖先替换者;原子 rename 只解决“半份文件”,没有锁住“读取—追加—替换”的整个过程。
完整成功链的先后关系现在是:
临时文件写完
→ rename 替换 notes.json
→ Action 返回 success
→ useActionState 显示成功消息
→ 客户端 Effect 执行 router.refresh()
→ Suspense 内的 getNotes() 读取新文件表单先接收一个很小的成功对象:
{
"status": "success",
"message": "书摘已经放进共读墙"
}Action 没有把整份书摘数组塞进返回值。成功消息留在 Client Component state 中,后续路由刷新只负责让服务器列表重新读取唯一数据源。
表单需要一个 Client Component,因为它要读取提交状态、显示返回错误,并在成功后重置 DOM。我们只把这块交互区域标记为 "use client";书目读取和共读墙列表仍留在 Server Component。
useActionState(createNote, initialNoteFormState) 返回三个值:
state:Action 最近一次返回的状态。formAction:交给 form action 的包装函数;变量名可以自定,这里用它和服务器的 createNote 区分。isPending:Hook 自己也会提供 pending;本项目把按钮状态交给下一节的 useFormStatus。当使用 useActionState 包装后,createNote 才会收到 previousState 和 formData 两个参数。字段错误来自服务器,客户端只负责把它放到相应控件下面。
useRouter() 提供的 router.refresh() 会重新请求当前路由的 Server Component,并把新的 RSC 结果合并进现有树。它保留未被替换的 Client Component state,所以 useActionState 中的成功消息可以继续显示。它也不会清空服务器数据缓存:缓存的书目可以复用,Suspense 内未缓存的 getNotes() 则会重新读取文件。
创建 src/components/note-form.tsx。下面是包含提交按钮、字段关联和成功刷新的完整文件:
"use client";
import { useActionState, useEffect, useRef } from "react";
import { useFormStatus } from "react-dom";
import { useRouter } from "next/navigation";
import { createNote } from "@/lib/actions";
import { initialNoteFormState } from "@/lib/action-state";
type BookOption = { slug: string; title: string };
先看边界。"use client" 让这个文件能够使用 Hook;传入的 books 只含 slug 和标题,是从 Server Component 跨边界传来的可序列化数组。导入 createNote 得到的是 Action 引用,真正的文件写入仍只在服务器执行。
再看表单数据。每个控件的 name 必须与 Action 中的 formData.get(...) 完全相同。字段使用 defaultValue 或 DOM 自身值,而不是受控 value,所以 formRef.current?.reset() 可以在成功后恢复初始状态;校验失败没有调用 reset,输入会保留。
错误数据从 state.fieldErrors 拆成三个变量。aria-invalid 告诉辅助技术控件当前无效,aria-describedby 再把控件与对应 <ul id="...-error"> 关联。只显示红色而不建立这种关系,会让无法看见颜色的读者不知道错误属于哪个字段。
role="status" 是礼貌播报区域,成功和失败消息返回后都会被读出。消息不存在时不渲染空段落,可以减少无意义的可访问性节点。
Effect 依赖 router 与完整的 state 对象,而不只依赖 state.status。每次 Action 返回的都是新状态对象,所以连续两次成功提交都会触发重置和 router.refresh();如果只监听字符串 "success",第二次成功与第一次取值相同,Effect 不会再次运行。
提交过短的读后感后,文本框下面出现“读后感至少需要 10 个字”,表单内容仍在。修正后再次提交,状态变成成功,三个控件被清空,底部显示“书摘已经放进共读墙”。随后服务器列表重新读取,成功消息仍然可见。
错误展示与写入规则来自同一份服务器结果,没有在客户端复制长度判断。
请求需要时间。如果按钮没有反馈,用户可能连续点击,制造重复提交。useFormStatus 能读取最近一层父表单的状态,其中 pending 表示该表单的 Action 是否仍在处理。
这个 Hook 必须在 <form> 的后代组件中调用。若在渲染 <form> 的 NoteForm 自身调用,它看不到尚未成为父级的表单。最小而清楚的做法是拆出 SubmitButton。
禁用按钮减少误操作,但不是防重放机制。真正需要“恰好写一次”的业务还要在数据库使用唯一约束或幂等键。当前项目只做交互层防重复。
完整文件已经包含这个子组件,这里单独读懂它:
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
className="button-primary w-full"
type="submit"
disabled={pending}
>
{pending ? "正在发布到共读墙…" : "发布到共读墙"}
</button>
);
}它没有 props,输入来自最近父表单的上下文,输出是一枚会随 pending 改变的按钮。必须把它渲染在 <form> 内部;若把 useFormStatus 放到 NoteForm 顶层,它读取不到尚未建立的父表单状态。disabled 阻止正常重复点击,文字变化则让等待状态不依赖颜色表达。
点击“发布到共读墙”后,按钮立即显示“正在发布到共读墙…”,并暂时不可点击。Action 返回后:
提交反馈现在覆盖了等待、失败和成功三种状态。用户不会面对一个“点了但不知道有没有发生”的按钮。
表单需要客户端交互,列表不需要。NoteForm 保持 Client Component,NotesList 则是异步 Server Component。它在服务器调用未缓存的 getNotes(),不把整份书摘数据发送给客户端再筛选。
项目启用了 Cache Components。未缓存的文件读取属于请求时工作,需要放进 Suspense 边界。页面标题、表单和缓存的书目可以先渲染;书摘仍在读取时,边界只在右侧显示一块脉冲骨架。这条边界也让生产构建保留静态外壳。
NotesPage 只等待缓存的 getBooks() 来生成表单选项。NotesList 在边界内部并行取得缓存书目和未缓存书摘,再建立仅供服务器渲染使用的标题映射。成功 Effect 调用 router.refresh() 后,书目继续复用缓存,书摘列表重新读取最新文件。
创建 src/app/notes/page.tsx:
import type { Metadata } from "next";
import { Suspense } from "react";
import { NoteForm } from "@/components/note-form";
import { getBooks, getNotes } from "@/lib/data";
export const metadata: Metadata = {
title: "共读墙",
description: "写下真正留在心里的那一句。",
};
const dateFormatter = new Intl.
这段代码有三条数据路径。页面顶层的 getBooks() 输出表单选项,并只把 slug、title 这两个可序列化字段传给 Client Component。NotesList 内的 Promise.all 同时发起书目和书摘读取;书目会命中缓存,书摘每次读取文件。Map 只在服务器组件内部把 slug 翻译成标题,没有跨入客户端边界。
dateFormatter 在模块加载时创建一次,避免每张卡片重复构造格式化器;timeZone: "Asia/Shanghai" 让相同 ISO 时间不会因服务器部署地区不同而跨日。<time dateTime> 保留机器可读原值,页面文字则使用中文日期。
空数组有明确提示,旧书摘引用已删除书目时回退到“未知书籍”。常见错误是直接渲染空 <div>,或假定所有历史 slug 永远存在;两者都会让数据变化变成难以理解的空白或崩溃。
页面标题和表单先出现,右侧读取期间显示一块与列表区域相近的骨架,随后替换为按时间倒序排列的书摘。成功发布后,不需要浏览器硬刷新,新卡片会出现在列表顶部;卡片显示昵称、书名和由 time 元素标记的日期。
如果一条旧记录引用的书已经不存在,页面显示“未知书目”而不是崩溃。这个回退让读取端能够容忍历史数据变化。
/notes 已经能工作,但读者不应该靠手写地址发现它。站点导航负责稳定入口,首页按钮负责表达主要行动,书籍详情按钮则把“读完一本书”自然接到“留下读后感”。
这三个入口都使用 next/link。Link 会保留 App Router 的客户端导航能力,不需要用 window.location 触发整页加载。链接文字还要说明目的:“共读墙”和“留下读后感”比笼统的“了解更多”更容易判断。
在 src/components/site-header.tsx 的导航数组中加入共读墙:
const links = [
{ href: "/books", label: "书架" },
{ href: "/notes", label: "共读墙" },
{ href: "/about", label: "关于" },
] as const;更新 src/app/page.tsx 的首页行动区,把原来通往 /about 的次按钮改为 /notes:
<div className="mt-8 flex flex-wrap gap-3">
<Link className="button-primary" href="/books">
逛逛书架
</Link>
<Link className="button-secondary" href="/notes">
留下读后感
</Link>
</div>最后更新 src/app/books/[slug]/page.tsx 的详情行动按钮,把原来的 /books 改为 /notes:
<Link
className="button-primary mt-10 inline-flex"
href="/notes"
>
留下读后感
</Link>读者现在可以从三条自然路径进入共读墙:
三个链接的输入都是固定 href,输出是 App Router 导航。as const 保留每个 href 与 label 的字面量类型,并防止后续代码意外改写数组项。常见错误是用 window.location 完成站内跳转,那会丢掉客户端路由带来的平滑导航与预取能力。
写入功能要分别验证“什么都不写”和“确实写入”。只走成功路径,很容易漏掉字段错误、重复点击、路由没有重读文件或失败后清空输入的问题。
文件写入还有明确的部署边界。单实例、低并发时,它让数据变化容易观察;多个实例各自有文件系统时,请求可能写到不同副本。第十课会据此选择部署方式,并说明何时必须换数据库。
完成下面四组检查:
不选书、昵称只填一个字、读后感少于十个字,然后提交。确认三个字段分别显示服务器错误,数据文件没有新增记录。
填写有效内容并提交。确认按钮出现 pending 文案,成功后表单重置,新书摘出现在列表顶部。
刷新共读墙。确认刚才的书摘仍然存在,说明结果来自持久化文件,不是暂存在组件 state。
连续思考两个并发请求的过程。确认临时文件能防止半份 JSON,但不能防止“后写覆盖先写”;把这个限制记入发布决策。
下面是完整成功链的真实页面。URL 为 http://localhost:3000/notes,视口为 1440×900;选择书籍、填写合法昵称与不少于 10 个字的内容,点击“发布到共读墙”,等待按钮恢复后捕获。观察左侧表单已经清空、成功消息仍然可见,右侧新书摘位于列表顶部。

截图左下角的黑色 N 是 next dev 的开发指示器,不属于站点导航或表单。生产构建不会输出这个入口;这里保留它,是为了让截图如实对应本课的开发运行阶段。
这张图同时验证了 Action 返回成功、useActionState 接住消息、Effect 重置表单、router.refresh() 重新取得 Server Component 结果四件事。若只看到成功消息却没有新卡片,检查 getNotes() 是否误缓存;若卡片出现但表单没有清空,检查 Effect 是否依赖完整 state。
为什么“临时文件加 rename”仍不能代替数据库事务?
到这里,共读墙已经把客户端交互与服务器写入接了起来。下一课不再扩展业务,而是让页面更容易被搜索引擎和分享平台理解,并用自动化检查守住我们刚刚建立的输入规则。