表单登录与 loading:业界是怎么做的
两个结论先说:
- 业界登录页几乎都是真
<form>(微软 Identity 默认 UI / MSA、Google、GitHub、Auth0、Okta、Keycloak)。- "用 form 就没有 loading" 是误解——在
submit事件里切一下 UI 状态,转圈在"服务端处理 + 网络往返" 期间是真实可见的。本项目登录 / 同意 / 登出 / 外部账号确认四页就是这么做的。
一、证据:大厂登录页都是真表单
| 实现 | 形态 | 说明 |
|---|---|---|
| Microsoft:ASP.NET Core Identity 默认 UI | <form id="account" method="post"> + <button type="submit"> | 视图在 NuGet 包 Microsoft.AspNetCore.Identity.UI 里(RCL):Areas/Identity/Pages/V5/Account/Login.cshtml。它没有任何自研 spinner:体验由浏览器导航进度承担,错误靠 POST-Redirect-GET 回显 ModelState |
| Microsoft:MSA / Entra(login.microsoftonline.com) | 表单 POST + 提交瞬间切 loading(按钮转圈 / 页面进度) | DevTools → Network 里这是一个 document 请求(不是 XHR),说明是浏览器顶层导航 |
| Google(accounts.google.com) | 表单 POST;按钮点击后内容被换成转圈 spinner | 分步页面(账号 → 密码 → 二次验证),最终跨域交接用自动提交的 form(form_post) |
| GitHub | <form action="/session" method="post"> | 经典服务端渲染表单 |
| Auth0 / Okta / Keycloak | JS 先 XHR,成功后渲染隐藏表单并 form.submit() | "JS 提体验 + 表单兜底跨域交接"的混合式(form_post 响应模式) |
为什么都选 form:
- 密码管理器 / Passkey / 自动填充只认表单提交:
autocomplete="username"/"current-password"(Identity 默认 UI 就是这两个值)。用fetch提交时浏览器与扩展只能靠启发式猜,保存与填充的成功率明显下降。 - Enter 键提交、无 JS 也能用(渐进增强):form 是浏览器内建行为,JS 挂了照样能登录。
- 最后的跨域交接必须靠浏览器导航:OP 要 302 到
redirect_uri?code=...(另一个源)。fetch拿到的是不透明重定向(读不到Location、不改地址栏),本项目同意页 / 登出页因此必须用 form。 - POST 语义:凭据不进 URL / 历史 / Referer;刷新时浏览器会提示"重新提交表单",而不是把密码留在地址栏。
二、"用 form 就没有 loading" 是误解
浏览器在新文档开始接管之前不会卸载旧页面。所以:在 submit 事件里把 UI 切到"提交中", 这个状态会一直显示到服务端响应完成 + 新文档首帧——期间用户看到的就是转圈。 "点了登录 → 按钮转圈 → 页面跳走",这就是 MSA / Google 上看到的效果,和用不用 form 无关。
本项目登录页(src/pages/Login.tsx)的实现:
const [submitting, setSubmitting] = useState(false)
// 不 preventDefault:让浏览器按原生流程提交并顶层导航;这里只切 loading + 防重复提交
function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
if (submitting) {
event.preventDefault()
return
}
setSubmitting(true)
}
return (
<form method="post" action="/Account/Login" onSubmit={handleSubmit}>
…隐藏字段 ReturnUrl + __RequestVerificationToken,账号密码输入框 + "记住我"复选框…
<Button type="primary" htmlType="submit" loading={submitting}>登录</Button>
<Spin spinning={submitting} fullscreen description="正在登录…" />
</form>
)为什么不是"先 fetch 再手动跳转"
那样会变成 SPA 登录:要么把 code 换 token 的逻辑搬到浏览器里做(等于换了一套流程), 要么手动 location.assign(多了两次往返、还容易和 PKCE/state 对不上)。 业界主流登录页(Identity 默认 UI、微软 / Google 的登录页)都是"表单 POST + 服务端 302",React 前端照做最省事。
三、三条必须守住的规则(都是踩过才知道的)
规则 1:不要给"带 name/value 的提交按钮"加 disabled
浏览器是在 submit 事件之后才收集表单数据的,而 disabled 控件不进入表单数据。 同意页靠 name="submit.Accept" value="Yes" 分流(服务端 [FormValueRequired("submit.Accept")]), 提交时若把按钮 disable 掉,这个字段就丢了 → 服务端 400。
antd 的 loading 只加 class + 拦后续点击(handleClick 里 e.preventDefault()), 不会给 <button> 加 disabled 属性——所以这里用 loading,不要用 disabled 表达"提交中"。
规则 2:不要随手 preventDefault
event.preventDefault() 会取消原生提交 → 浏览器不导航 → 服务端的 302 没人跟随。 只在"已经在提交中"(防连点)时才拦。
规则 3:loading 只加在"被点的那个按钮"上
两个提交按钮的页面要用 SubmitEvent 的 nativeEvent.submitter 区分(同意页示例):
const submitter = event.nativeEvent.submitter
const name = submitter instanceof HTMLButtonElement ? submitter.name : ''
setSubmitting(name === 'submit.Deny' ? 'deny' : name === 'submit.Accept' ? 'accept' : null)用键盘 Enter 提交时没有 submitter(同意页此时不会被当成"同意"提交:服务端只是重新渲染同意页, 前提是重定向时把参数从 Form 里取出来,见③ 同意流程)。
顺带一个好处:loading 天然挡住第二次点击(loading 中的 antd 按钮会 preventDefault 掉 click), "连点两次提交两次"不会发生。
四、什么时候用 form,什么时候用 fetch
| 场景 | 建议 |
|---|---|
| 结果必须变成"新页面 / 新源"(OIDC 的 authorize、同意、登出、登录跳转) | 原生 form(浏览器顶层导航) |
| 要在页面原地显示错误、多步表单、拿返回值继续做动作 | fetch + JSON |
需要自定义请求头(例如 X-XSRF-TOKEN) | fetch(HTML 表单不能设请求头,form 只能走隐藏字段) |
本项目的选择:登录 / 同意 / 登出都是 form(最终都要跳到别的页面或别的源), 防伪令牌走隐藏字段 __RequestVerificationToken(框架默认 FormFieldName)。 登录页的"记住我"是原生复选框(name="rememberMe",未勾选时浏览器不提交该字段)——勾选后服务端用 PasswordSignInAsync(isPersistent: true, ...) 写持久 cookie(带 expires),不勾则仍是会话 cookie。
五、本项目实测记录
| 页面 | submitter | 提交中的 UI | 提交按钮 disabled | 按钮 value |
|---|---|---|---|---|
登录 /Account/Login | —(字段:username / password / ReturnUrl + 可选的 rememberMe) | 按钮 loading + 全屏"正在登录…" | false | — |
同意 /Account/Consent | submit.Accept | 只有被点的按钮 loading + 全屏"正在提交…" | false | Yes(保留) |
登出 /Account/Logout | Confirm | 按钮 loading + 全屏"正在退出…" | false | Yes(保留) |
端到端(2026-09-27 实测):登录(表单 POST)→ 未同意 → 同意页 → 点"同意"(loading 中提交) → code → 客户端 /signin-callback → 换 token → GET /weatherforecast 200(5 条)。
登录失败也一样是"表单语义":服务端 302 回登录页并带上 error 与 username, 页面显示错误提示并把用户名预填回去(等价 Identity 默认 UI 用 ModelState 回显 Input.Email, Google/微软同样不会让你重打一遍账号)。
六、参考资料
- Identity 默认 UI 登录页源码(本项目"抄微软"的对象):
src/Identity/UI/src/Areas/Identity/Pages/V5/Account/Login.cshtml—— https://github.com/dotnet/aspnetcore/blob/main/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/Login.cshtml - HTML 规范:构造表单数据集合(disabled 控件不参与;只有"被点的那个"提交按钮会带上 name/value) —— https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-form-data-set
- MDN:
SubmitEvent.submitter—— https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent/submitter - MDN:
<form>—— https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form - antd:
Button的loadinghttps://ant.design/components/button、Spin的fullscreenhttps://ant.design/components/spin