Skip to content

表单、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 ​

jsx
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),并不会凭空猜出点击了哪颗按钮。

html
<!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 提交尤其自然。

练习:契约任务 ​

实现“保存/删除”双意图资料表:

  1. email 为受控输入;保存要求包含 @,失败保留输入并显示 invalid email。这里只练习提交分支,不把包含 @ 当完整邮箱验证。
  2. 两个提交按钮都叫 intent,值分别为 save、delete;一个 form Action 从 FormData 分支。
  3. 删除要求勾选 confirm,否则显示 confirm deletion;合法删除显示 deleted。
  4. pending 时两个按钮禁用;明确说明这仍是客户端模拟,不能代表服务端鉴权。

完成后对照答案 06-03。

用心记录每一步成长