Chapter 06
变更:Server Actions 与 Route Handlers
上一章讲了缓存:数据怎么存、怎么失效。失效通常由一次写操作触发——这一章讲怎么写。Server Actions 把 02 章那条"函数不能跨边界"的规则开了一个受控的口子;Route Handlers 则是给机器用的标准 HTTP 端点。写完之后,用 05 章的 revalidateTag / revalidatePath 让 UI 更新。
本章你将建立的 schema
- Server Action(
'use server'):一个被编译成"加密 POST 端点"的函数,可从客户端直接调用,或绑到<form action={fn}> - 安全模型:每个 action 都是公开端点——必须校验输入、校验权限(不只是认证);闭包捕获的变量会被加密签名后传到客户端
- Route Handler(
route.ts):标准 HTTP 端点,给 webhook / 第三方 / 移动端 / 公开读 API - 写完必须让缓存失效(接 05 章);Server Action 与 Route Handler 的选择规则
6.1Server Action:可跨界调用的函数
在函数(或文件)顶部写 'use server',它就成为一个可以从客户端直接 await 调用的服务端函数——表面是函数调用,底层是一次 POST 请求。
02 章给过一条硬规则:函数不能从服务端传给客户端,因为函数序列化不了(见 §2.4 那张跨界 props 表的最后两行)。Server Action 是那条规则的唯一例外。'use server' 让编译器把这个函数变成一个 POST 端点,按一个"加密的、每次构建生成的 action ID"路由——这个 ID 是源码位置的 hash,不可猜测。客户端拿到的不是函数本体,而是一个指向该端点的引用;调用这个引用,等价于向那个端点发一次 POST。未被任何地方引用的 action ID 会被 tree-shake 掉,不进产物。
绑到 <form action={fn}> 时还多一层好处:渐进增强。表单在 JS 尚未加载完时也能提交,因为 React 扩展了原生表单的 POST 行为——浏览器原生表单不依赖 JS。
下面是一个 'use server' 的 createTodo,先绑到表单,再演示从一个 Client Component 里直接 await 调用同一个函数。
'use server' // ← 整个文件的导出函数都成为 Server Action
import { db } from '@/lib/db'
import { revalidateTag } from 'next/cache'
export async function createTodo(formData: FormData) {
const title = formData.get('title') as string
await db.todo.create({ title }) // 在服务端写库
revalidateTag('todos', 'max') // 写完让缓存失效(详见 §6.3)
}
import { createTodo } from './actions'
export default function TodosPage() {
return (
<form action={createTodo}> {/* 把 action 直接交给 form */}
<input name="title" />
<button type="submit">新增</button>
</form>
)
}
'use client'
import { createTodo } from './actions'
export function QuickAdd() {
async function onClick() {
const fd = new FormData()
fd.set('title', '买牛奶')
await createTodo(fd) // 像调本地函数,底层是一次 POST
}
return <button onClick={onClick}>一键添加</button>
}
actions.ts文件顶部一行 'use server' 把所有导出函数标成 Server Action。函数体永远在服务端执行——db 不会进客户端 bundle。
quick-add.tsx是 Client Component,却能 import 并 await 这个服务端函数。它拿到的是一个 action 引用,await createTodo(fd) 实际触发了向加密端点的一次 POST,再拿回返回值。这正是 02 章那条规则的例外。
revalidateTag → 新的 RSC payload 流回 → UI 更新。注意:流回的是 RSC payload 而非整页 HTML(呼应 §2.3),所以更新被 reconcile 进当前还活着的组件树,客户端状态不丢。把一个普通服务端函数(没写 'use server')作为 prop 传给一个客户端按钮的 onClick,能成吗?给这个函数加上 'use server' 之后呢?
展开答案(先停 10 秒)
不加 'use server':报错。这正是 02 章 §2.4 的规则——普通函数序列化不了,跨不了网络边界,不能作为 prop 从服务端传给客户端。
加上 'use server' 之后:能。它不再是普通函数,而被编译成一个可跨界的 action 引用(指向加密端点)。传给客户端的是这个引用,客户端 onClick 调用它就发一次 POST。Server Action 是那条规则唯一被官方打开的口子。
6.2安全模型:公开端点要当敌意输入处理
每个 Server Action 编译后都是一个任何人都能打的公开 POST 端点,所以它必须自己校验输入、校验"这个用户有没有权限动这条数据"。
第一个错觉来自类型。TypeScript 的类型在运行时被擦除——参数标注成 { id: number } 挡不住一个手写的恶意请求,对方完全可以传任意 JSON 打这个端点。所以"靠类型保证安全"是假的,必须运行时校验(如用 Zod 解析)。
第二个、也是更致命的错觉是把认证当成了授权。"检查用户登录了没"(authentication)只回答"你是谁";它不回答"你能不能动这一条"(authorization)。只做前者会留下越权漏洞——攻击者把请求里的 id 换成别人的,就动了别人的数据(IDOR,Insecure Direct Object Reference)。每个 action 都要重新校验:当前用户对这个具体对象是否有权限。
Next 提供了一层内置防护:Origin / Host 校验防 CSRF、只接受 POST、action ID 加密不可猜。还有一个容易忽略的点——action 闭包里捕获的变量会被用构建期密钥加密签名后下发到客户端,再随调用传回。所以不要在闭包里捕获 secret(如 API key);用 .bind() 绑定的参数则是显式、不加密地传递的。
'use server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidateTag } from 'next/cache'
const Input = z.object({ postId: z.string(), title: z.string().min(1) })
export async function renamePost(raw: unknown) {
// 1) 运行时校验输入——类型在运行时不存在,挡不住恶意请求
const { postId, title } = Input.parse(raw)
// 2) 认证:你是谁
const session = await auth()
if (!session) throw new Error('未登录')
// 3) 授权:你能不能动「这一条」——少了这步就是越权漏洞(IDOR)
const post = await db.post.find(postId)
if (post.ownerId !== session.userId) throw new Error('无权操作')
await db.post.update(postId, { title })
revalidateTag('posts', 'max')
}
把 Server Action 当成"私有内部函数",或拿它来做读取。两者都错。它不是私有的——编译后是一个公开可调用的端点,谁都能打,所以"反正只有自己的 UI 会调"不成立。它也不该用来读:用 action 读会绕过 05 章的缓存体系(每次都是一次未缓存的 POST 往返),还白白多暴露一个端点。读用 Server Component 直接 await 数据源(04 章),写才用 Server Action。
6.3写完让缓存失效(接 05 章)
Server Action 改了数据库后,缓存仍持有旧渲染;在 action 末尾调用 revalidateTag / revalidatePath(05 章)让相关页面下次请求重新渲染。
写库只改了数据源,不会自动碰 05 章那四层缓存里的任何一层。Data Cache 里那份旧查询结果、Full Route Cache 里那份旧渲染产物,仍然原封不动——下次访问命中的还是旧值。所以"写"和"失效"在 App Router 里是成对出现的两步:写完必须显式告诉框架"哪些缓存作废了"。
revalidateTag('todos') 让所有带 todos 这个 tag 的缓存条目失效,下次访问时重新渲染、重新拿数据。需要"写完当前这次请求内就读到新值"(读己写一致)时,用 updateTag 而非 revalidateTag——前者立即更新当前渲染,后者只标记下次失效。
'use server'
import { db } from '@/lib/db'
import { revalidateTag } from 'next/cache'
export async function createTodo(formData: FormData) {
const title = formData.get('title') as string
await db.todo.create({ title }) // 第一步:写数据源
// 第二步:让缓存失效——少了这步,页面继续显示旧列表
// 注:Next 16 起 revalidateTag 需要 profile 参数(如 'max'),呼应 05 章
revalidateTag('todos', 'max')
}
一个 Server Action 成功写了数据库(数据库里确实多了一行),但刷新页面前,列表仍显示旧数据。最容易漏掉哪一步?
展开答案(先停 10 秒)
漏了 revalidate。action 只完成了"写数据源"那一步,没调用 revalidateTag / revalidatePath,于是 05 章那几层缓存仍持有旧渲染,页面命中的是缓存里的旧列表。补上 revalidateTag('todos', 'max') 即可——写库永远不会自动让缓存失效,这是两件事。
6.4Server Action vs Route Handler
人从你自己的 UI 触发的变更,用 Server Action;机器经 HTTP 访问(webhook、第三方、移动端、公开读 API)的,用 Route Handler。
Route Handler 是 app/api/.../route.ts 里导出的 GET / POST 等函数,签名是标准的 Request → Response。你完全掌控 HTTP 细节——状态码、响应头、缓存策略——并且它能被任意客户端调用(curl、Stripe、安卓 App 都行)。代价是手写:要自己解析请求、自己序列化响应、自己处理 CSRF。
Server Action 把这些都省掉了:没有手写 fetch、没有手写端点、没有手写序列化,内置 CSRF 防护和渐进增强。代价是它只为"你的应用自己的 UI"设计——它不是一个稳定的公开 HTTP 契约,外部系统不该依赖它。经验比例:约 90% 的内部变更用 Action,对外契约用 Handler。
| 维度 | Server Action | Route Handler |
|---|---|---|
| 触发者 | 你自己应用的 UI(人点击 / 提交表单) | 任意 HTTP 客户端(机器) |
| 典型场景 | 保存草稿、点赞、改资料、表单提交 | webhook、第三方回调、移动端、公开读 API |
| HTTP 控制 | 不暴露,框架代管 | 完全掌控状态码 / 响应头 / 缓存 |
| CSRF 防护 | 内置(Origin/Host 校验 + 仅 POST) | 自己负责 |
| 渐进增强 | 有(<form action> 免 JS 可用) | 无(就是个端点) |
| 何时选 | 变更由你的 UI 触发 → 选它(约 90% 内部变更) | 需要公开契约 / 自定义 HTTP 响应 → 选它 |
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- 02 章说函数不能从服务端传到客户端。Server Action 为什么是例外?它底层被编译成了什么?
- 为什么"Server Action 里已经检查了用户已登录"还不够安全?还需要检查什么?
- 一个 Server Action 成功写了数据库,但页面仍显示旧数据。最容易漏掉哪一步?为什么写库不会自动刷新页面?
- (设计层)你要接收 Stripe 的支付成功 webhook,并在用户点"保存草稿"时存草稿。这两件事分别该用 Server Action 还是 Route Handler?为什么?
答案(先做完再展开)
- 因为
'use server'让编译器把它变成一个加密的 POST 端点(按源码位置 hash 出的、不可猜的 action ID 路由)。传给客户端的不是函数本体——那序列化不了,正是 02 章的规则——而是一个指向该端点的引用;调用引用等于发一次 POST。这是那条规则官方打开的唯一口子。 - 因为"已登录"只是认证(你是谁),不是授权(你能不能动这一条)。每个 action 都是公开端点,攻击者可以把请求里的
id换成别人的对象。必须额外校验"当前用户对这个具体对象是否有权限",否则就是越权漏洞(IDOR)。另外别忘了运行时校验输入——TS 类型在运行时已被擦除。 - 漏了 revalidate(
revalidateTag/revalidatePath)。写库只改了数据源,不会自动碰 05 章那四层缓存;Data Cache / Full Route Cache 仍持有旧渲染,页面命中旧值。写和失效是成对的两步。 - Stripe webhook → Route Handler:触发者是机器经 HTTP,需要一个稳定的公开端点、自定义响应(给 Stripe 返 200)、自己验签。保存草稿 → Server Action:触发者是人从你自己的 UI,省掉手写 fetch/端点,内置 CSRF 与渐进增强。判别只看第一个分叉:谁来触发。
"点赞":乐观 UI + 写库 + 多端最终一致
设计一个点赞功能:点击后立即在 UI 上 +1(不等服务端往返),同时把变更写到服务端,并保证多个客户端最终看到一致的计数。三块拼图怎么组合——Client Component(乐观 UI)、Server Action(写库 + 校验授权)、revalidate(05 章)?三者各自负责哪一段,失败时又该怎么回退?
提示(卡住再展开)
乐观那一段用 React 19 的 useOptimistic:在 Client Component 里点击瞬间把本地计数 +1 渲染出去,不阻塞等待。写那一段用一个 'use server' action:它先校验"这个用户能不能给这个对象点赞"(授权,§6.2 那条不能省),再写库。写完调 revalidateTag('likes', ...)(§6.3)让其他客户端下次请求拿到真实计数——这就是"多端最终一致"的来源。失败回退:action 抛错时,useOptimistic 的乐观值会在这次 transition 结束后自动丢弃,UI 回到真实值,因此乐观更新天然是"先假设成功、失败再退回"。