React 基础体系 · 第 67/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
Next.js Server Actions:序列化、认证、校验、重放和渐进增强
Server Actions 是一种让客户端组件以“调用函数”的形式触发服务端代码的机制。它不是把普通 JavaScript 函数透明地搬到服务器上,而是将函数注册为服务端入口,客户端提交可序列化的参数,框架在服务器执行函数,再把可序列化的结果或错误状态返回给客户端。
因此,一个 Server Action 同时具有三种属性:
- 它是服务端函数,可以访问数据库、密钥和服务器运行时。
- 它是跨边界调用的入口,调用参数和返回值必须经过序列化。
- 它通常通过 HTTP POST 暴露,不能因为“源码写在服务端”就忽略认证、授权、输入校验和重复请求。
本文以 React 19、现代 TypeScript 和 Next.js App Router 为范围,重点说明这条边界上的五个问题:
- 什么能够穿过客户端与服务器边界;
- 如何在 Action 内认证用户并授权操作;
- 为什么 TypeScript 类型不能代替运行时校验;
- 为什么请求可能被重放,以及如何实现幂等;
- 没有 JavaScript 或尚未完成 Hydration 时,表单如何继续工作。
一、Server Action 的真实调用模型
1. use server 的含义
在服务端文件顶部写:
// app/actions.ts
'use server'
export async function createProject(formData: FormData) {
// 这里的代码在服务端执行
}
'use server' 是指令。对文件使用时,该文件导出的函数都可以作为 Server Function;对函数使用时,只标记单个函数。
客户端拿到的并不是 createProject 的函数体,而是一个可以被 React/Next.js 识别的服务端引用。调用过程可以抽象为:
客户端组件
│
│ 提交参数:FormData / 字符串 / 普通对象
▼
React Server Components 协议与 Next.js Action 入口
│
│ 服务器反序列化参数
▼
Server Action
│
├─ 认证
├─ 授权
├─ 输入校验
├─ 幂等检查
└─ 数据库写入
│
▼
序列化返回值或错误
│
▼
客户端更新状态或执行重定向
这里的“调用”不是普通的本地函数调用。网络延迟、连接中断、浏览器重复提交、服务器重试和用户恶意构造请求都可能发生。
2. Server Action 通常是 POST 入口
Server Actions 主要用于变更数据,例如创建、修改、删除记录。Next.js 会为 Action 生成服务端调用入口,表单提交和程序化调用最终都需要向服务器发送请求。
这意味着下面的判断是错误的:
“这个函数只被我的按钮调用,所以不需要权限检查。”
正确的判断是:
“这个函数是一个服务端入口,任何到达该入口的请求都必须重新认证、授权和校验。”
客户端按钮、菜单隐藏、路由保护只能改善用户体验,不能构成安全边界。
二、序列化:跨边界传输的到底是什么
1. 序列化的定义
序列化是把内存中的值转换为可以跨进程、跨网络传输的表示;反序列化则是服务器把这种表示恢复为可使用的值。
Server Action 的参数和返回值需要符合 React Server Components 所支持的可序列化范围。工程上最稳妥的公共数据结构是:
stringnumberbooleannullundefined- 数组
- 只包含上述值的普通对象
- 表单提交产生的
FormData
某些 React 版本还支持特定内建类型,例如 Date、Map、Set、URL、FormData、File 等,但具体支持范围应以当前 React 和 Next.js 版本文档为准。不要把“JavaScript 能保存”误认为“能够穿过 RSC/Action 协议”。
以下值不应作为 Action 参数或返回值设计的一部分:
// 不要这样设计
export async function badAction(
db: DatabaseClient,
callback: () => void,
request: Request,
) {
// ...
}
原因分别是:
- 数据库客户端包含连接、缓存和方法,不能序列化;
- 函数不能被安全地编码为远程调用参数;
Request不是普通业务数据,且其流状态不能简单跨边界传递。
应当传递业务标识和普通数据,在服务端重新获取资源:
'use server'
export async function updateProject(
projectId: string,
input: { name: string },
) {
const project = await db.project.findUnique({
where: { id: projectId },
})
if (!project) {
return { ok: false, message: '项目不存在' }
}
// 使用服务器上的 db,而不是从客户端传入 db
await db.project.update({
where: { id: projectId },
data: { name: input.name },
})
return { ok: true }
}
2. FormData 的特点
HTML 表单提交的数据不是一个已经按 TypeScript 类型组织好的对象,而是键值集合:
<input name="name" value="demo">
<input name="count" value="3">
服务端收到后:
const name = formData.get('name')
const count = formData.get('count')
得到的值可能是:
- 字符串;
File;null;- 对于重复字段,同名字段还可能有多个值。
即使 HTML 中有:
<input name="count" type="number">
浏览器提交的仍然通常是字符串 "3",不是数字 3。type="number"主要影响浏览器界面和部分客户端校验,不会让服务端获得 TypeScript 的 number。
3. 返回值应当是显式 DTO
建议返回简单的状态对象,而不是直接返回数据库实体、ORM 对象或 Error:
type ActionState = {
ok: boolean
message?: string
fieldErrors?: Record<string, string[]>
}
原因有两个:
- ORM 实例可能包含不可序列化的原型、方法或特殊字段。
- 返回 DTO 能够明确区分用户可见错误和内部异常。
例如:
return {
ok: false,
message: '项目名称已存在',
fieldErrors: {
name: ['请使用其他名称'],
},
}
序列化不是加密。任何被返回给客户端的字段都应视为客户端可见数据;数据库内部字段、权限信息和密钥不能因为“经过了 RSC 协议”就变得保密。
三、一个完整的表单 Action
下面使用 zod 做运行时校验。安装依赖:
npm install zod
1. 服务端 Action
// app/projects/actions.ts
'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'
import { requireUser } from '@/lib/auth'
import { db } from '@/lib/db'
const createProjectSchema = z.object({
name: z
.string()
.trim()
.min(1, '项目名称不能为空')
.max(100, '项目名称不能超过 100 个字符'),
})
export type CreateProjectState = {
ok: boolean
message?: string
fieldErrors?: {
name?: string[]
}
}
export async function createProject(
_previousState: CreateProjectState,
formData: FormData,
): Promise<CreateProjectState> {
const user = await requireUser()
const rawName = formData.get('name')
// 先确认字段形状,再交给 schema 处理
if (typeof rawName !== 'string') {
return {
ok: false,
message: '请求格式错误',
fieldErrors: {
name: ['name 必须是文本'],
},
}
}
const parsed = createProjectSchema.safeParse({
name: rawName,
})
if (!parsed.success) {
return {
ok: false,
message: '请修正表单错误',
fieldErrors: parsed.error.flatten().fieldErrors,
}
}
const existing = await db.project.findFirst({
where: {
ownerId: user.id,
name: parsed.data.name,
},
select: { id: true },
})
if (existing) {
return {
ok: false,
message: '你已经有同名项目',
fieldErrors: {
name: ['项目名称必须唯一'],
},
}
}
const project = await db.project.create({
data: {
ownerId: user.id,
name: parsed.data.name,
},
select: { id: true },
})
// redirect 会中断当前函数,不应再依赖它后面的代码执行
redirect(`/projects/${project.id}`)
}
这个 Action 的顺序不是随意的:
requireUser()确认调用者身份;- 从
FormData取出原始值; - 运行时校验值的形状和业务约束;
- 使用当前用户身份执行查询;
- 创建记录;
- 成功后重定向。
其中 requireUser 和 db 是应用自己的基础设施。一个典型的 requireUser 应当在服务端读取会话 Cookie 或访问认证系统,并在没有有效身份时抛出未认证错误或重定向到登录页。不能从表单中接收 userId 后直接相信它。
2. 客户端表单
// app/projects/create-project-form.tsx
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import {
createProject,
type CreateProjectState,
} from './actions'
const initialState: CreateProjectState = {
ok: false,
}
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? '创建中…' : '创建项目'}
</button>
)
}
export function CreateProjectForm() {
const [state, formAction] = useActionState(
createProject,
initialState,
)
return (
<form action={formAction}>
<label>
项目名称
<input name="name" required maxLength={100} />
</label>
{state.fieldErrors?.name?.map((error) => (
<p key={error} role="alert">
{error}
</p>
))}
{state.message && !state.fieldErrors?.name && (
<p role="status">{state.message}</p>
)}
<SubmitButton />
</form>
)
}
在 React 19 中,useActionState 的 Action 签名是:
(previousState, formData) => nextState
所以服务端函数的第一个参数不是表单,而是上一次状态。直接写成下面这样会导致参数错位:
// 错误示例:把 previousState 当成 FormData 使用
export async function wrongAction(formData: FormData) {
const name = formData.get('name')
}
如果不使用 useActionState,而是直接写:
<form action={createProject}>
则 Action 可以采用只接收 FormData 的签名:
'use server'
export async function createProjectDirect(formData: FormData) {
// ...
}
两种形式不能混用。useActionState 会为 Action 增加状态参数,并返回一个新的 formAction。
useFormStatus 必须放在 <form> 的后代组件中。它读取最近表单的提交状态;把它放在表单外部,通常无法得到该表单的 pending 状态。
四、认证和授权不是同一件事
1. 认证:你是谁
认证(authentication)是确认调用者身份。例如从会话 Cookie 中得到:
type User = {
id: string
role: 'user' | 'admin'
}
requireUser() 应该在没有有效会话时阻止继续执行。
2. 授权:你能做什么
授权(authorization)是判断已经识别出的用户是否有权执行当前操作。即使用户已经登录,也不代表他能修改任意项目。
错误写法:
const project = await db.project.findUnique({
where: { id: projectId },
})
if (!project) {
throw new Error('not found')
}
await db.project.update({
where: { id: projectId },
data: { name },
})
这里没有确认项目属于当前用户。攻击者只要把 projectId 换成其他人的 ID,就可能越权修改。
正确做法是把权限条件放进查询或更新条件:
const result = await db.project.updateMany({
where: {
id: projectId,
ownerId: user.id,
},
data: {
name,
},
})
if (result.count !== 1) {
return {
ok: false,
message: '项目不存在,或你没有修改权限',
}
}
这样数据库操作本身就带有所有权条件。读取和写入都要这样处理,而不是只在页面加载时检查一次。
3. 认证不能依赖客户端传值
以下字段都不能作为权限依据:
formData.get('userId')
formData.get('role')
formData.get('isAdmin')
客户端可以修改隐藏字段、构造新的 POST 请求,甚至完全绕过页面。正确关系应当是:
请求携带:projectId、name
服务器推导:userId、role、权限范围
数据库校验:projectId 是否属于 userId
角色和所有权必须来自服务端可信来源,例如签名会话、数据库或身份提供商。
4. CSRF 与来源检查
Server Actions 使用 POST 和 Cookie 会话时,仍然要考虑跨站请求伪造(CSRF)。现代浏览器的 SameSite Cookie 策略、Next.js 对 Action 请求来源的检查以及部署平台的代理配置可以降低风险,但它们不是业务授权的替代品。
需要特别检查:
- 生产环境是否正确设置 HTTPS;
- 会话 Cookie 是否设置
Secure、合适的SameSite和HttpOnly; - 反向代理是否正确转发 Host 和 Origin;
- Next.js 版本与配置是否允许特定的额外来源;
- 是否把 Action 错当成允许任意第三方调用的公开 API。
如果需要让跨站客户端正式调用,应设计独立的 API 认证和 CSRF 策略,而不是依赖 Server Action 的内部调用约定。
五、校验:TypeScript 不会校验网络输入
1. 类型注解在运行时不存在
下面的类型只在编译阶段帮助开发者:
type Input = {
name: string
}
export async function action(input: Input) {
// input 在运行时仍然可能是恶意构造的数据
}
请求到达服务器时,攻击者可以发送:
{
"name": 123
}
或者直接提交没有 name 的表单。TypeScript 不会替服务器解析网络数据。
因此应区分两层:
- 传输校验:字段是否存在、类型是否正确、是否超过大小;
- 业务校验:名称是否为空、状态转换是否合法、用户是否有权限。
2. FormData 的重复字段和文件字段
不应对所有字段无条件使用 String():
// 风险:null 会变成 "null",File 也可能变成 "[object File]"
const name = String(formData.get('name'))
应先检查:
const value = formData.get('name')
if (typeof value !== 'string') {
return {
ok: false,
message: 'name 必须是文本',
}
}
如果字段允许多个值,应明确使用:
const tags = formData.getAll('tags')
if (!tags.every((tag): tag is string => typeof tag === 'string')) {
return {
ok: false,
message: '标签格式错误',
}
}
文件上传还要校验大小、MIME 类型、文件名和存储路径。浏览器提交的 MIME 类型不能单独作为文件内容真实性证明;高风险场景应在服务器检查文件头,并使用不可预测的存储名。
3. 数据库约束仍然必要
应用层检查不能取代数据库唯一约束。假设两个并发请求都执行:
请求 A:查询“名称不存在”
请求 B:查询“名称不存在”
请求 A:插入
请求 B:插入
如果只有应用层查询,两者都可能成功。应同时建立数据库约束:
CREATE UNIQUE INDEX project_owner_name_unique
ON projects (owner_id, name);
然后把唯一冲突转换成用户可理解的状态。应用层检查用于较早反馈,数据库约束用于最终一致性。
六、重放:同一个 Action 可能被执行多次
1. 重放不是只有攻击者才会造成
一次提交的真实状态可能是:
客户端发送请求
服务器完成数据库写入
服务器准备返回响应
网络连接断开
客户端不知道写入是否成功
用户再次点击提交
这时第二次请求可能合法地再次执行。其他来源还包括:
- 用户双击提交按钮;
- 浏览器或用户代理重试;
- 移动网络切换;
- 用户刷新或返回后重新提交;
- 攻击者主动复制 POST 请求。
因此,任何“创建订单”“扣款”“发放奖励”“发送邮件”的 Action 都不能默认只执行一次。
2. 幂等的定义
如果同一个业务操作使用同一个幂等键 K 重复提交,系统应满足:
这里:
K是幂等键;x是请求内容;F是业务操作;- 重复调用应返回同一个业务结果,而不是重复产生副作用。
这不等于所有请求都天然幂等。例如:
await db.project.create({ data: ... })
每执行一次都会产生一条记录,它是非幂等操作。
而:
await db.project.update({
where: { id },
data: { name: 'new-name' },
})
在“设置为某个值”的语义下通常更接近幂等,但仍要考虑并发更新和审计事件是否会重复写入。
3. 幂等键必须由服务端参与约束
可以为业务操作建立收据表:
CREATE TABLE action_receipts (
user_id TEXT NOT NULL,
operation TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
result JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, operation, idempotency_key)
);
主键确保同一个用户、同一种操作、同一个幂等键只有一条收据。
表单中可以携带一个随机键:
<input
type="hidden"
name="idempotencyKey"
value={idempotencyKey}
/>
这个键应在服务端渲染表单时生成,例如:
// Server Component
import { randomUUID } from 'node:crypto'
import { CreateProjectForm } from './create-project-form'
export default function Page() {
return (
<CreateProjectForm idempotencyKey={randomUUID()} />
)
}
客户端组件接收并提交它:
export function CreateProjectForm({
idempotencyKey,
}: {
idempotencyKey: string
}) {
// ...
return (
<form action={formAction}>
<input type="hidden" name="idempotencyKey" value={idempotencyKey} />
{/* 其他字段 */}
</form>
)
}
服务端不能只做“先查询收据,再插入业务记录”,因为并发请求可能同时查不到收据。正确的实现必须依靠数据库唯一约束和事务:
开始事务
1. 尝试插入 action_receipts
2. 如果键已存在:
- request_hash 不同:拒绝,说明同一键被用于不同请求
- request_hash 相同:返回已有 result
3. 如果插入成功:
- 执行业务写入
- 把业务结果写入收据
提交事务
伪代码如下,实际 SQL/ORM 语法需按数据库实现:
await db.$transaction(async (tx) => {
const previous = await tx.actionReceipt.findUnique({
where: {
userId_operation_idempotencyKey: {
userId: user.id,
operation: 'create-project',
idempotencyKey,
},
},
})
if (previous) {
if (previous.requestHash !== requestHash) {
throw new Error('同一个幂等键对应了不同请求')
}
return JSON.parse(previous.result)
}
const project = await tx.project.create({
data: {
ownerId: user.id,
name,
},
})
const result = {
ok: true,
projectId: project.id,
}
await tx.actionReceipt.create({
data: {
userId: user.id,
operation: 'create-project',
idempotencyKey,
requestHash,
result: JSON.stringify(result),
},
})
return result
})
这里的事务有两个关键作用:
- 业务记录和幂等收据要么一起提交,要么一起回滚;
- 并发请求由唯一约束裁决,而不是依赖应用代码的时间顺序。
如果副作用不能放进同一个数据库事务,例如调用支付服务或发送邮件,则需要使用支付方提供的幂等键,或者采用 Outbox 等可靠消息模式。仅在内存中保存幂等键,在多实例部署、进程重启或边缘运行时中都不可靠。
4. 幂等键不是授权令牌
幂等键只用于识别“同一次业务意图”,不能代替登录凭证或权限检查。收据查询必须至少绑定:
用户身份 + 操作类型 + 幂等键
否则一个用户可能拿到另一个用户的键并读取或复用对方的结果。
七、渐进增强:没有 JavaScript 也能提交
渐进增强(progressive enhancement)意味着:先保证基础 HTML 交互成立,再在 JavaScript 可用时增加 pending 状态、局部更新和更丰富的错误显示。
Server Actions 与表单结合时,React/Next.js 可以在客户端组件尚未完成 Hydration 前处理表单提交。用户不必等待整段客户端 JavaScript 下载完成才能提交表单。
其状态变化可以表示为:
stateDiagram-v2
[*] --> Rendered
Rendered --> Submitted: 用户提交表单
Submitted --> ServerValidating: 服务器收到 POST
ServerValidating --> ErrorState: 认证/校验/业务失败
ServerValidating --> Success: 写入成功
Success --> Redirected: redirect()
ErrorState --> Rendered: 返回表单状态
Rendered --> Hydrated: 客户端完成 Hydration
Hydrated --> Pending: useFormStatus()
Pending --> ErrorState: Action 返回错误状态
Pending --> Redirected: Action 重定向
关键路径是:
- 服务端先输出包含
<form>和<input>的 HTML; - 用户提交后,浏览器仍然可以发送表单;
- 服务器执行 Server Action;
- 成功时可以重定向;
- 失败时返回结构化状态;
- Hydration 完成后,React 接管表单,使
useActionState和useFormStatus提供更细的交互反馈。
1. 哪些能力依赖 JavaScript
以下能力通常需要客户端 JavaScript 已经运行:
pending按钮状态;- 不刷新页面地更新错误信息;
- 客户端字段联动;
- 提交前的即时校验;
- 防止按钮重复点击的本地逻辑。
以下能力不应只依赖 JavaScript:
- 用户身份判断;
- 权限判断;
- 服务端输入校验;
- 数据库唯一性;
- 幂等控制;
- 最终业务写入。
disabled={pending} 可以减少正常用户双击,但不能解决恶意重放,也不能覆盖“服务器已写入、客户端未收到响应”的情况。真正的重复保护仍然要在服务端完成。
2. 表单失败和异常失败
用户输入错误是预期分支,适合返回状态:
return {
ok: false,
message: '请修正表单错误',
fieldErrors: {
name: ['名称过短'],
},
}
数据库宕机、代码 Bug、第三方服务超时则属于非预期异常。可以记录服务器日志并返回通用错误,不应把堆栈、SQL、内部路径返回给客户端:
try {
// 业务操作
} catch (error) {
console.error('create project failed', error)
return {
ok: false,
message: '服务暂时不可用,请稍后重试',
}
}
对于 redirect(),不要用一个过宽的 try/catch 把它吞掉。Next.js 的 redirect 通过特殊控制流结束当前渲染;如果捕获后继续返回普通状态,可能破坏预期重定向。通常应在事务成功后直接调用 redirect,或只捕获明确的业务异常。
八、程序化调用、bind 与参数安全
Server Action 不只可以由表单触发,也可以在客户端事件中调用:
'use client'
import { deleteProject } from './actions'
export function DeleteButton({ projectId }: { projectId: string }) {
async function handleDelete() {
await deleteProject(projectId)
}
return <button onClick={handleDelete}>删除</button>
}
这种写法仍然不改变安全模型:projectId 是客户端提供的输入,服务端必须再次确认当前用户拥有该项目。
如果需要绑定额外参数,可以使用 bind:
const deleteForProject = deleteProject.bind(null, projectId)
return <form action={deleteForProject}>删除</form>
绑定参数也要符合可序列化要求。bind 只是改变 Action 的调用参数,不会把 projectId 变成可信数据,也不会自动完成授权。
九、生产环境中的边界和诊断
1. 调试时先确认 Action 是否真的被调用
遇到表单“不生效”,按以下顺序检查:
- 文件是否包含正确的
'use server'指令; - 导出的函数是否被正确导入;
- 使用
useActionState时签名是否为(previousState, formData); <form>是否使用了action={formAction};- 字段是否有正确的
name; - 服务端日志中是否进入了 Action;
- 是否在认证阶段被重定向或拒绝;
- 是否在校验阶段返回了错误状态;
- 是否在数据库约束处失败;
- 浏览器 Network 面板中 POST 是否返回错误。
2. 观察序列化问题
典型失败表现包括:
- 客户端调用时出现“参数不可序列化”;
- 返回值在客户端变成意外结构;
- ORM 对象或类实例无法传输;
FormData.get()得到null或File,但代码直接当成字符串;- 把
Error作为状态返回后,客户端拿不到预期字段。
诊断方法是暂时将参数和返回值缩减为:
{
ok: false,
message: 'test',
}
如果简单对象可以工作,再逐个加入字段,定位具体的不可传输值。不要直接把数据库实体整体返回作为排查手段。
3. 请求体和文件上传限制
文件和大型 FormData 会受到框架、运行时、反向代理和部署平台的请求体限制。限制不是由 TypeScript 决定的,也不是把 File 放入表单就能绕过的。
大文件通常更适合:
- 服务端生成一次性上传凭证;
- 浏览器直接上传到对象存储;
- Server Action 只接收对象键和元数据;
- 服务端再次确认对象属于当前用户并完成入库。
这样可以避免让业务 Action 长时间持有大文件请求,也能更明确地处理上传失败和重试。
4. 事务不能自动覆盖外部副作用
下面的代码即使数据库事务成功,也不保证邮件只发送一次:
await db.$transaction(async (tx) => {
await tx.order.create({ data: orderData })
await email.send({ to, template: 'order-created' })
})
邮件服务不一定参与数据库事务。如果邮件发送成功而数据库提交失败,或者数据库提交成功而邮件调用超时,就会产生不一致。应将“订单创建”和“待发送邮件事件”写入同一数据库事务,再由可靠的后台消费者发送邮件,并使用邮件供应商支持的幂等键。
十、规范保证、框架实现与工程取舍
需要区分三个层次:
React/RSC 层
React Server Components 定义了服务端组件和服务端函数之间的协议模型,并规定哪些值可以穿过该协议。具体可序列化类型应以当前 React 版本文档为准。
Next.js 层
Next.js 负责把 'use server' Action 接入应用路由、表单和渲染生命周期,并提供 redirect、缓存更新等框架能力。请求来源检查、体积限制、运行时限制和部署行为可能随 Next.js 版本及运行环境变化。
应用层
以下内容必须由应用负责:
- 当前用户是谁;
- 当前用户能否操作目标资源;
- 输入是否满足业务约束;
- 重复请求是否产生重复副作用;
- 数据库约束和事务边界;
- 外部服务失败后的恢复策略。
最小可靠模型可以写成:
Action = 认证
→ 授权
→ 解析与校验
→ 幂等检查
→ 事务性业务操作
→ DTO / redirect
顺序可以因业务调整,但不能删除其中的安全边界。尤其要避免两种极端:
- 只在客户端校验,服务器直接信任表单;
- 只依赖框架自动处理,把 Action 当成不会重放的本地函数。
Server Actions 的价值在于减少手写 API 胶水代码,并让表单、服务端执行和渐进增强自然衔接;它并没有取消网络调用的基本事实。只要把 Action 当作可被重复调用的服务端入口,明确设计序列化数据、认证授权、运行时校验、幂等策略和错误状态,它就能在 React 19 与现代 Next.js 应用中形成清晰且可验证的边界。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:Next.js 数据与缓存:请求记忆、Data Cache、Revalidation 和标签
- 下一篇:Next.js Node 与 Edge Runtime:API、限制、依赖和部署选择
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论