Old-School Front-End · Frameworks / 2026 ・ No.06
古法前端入门 框架篇
第6讲 表单与输入检查 ── 受控与非受控、Actions、按 2 次
发布
第 5 讲里行已经能动了,但还发不出去。第 6 讲用 React 19 的 Actions(useActionState・useFormStatus)加上发送,并用和前端篇第 9・10 讲相同的规则,在画面和服务器两边检查。
然后故意制造 3 个问题来测量:发送后事由没了、按 2 次产生 2 件申请、错误跑到了旁边的行。
值在哪里
新井要发送的话,把事由、备注全都做成 useState,按钮里 fetch 一下就完了吧。
古井全做成状态之前,先看看现在值在哪里。第 5 讲放进状态的只有明细。
全 15 讲路线图(现在:第 6 讲)
申请表单的栏分成 2 种。明细的栏用 value 和 onChange 连到状态,是受控的栏。事由・支付方式・备注・确认勾选・收据文件,把值交给浏览器,是非受控的栏。正如第 5 讲第五节测到的,输入事由时,哪个组件都不重绘。
值在哪里
发送时哪种都可以。浏览器的 FormData 会以 name 为线索把两边都收集起来。放进状态的,只要是其他组件要用的值(合计・件数・出错的行)就够了。文件栏本来就无法由程序放入值,只能做成非受控。
用 Actions 发送
/** 把 'amount-2'(第 2 行的金额)换成发送时第 2 行的 id。这样删除行时,错误不会移到旁边 */
function tie(errors, ids) {
return errors.map((e) => {
const m = /^(date|category|item|amount)-(\d+)$/.exec(e.field);
return { ...e, key: m ? `${m[1]}:${ids[Number(m[2]) - 1] ?? ids[0]}` : e.field };
});
}
/** 发送时的工作(Action)。接收上次的结果和表单内容(FormData),返回下一个结果 */
async function submitClaim(prev, formData) {
const ids = formData.getAll('rowId').map(Number);
const draft = formData.get('action') === 'draft'; // 草稿不检查,直接保存
const at = prev.at + 1;
if (!draft && CHECK) {
const errors = validateClaim(claimOf(formData), localDate(), RATE);
if (errors.length > 0) return { errors: tie(errors, ids), at, from: 'client' };
}
const r = await sendClaim(formData);
return { ...r, errors: tie(r.errors ?? [], ids), at, from: 'server' };
}
/** 申请表单的画面。明细的状态(rows)只在这里保存 1 份 */
export default function App() {
countRender('App');
const [rows, setRows] = useState(SAMPLE_ROWS);
const [result, formAction] = useActionState(submitClaim, { errors: [], at: 0 });
// …(change・add・remove 和第 5 讲一样)
// 写成 <form action={…}>,React 会在发送后清空栏(出错时也是)。
// 这里用 onSubmit 接收,不让浏览器和 React 清空,自己调用 formAction
const onSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget, event.nativeEvent.submitter); // 也放入按下的按钮(提交/草稿)
startTransition(() => formAction(formData));
};
return ( … <form onSubmit={onSubmit} noValidate> … </form> … );
}
React 19 把发送时的工作归纳成叫 Action 的函数。useActionState(submitClaim, 初始值) 返回当前结果 result 和调用 Action 的函数 formAction。submitClaim 只是接收“上次的结果”和“表单内容”,返回下一个结果。结果一变,画面就重绘。
发送的流程
一般只要写 <form action={formAction}> 就能动。这个应用故意用 onSubmit 接收,自己在 startTransition 里调用 formAction。原因在第六节测。
先在画面上检查
/** 从 FormData 做出申请内容(和练习用服务器同样的读法) */
export function claimOf(formData) {
const all = (k) => formData.getAll(k).map(String);
const [d, c, i, a, cur] = ['date', 'category', 'item', 'amount', 'currency'].map(all);
return {
title: String(formData.get('title') ?? ''),
rows: d.map((_, n) => ({ date: d[n], category: c[n], item: i[n], amount: a[n], currency: cur[n] })),
receipts: formData.getAll('receipt').filter((f) => f.size > 0).length,
agreed: formData.get('agree') === 'yes',
};
}
/** 检查申请内容,返回 [{ field: 'amount-2', message: '…' }, …](空数组就是合格) */
export function validateClaim(claim, today = localDate(), rate = null) {
const errors = [];
// …(和前端篇第 9・10 讲同样的规则)
}
规则只是把前端篇第 9 讲・第 10 讲的 validate.js 改成能 import 的形式。练习用服务器也用同一个 validate.js 再查一遍,所以连文字都一样。FormData 的读法也和服务器一致。
# 同样的错误(没有事由・第 2 行金额带“日元”・没有勾选)时点击“提交申请”
画面的检查服务器的检查(?check=off)
发往服务器0 1
列表出现前21.9 ms 627 ms
列表项目3 2(事由已经填了)
焦点#error-summary #error-summary
# 时间是生产用构建测 5 次的中位数。服务器那边的 600 ms 是练习用服务器故意让它等的
# 两边的文字相同(同样的规则・同样的句子)。下面是服务器返回的列表
第 2 行的金额请只填数字,例如“14720”。
请阅读费用报销规定并勾选。
画面的检查一次也没发给服务器,约 0.02 秒就出了列表。服务器的检查约 0.63 秒(大部分是练习用服务器故意等待的时间)。两边文字相同。画面的检查是为了快和体贴,服务器的检查是为了守。就像第五节看到的,画面的检查谁都能跳过。
# npm run build 的 JavaScript(第 5 讲 → 第 6 讲) index-*.js 229.71 kB → 239.35 kB gzip 72.57 kB → 75.95 kB # 增加的 3.4 kB(gzip)是检查规则和 3 种语言的文字。React 的 Actions 本来就在 React 本体里
把错误显示出来
/** 输入错误的列表。出现时把焦点移到这里(让读屏软件也先读列表) */
export default function ErrorSummary({ errors }) {
const go = (event, key) => { // 点击列表的项目,就移到那一栏
event.preventDefault();
document.getElementById(key.replace(':', '-r'))?.focus();
};
return (
<div className="alert alert-danger" id="error-summary" tabIndex={-1} ref={(el) => el?.focus()}>
<h2 className="h6 alert-heading">{T.check}</h2>
<ul className="mb-0">
{errors.map((e) => (
<li key={e.key + e.message} id={`err-${e.key.replace(':', '-r')}`}>
<a href={`#${e.key.replace(':', '-r')}`} className="alert-link" onClick={(event) => go(event, e.key)}>{e.message}</a>
</li>
))}
</ul>
</div>
);
}
显示方式和前端篇第 9 讲一样。在上方显示列表并移动焦点,栏上加 aria-invalid,以及指向列表项目的 aria-describedby。移动焦点没有用 useEffect。给列表的 ref 传一个函数,列表出现在画面上时就会被调用。每次发送都改变 key 重新做列表,所以第 2 次“不行”时焦点也会回来。
麻烦的是,服务器的回复是 amount-2(第 2 行的金额)这样用第几个来表示。和第 5 讲 key 同样的陷阱,这里也有。
# 服务器返回“amount-2(第 2 行的金额)不行”。之后删除了第 1 行 错误的记法删除前红的行删除后红的行 第几个(2)酒店 1 晚与客户午餐(错位了) 行的 id(发送时的第 2 行)酒店 1 晚酒店 1 晚 # 申请表单里也一样:用 id 记住,删除第 1 行后红框仍在酒店的金额上
按第几个记住的话,删掉第 1 行的瞬间,红框就移到了午餐上。于是在每行放 <input type="hidden" name="rowId"> 一起发送,在 Action 里换成“发送时第 2 行”的 id(第二节的 tie)。不用改服务器,在画面这边就能修好。
转给练习用服务器
发送目标是前端篇的练习用服务器(端口 3000)。React 的开发用服务器是 5173,直接发就是不同的地方(源)。所以请 Vite 帮忙转发。
export default defineConfig({
plugins: [react()],
server: {
// 第 6 讲:把以 /api 开头的调用转给练习用服务器(从浏览器看是同一个地方)
proxy: { '/api': process.env.KEIHI_API ?? 'http://localhost:3000' },
},
})
import { T } from './text.js';
export async function sendClaim(formData) {
const res = await fetch('/api/expenses', { method: 'POST', body: formData });
const body = await res.json().catch(() => ({}));
if (res.status === 201) return { id: body.id, draft: Boolean(body.draft) };
if (res.status === 400 && Array.isArray(body.errors)) return { errors: body.errors };
if (res.status === 401) return { message: T.signedOut }; // 登录的问题在第 7 讲
return { message: T.failed(res.status) };
}
从浏览器看,发送目标是同一个 5173 的 /api/expenses。CORS 和 CSRF 都还没出场(在连接真正的 Spring Boot 的第 7 讲)。登录先在练习用服务器的画面(localhost:3000)完成。cookie 不区分端口,所以在 3000 拿到的 cookie 也会带到 5173。
试着不经过画面,用 curl 发送。不登录是 401。用登录后的 cookie 发送,没有事由・“14720円”・没有勾选这 3 处,服务器用和画面相同的文字返回。画面的检查能跳过,服务器的检查跳不过。所以两边都要放。
发送后,事由没了
新井改成 <form action> 以后,“不行”之后事由变空了。还得再输一遍吗……
古井收据也得重新选。用 3 种发送方式比一比。
# 填好事由・金额・收据・勾选后发送,Action 在 600 毫秒后返回“不行”之后 发送方式事由金额收据勾选 ① <form action>空保留0取消了 ② ① + defaultValue保留保留0取消了 ③ 从 onSubmit 发送保留保留1保留 # 金额是连到状态的栏(value),所以哪种都保留。收据的数字是选中的文件数 子组件的 useFormStatus绘制 <form> 的组件的 useFormStatusisPending ① <form action>true → falsefalsetrue → false ③ 从 onSubmit 发送true → falsefalsetrue → false # 两种 pending 都持续了 603 ms。按下的按钮的值,只用 new FormData(form) 时是 null,传入 submitter 就是 "submit"
① 写成 <form action={formAction}>,React 会在 Action 结束后把非受控的栏全部清空。这是为成功时准备的体贴,但 Action 返回“不行”时,在 React 看来也是“结束了”,所以也清空。事由、选好的文件、勾选都没了。
② 常见的修法是把 Action 收到的值放进结果返回,用 defaultValue 恢复。事由回来了,但文件回不来(因为是无法由程序放入值的栏)。要恢复勾选,也得逐个栏补写。
③ 在 onSubmit 里 preventDefault(),在 startTransition 里调用 formAction,就谁也不会清空栏。即便如此,useFormStatus 的 pending 也和 ① 一样变化。因为 React 把在 onSubmit 里开始的发送,也当作那个 <form> 的发送来处理。这个应用用了 ③。成功后想清空时,可以用 requestFormReset 主动请求。
③ 有一点要注意。只用 new FormData(form),按下的按钮(提交/草稿)的值不会放进去,是 null。把 event.nativeEvent.submitter 作为第 2 个参数传入就有了。
按 2 次,申请也是 2 件
# lab.html?lab=double。把一定能通过的申请真的发送到练习用服务器 写法 双击 隔 0.15 秒按 2 次 每次在 onSubmit 里 fetch2件 0013 00142件 0015 0016 useActionState(按钮不变)2件 0017 00182件 0019 0020 useActionState + pending 时不能按1件 00211件 0022 # 数字是申请编号(EXP-2026-…)。useActionState 等第 1 个的回复后才发送第 2 个(0 ms 和 603 ms) # 申请表单(SendButtons)双击:发送 1 件。发送期间按钮显示“正在发送…”,不能按
什么都不做的话,双击就产生了2 件申请。想在审批的人把同一笔新干线费用批两次之前拦住。useActionState 会等第 1 个的回复再发第 2 个(遵守顺序),但不会停止发送本身。要拦住,就是在发送期间让按钮不能按。
/** 保存草稿和提交的按钮。发送期间不能按(按 2 次,申请也只有 1 件) */
export default function SendButtons() {
countRender('SendButtons');
const { pending, data } = useFormStatus(); // 最近的父级 <form> 现在是否在发送。data 是发送的内容
const which = pending ? data?.get('action') : null;
return (
<div className="d-flex flex-wrap justify-content-end gap-2">
<button type="submit" className="btn btn-outline-secondary" name="action" value="draft" disabled={pending}>{which === 'draft' ? T.sending : T.draft}</button>
<button type="submit" className="btn btn-brand" name="action" value="submit" disabled={pending}>{which === 'submit' ? T.sending : T.submit}</button>
</div>
);
}
useFormStatus 告诉我们最近的父级 <form> 是否在发送。正如第六节表格中间那一列,在绘制 <form> 的组件本身(App)里调用,一直是 false。所以只把按钮分成一个小组件。data 里是正在发送的内容,所以可以只让按下的那个按钮显示“正在发送…”。
和后端篇第 6 讲对照
# 同样的“不行”,在画面(validate.js・练习用服务器)和后端篇第 6 讲(Bean Validation)里怎么写 规则画面・练习用服务器后端第 6 讲 日期为空date === '' @NotNull 日期是未来date > today @PastOrPresent 内容为空・只有空白item.trim() === '' @NotBlank(strip) 内容太长(没有)@Size(max = 100) 金额为空toAmount → null @NotNull 金额不是数字toAmount → NaN JSON 读不了,400 金额为 0amount === 0 @Positive 金额的位数日元整数・美元 2 位@Digits(9, 0) 3 万日元以上要收据needsReceipt (没有) 超过 5 万日元要理由(没有)@AssertTrue 确认规定agreed (没有) # 回复的形式也相近:都是 400 和 [{ field, message }] 的列表(后端是 ProblemDetail 的 errors)
后端篇第 6 讲的 Spring Boot 以 JSON 接收 1 件费用,用 Bean Validation 的标注检查。规则大多形式相同,但也有偏差。
- 画面没有的规则 ── 内容的长度(100 个字)和超过 5 万日元要理由。第 7 讲连接真正的服务器之前,在画面这边也补上
- 读法的不同 ── 画面会把全角的“12800”改正后通过,后端在 JSON 阶段就是 400。发送画面改正后的值比较安全
- “今天” ── 画面的今天是电脑的时钟,服务器的今天是服务器的时钟(后端篇第 6 讲第七节)。在日期交界处,画面通过了,服务器也可能拒绝
画面再怎么对齐,最后决定的是服务器。把画面看成“在被服务器拒绝之前,提早告诉你”的角色,有偏差也不要紧。
在自己的电脑上
用 node server15.mjs zh 3001 启动练习用服务器,在 localhost:3001 登录,然后在 React 项目里用 KEIHI_API=http://localhost:3001 npm run dev 启动,打开 ?lang=zh。把事由清空后点击“提交申请”,开发者工具的 Network 标签里什么都没出现,列表就出来了。在 URL 加上 ?check=off,这次 /api/expenses 会返回 400。
第六节的实验是 /lab.html?lab=reset&how=action(还有 values・submit),第七节是 ?lab=double&how=plain。第七节会真的产生申请,重新启动练习用服务器后编号回到 0013。
结 — 一页总结与下一讲
- 放进状态的只是其他组件要用的值。发送时 FormData 会收集受控・非受控两边。
- Action 是从“上次的结果”和“内容”返回下一个结果的函数。useActionState 返回结果和调用入口。
- 画面的检查发送 0 次・约 0.02 秒,服务器用同样的文字约 0.63 秒。画面能跳过,服务器跳不过。
- <form action> 在“不行”时也会清空栏。从 onSubmit 用 startTransition 调用就会保留,也别忘了 submitter。
- 按 2 次就是 2 件。在子组件里读 useFormStatus,发送期间让按钮不能按。错误用行的 id 记住。
新井发出去了!不过汇率还是写死的 151.23。
古井下一讲是副作用与数据获取。用 useEffect 取汇率,连接真正的 Spring Boot。CORS、CSRF 和 401 正一起等着呢。
参考
- React — useActionState
- React — useFormStatus
- React — <form>
- React — <input>(controlled / uncontrolled)
- React — startTransition
- React — requestFormReset
- MDN — FormData() constructor(submitter)
- Vite — server.proxy
记录是笔者于 2026 年 11 月 2 日在云端 Linux 上取得的。在第 5 讲的项目(Vite 8.3.5、React 19.3.1)上加入发送和检查,用 Vite 的 server.proxy 转给前端篇的练习用服务器(Node.js,端口 3000〜3002)。开发用(npm run dev)和生产用(npm run build 后用 npm run preview 提供)都用 Playwright 驱动的 Chromium 153.0.8010.12 打开。时间是生产用测 5 次的中位数。第六・七节的实验在 lab.html 的另一个画面进行。
古法前端入门 框架篇 第6讲 — 表单与输入检查 / 2026.11