表单、Server Action 与边界
问题:同一个 action 属性可能跨越哪条边界
HTML 的 <form action="/posts" method="post"> 由浏览器导航并向 URL 提交。React 19 还允许把函数传给 action 或按钮的 formAction;React 调用函数并传入 FormData。这个函数可以是当前浏览器 bundle 里的客户端 Action,也可以是框架提供、带专用传输协议的 Server Function。
普通 Vite 项目只有客户端运行时。 在文件里写 "use server" 不会自动创建服务器、路由或安全边界;Server Function 需要支持 React Server Components/Server Functions 的框架和构建配置。
完整实验:草稿与发布两个客户端 Action
import { useState } from 'react'
import { useFormStatus } from 'react-dom'
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
function SubmitButtons({ saveDraft, publish }) {
const { pending } = useFormStatus()
return (
<>
<button type="submit" formAction={saveDraft} disabled={pending}>
{pending ? '处理中…' : '保存草稿'}
</button>
<button type="submit" formAction={publish} disabled={pending}>
发布
</button>
</>
)
}
export default function App() {
const [title, setTitle] = useState('')
const [result, setResult] = useState('尚未提交')
async function saveDraft(formData) {
await wait(400)
const submitted = String(formData.get('title') ?? '').trim()
setResult(submitted ? `草稿已保存:${submitted}` : '草稿标题为空')
}
async function publish(formData) {
await wait(600)
const submitted = String(formData.get('title') ?? '').trim()
if (submitted.length < 3) {
setResult('发布标题至少 3 个字符')
return
}
setResult(`已发布:${submitted}`)
setTitle('')
}
return (
<main>
<h1>文章编辑</h1>
<form>
<label>
标题
<input
name="title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<SubmitButtons saveDraft={saveDraft} publish={publish} />
</form>
<p aria-live="polite">{result}</p>
</main>
)
}两个按钮的 formAction 覆盖 form 的默认提交函数,让“草稿”和“发布”成为明确意图。FormData 只包含有 name 且成功提交的控件。函数 action 按 POST 语义处理,不应把 method="get" 当作客户端函数的传输配置。
这里使用受控标题:业务校验失败时输入不会因一次已完成的函数 Action 而丢失;发布成功后由业务代码明确清空。对于不受控控件,函数 Action 成功完成后 React 会重置它们。若返回一个“校验错误对象”而非抛异常,函数依然正常完成,所以要有意识地设计保留输入的策略。
从一次点击追踪到执行位置
在双按钮例子里输入 React,先点保存草稿,再点发布。提交链是:浏览器确定本次 submitter → 选取按钮的 formAction → 收集表单控件的提交值 → React 调用对应客户端函数 → 函数等待并写入 state。两个函数虽然叫 saveDraft/publish,仍可在浏览器调试器中暂停,也没有真正写数据库。
如果将调用换成 await fetch('/api/posts', { method: 'POST', body: formData }),增加的边界是浏览器到 HTTP 服务。服务端必须真正存在该地址;客户端 Action 仍在浏览器,只是内部发了网络请求。响应成功后再更新显示,网络失败时保持可重试状态。不能仅把函数重命名为 saveOnServer 就声称执行位置改变。
在支持 Server Function 的框架中,客户端持有的是可调用引用:调用被框架编码成请求,服务端解码输入、鉴权、执行函数,再传回可序列化结果。函数闭包不会原样搬进浏览器,但传出的数据仍会暴露给客户端。引用不是权限凭证,恶意调用方仍可能伪造参数;服务器必须逐次判断谁能改哪一条记录。
提交按钮为何不只是一个外观
下面独立 HTML 可直接打开,先观察浏览器提供的 FormData,再回到 React Action。只有本次点击的 submitter 对应 intent 会加入数据;若手工只写 new FormData(form),并不会凭空猜出点击了哪颗按钮。
<!doctype html>
<meta charset="utf-8">
<form id="editor">
<input name="title" value="React">
<button name="intent" value="save">保存</button>
<button name="intent" value="delete">删除</button>
</form>
<pre id="result"></pre>
<script>
const form = document.querySelector('#editor');
form.addEventListener('submit', event => {
event.preventDefault();
const data = new FormData(form, event.submitter);
document.querySelector('#result').textContent = JSON.stringify([...data]);
});
</script>保存得到 title=React、intent=save;删除得到 intent=delete。这是后面双意图练习依赖的浏览器规则。对回车、脚本提交等入口仍应设计默认意图或拒绝未知意图,不凭某个按钮文本猜权限。checkbox 未选中时没有对应项;练习以默认勾选值 on 判断确认。
再比较三种提交
- URL action:浏览器原生提交,可在没有 React JavaScript 时工作;目标服务器负责响应,页面通常导航。
- 客户端函数 Action:函数代码在浏览器执行,可调用 fetch;不能隐藏密钥,也不能成为权限裁决者。
- Server Function:由支持的框架把客户端引用映射到服务端执行;仍必须在服务端鉴权、校验、处理 CSRF/来源策略和幂等。
渐进增强取决于具体框架如何渲染、编码和路由 Server Function;纯 Vite 示例不能声称具备这项能力。React API 组织提交状态,不定义你的 HTTP 接口和数据库事务。
关键边界
- 客户端校验可被绕过;服务端必须重新读取、规范化和验证。
- 禁用按钮减少误触,却无法阻止重放、双标签页或网络重试。
- 不要把异常详情直接回显给用户;返回稳定业务错误,敏感日志留在服务端。
- 多个意图也可用提交按钮的
name/value,让服务端从 FormData 判断;这对原生 URL 提交尤其自然。
练习:契约任务
实现“保存/删除”双意图资料表:
email为受控输入;保存要求包含@,失败保留输入并显示invalid email。这里只练习提交分支,不把包含 @ 当完整邮箱验证。- 两个提交按钮都叫
intent,值分别为save、delete;一个 form Action 从 FormData 分支。 - 删除要求勾选
confirm,否则显示confirm deletion;合法删除显示deleted。 - pending 时两个按钮禁用;明确说明这仍是客户端模拟,不能代表服务端鉴权。
完成后对照答案 06-03。