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 章)

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 调用同一个函数。

app/todos/actions.ts tsx
'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)
}
app/todos/page.tsx(表单绑定 · Server Component) tsx
import { createTodo } from './actions'

export default function TodosPage() {
  return (
    <form action={createTodo}>       {/* 把 action 直接交给 form */}
      <input name="title" />
      <button type="submit">新增</button>
    </form>
  )
}
app/todos/quick-add.tsx(直接调用 · Client Component) tsx
'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 章那条规则的例外。

客户端 表单提交 / await 调用 乐观更新可先发生 服务端执行 action 校验 → 写库 revalidateTag UI 更新 收到新 RSC payload 列表反映新数据 POST 加密 action ID RSC payload 流回 同一棵客户端树 · 状态保留
图 6.1一次 Server Action 调用的完整往返:客户端触发 → 携带加密 action ID 的 POST → 服务端校验并写库 → 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() 绑定的参数则是显式、不加密地传递的。

app/posts/actions.ts(校验输入 + 校验所属权) tsx
'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——前者立即更新当前渲染,后者只标记下次失效。

app/todos/actions.ts(写 + 失效成对出现) tsx
'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。

表 6.1 · Server Action 与 Route Handler 的分工
维度Server ActionRoute Handler
触发者你自己应用的 UI(人点击 / 提交表单)任意 HTTP 客户端(机器)
典型场景保存草稿、点赞、改资料、表单提交webhook、第三方回调、移动端、公开读 API
HTTP 控制不暴露,框架代管完全掌控状态码 / 响应头 / 缓存
CSRF 防护内置(Origin/Host 校验 + 仅 POST)自己负责
渐进增强有(<form action> 免 JS 可用)无(就是个端点)
何时选变更由你的 UI 触发 → 选它(约 90% 内部变更)需要公开契约 / 自定义 HTTP 响应 → 选它
谁来触发? trigger source 需要公开端点 或自定义响应? Server Action 省 fetch · 内置 CSRF · 渐进增强 Route Handler route.ts · 掌控 HTTP 人 / 你的 UI 机器 / HTTP 否 是
图 6.2变更工具的决策树。注意:第一个分叉只看触发者——只要是机器经 HTTP就直接走 Route Handler;只有"人从你自己 UI 触发"才进第二问,而第二问只有在确实需要公开契约或自定义 HTTP 响应时才回到 Handler,否则落在 Server Action。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. 02 章说函数不能从服务端传到客户端。Server Action 为什么是例外?它底层被编译成了什么?
  2. 为什么"Server Action 里已经检查了用户已登录"还不够安全?还需要检查什么?
  3. 一个 Server Action 成功写了数据库,但页面仍显示旧数据。最容易漏掉哪一步?为什么写库不会自动刷新页面?
  4. (设计层)你要接收 Stripe 的支付成功 webhook,并在用户点"保存草稿"时存草稿。这两件事分别该用 Server Action 还是 Route Handler?为什么?
答案(先做完再展开)
  1. 因为 'use server' 让编译器把它变成一个加密的 POST 端点(按源码位置 hash 出的、不可猜的 action ID 路由)。传给客户端的不是函数本体——那序列化不了,正是 02 章的规则——而是一个指向该端点的引用;调用引用等于发一次 POST。这是那条规则官方打开的唯一口子。
  2. 因为"已登录"只是认证(你是谁),不是授权(你能不能动这一条)。每个 action 都是公开端点,攻击者可以把请求里的 id 换成别人的对象。必须额外校验"当前用户对这个具体对象是否有权限",否则就是越权漏洞(IDOR)。另外别忘了运行时校验输入——TS 类型在运行时已被擦除。
  3. 漏了 revalidate(revalidateTag / revalidatePath)。写库只改了数据源,不会自动碰 05 章那四层缓存;Data Cache / Full Route Cache 仍持有旧渲染,页面命中旧值。写和失效是成对的两步。
  4. 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 回到真实值,因此乐观更新天然是"先假设成功、失败再退回"。