在第一个 await 之前调用 event.preventDefault(),运行异步验证,然后使用 form.requestSubmit() 发起新的提交尝试。一次性守卫可让第二次尝试继续,而无需重复异步检查。
本教程面向在 Mac 上编辑小型浏览器界面的用户。你将构建一个本地 HTML 示例,其中表单仅在模拟标签检查通过后才关闭对话框。同一事件处理模式也适用于会导航或发送请求的普通表单。
为什么异步提交处理程序不会延迟提交?
浏览器不会等待事件侦听器返回的 Promise。DOM 事件分派算法会调用侦听器并继续执行;HTML 提交算法会在继续之前检查 submit 事件是否已被取消。在侦听器上添加 async 并不会在该生命周期中引入等待步骤。DOM 标准:事件分派,HTML 标准:表单提交算法
异步函数会同步运行,直到遇到第一个 await,然后再异步继续。因此,即使等待的 Promise 已经兑现,也必须在第一个 await 之前取消事件。之后调用 preventDefault() 无法撤销浏览器已经作出的提交决定。MDN:async function
取消会结束原始尝试;它不会暂停该尝试以便稍后恢复。验证成功后,代码必须显式请求再次提交。此外,preventDefault() 不会停止事件传播,因此其他侦听器仍可观察到该事件。MDN:Event.preventDefault()
在表单的 submit 事件上进行侦听。这样可涵盖通过按钮提交、受支持的隐式提交(例如按 Enter),以及调用 requestSubmit() 的情况。仅处理按钮点击的处理程序无法涵盖所有入口。通常会先进行原生约束验证:必填字段无效时,提交会被阻止,且不会触发 submit。MDN:HTMLFormElement submit event
哪种方法能保留表单的提交行为?
如果检查成功后应返回正常的表单生命周期,请使用 requestSubmit()。直接调用 submit() 会跳过约束验证和 submit 事件。这种捷径会改变异步处理程序本应保留的行为约定。MDN:HTMLFormElement.submit()
| 方法 | 发生的情况 | 在此处是否合适 |
|---|---|---|
| 从侦听器返回 Promise | 事件分派会继续,不会等待该 Promise | 不会延迟默认操作 |
取消后调用 submit() |
提交会绕过原生验证和提交侦听器 | 会改变生命周期 |
取消后调用 requestSubmit(submitter) |
原生验证和提交事件会再次运行 | 保留预期的提交路径 |
等待前先捕获 event.submitter。将该按钮传给 requestSubmit() 可以保留其作用,包括提交覆盖设置,以及适用时它对名称/值的贡献。如果没有提交按钮,则不带参数调用 requestSubmit()。传入的按钮必须仍属于该表单;无效按钮或属于其他表单的按钮会导致异常。MDN:HTMLFormElement.requestSubmit()
新的提交会再次触发侦听器。因此,下面的示例使用了两个独立的状态:pending 用于防止异步检查重叠,而 allowNextSubmit 用于授权处理程序仅同步通过一次。永久性的“已验证”标志范围过宽,因为之后的编辑可能会复用先前的验证结果。
在 Mac 上构建完整的本地示例
在纯文本代码编辑器中,将以下完整文档保存为 async-form.html,然后在 Mac 上用当前版本的浏览器打开。无需库、导入、服务器或安装。标准文档列出了 Safari 16 及更高版本对 requestSubmit() 的支持。HTML 标准:表单 API
该示例使用 method="dialog",以便在本地轻松观察默认操作。提交成功后,会关闭外层对话框,并将其 returnValue 设置为提交按钮的值;它不会传输表单控件。打开按钮调用 showModal() 来显示对话框。MDN:dialog 元素,MDN:HTMLDialogElement.showModal()
验证器是刻意设计的模拟程序:短暂计时后,它会接受去除首尾空白后至少包含三个字符的标签,拒绝完全匹配的小写标签 taken,并对完全匹配的小写标签 error 抛出异常。该延迟用于演示异步完成,并非性能测量。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Asynchronous form validation</title>
</head>
<body>
<h1>Label preview</h1>
<button id="open" type="button">Open form</button>
<p id="outcome" role="status"></p>
<dialog id="editor" aria-labelledby="dialog-title">
<form id="label-form" method="dialog">
<h2 id="dialog-title">Choose a label</h2>
<label for="label">Label</label>
<input id="label" name="label" required autofocus>
<button type="submit" value="accepted">Continue</button>
<button id="cancel" type="button">Cancel</button>
<p id="status" role="status"></p>
</form>
</dialog>
<script>
const dialog = document.getElementById("editor");
const form = document.getElementById("label-form");
const input = document.getElementById("label");
const status = document.getElementById("status");
const outcome = document.getElementById("outcome");
const openButton = document.getElementById("open");
const cancelButton = document.getElementById("cancel");
let pending = false;
let allowNextSubmit = false;
let revision = 0;
async function validateLabel(value) {
await new Promise(resolve => setTimeout(resolve, 700));
const label = value.trim();
if (label === "error") {
throw new Error("Synthetic validation failure");
}
return label.length >= 3 && label !== "taken";
}
openButton.addEventListener("click", () => {
revision += 1;
dialog.returnValue = "";
status.textContent = "";
outcome.textContent = "";
dialog.showModal();
});
input.addEventListener("input", () => {
revision += 1;
});
cancelButton.addEventListener("click", () => dialog.close());
dialog.addEventListener("close", () => {
revision += 1;
outcome.textContent = "Dialog closed. Return value: " +
(dialog.returnValue || "(empty)");
});
form.addEventListener("submit", async event => {
if (allowNextSubmit) {
allowNextSubmit = false;
return;
}
event.preventDefault();
if (pending) return;
pending = true;
const submitter = event.submitter;
const value = input.value;
const checkedRevision = revision;
status.textContent = "Checking label...";
try {
const valid = await validateLabel(value);
if (!dialog.open) return;
if (revision !== checkedRevision || input.value !== value) {
status.textContent = "The label changed. Submit again.";
return;
}
if (!valid) {
status.textContent =
"Use at least three characters and avoid taken.";
return;
}
status.textContent = "Check passed. Submission requested.";
allowNextSubmit = true;
try {
if (submitter) form.requestSubmit(submitter);
else form.requestSubmit();
} finally {
allowNextSubmit = false;
}
} catch (error) {
if (dialog.open && revision === checkedRevision) {
status.textContent = "The check failed. Try again.";
}
console.error(error);
} finally {
pending = false;
}
});
</script>
</body>
</html>
该示例的浏览器行为依据文档记录的生命周期推导而来。一次简短的 Node.js EventTarget 检查确认了时序差异:同步取消会改变分派结果,而在 await 之后取消则赶不上该结果。此处尚未在 Mac 浏览器中运行完整的对话框示例。
守卫如何保留顺序并处理更改?
首次提交时,处理程序会立即取消事件,并捕获标签和发起提交的按钮。在检查等待期间,其他提交事件也会被取消,但不会启动额外验证。这样可以避免连续快速提交导致多个检查重叠。
检查完成后,处理程序会确认对话框仍处于打开状态,且标签未发生变化。修订计数器能够检测用户编辑,即使用户更改文本后又将其恢复也不例外。值比较还可捕获直接更改值、导致字段文本不同时的情况。
验证通过后,代码会在调用 requestSubmit() 前立即设置 allowNextSubmit。该方法会再次运行原生验证和提交事件流程。侦听器重新进入时,会消耗这次授权并直接返回,不会取消事件。随后浏览器便可执行默认的关闭对话框操作。此顺序遵循标准中先验证、后触发提交事件的步骤。HTML 标准:表单提交算法
内部的 finally 会清除授权,即使没有发生提交事件或该方法抛出异常也不例外。外部的 finally 会在拒绝、异常、结果过期或重试成功后清除 pending。因此,失败的尝试之后,表单仍可用于再次检查。
关闭对话框时也会递增修订号。先前打开时产生的结果不能授权之后再次打开的对话框。对于这份独立文档,这些检查涵盖了所有可用的交互路径。如果你添加更多字段或会更新字段的脚本,请扩展快照和修订号处理,使其覆盖验证器所依赖的每个值。
测试成功、拒绝和中断路径
在本地浏览器示例中运行以下检查。预期结果根据所提供的代码推断,并非声称已通过浏览器测试。
| 操作 | 预期结果 |
|---|---|
将标签留空并选择 Continue |
原生必填字段验证会在异步处理程序运行前阻止提交 |
输入 ab 并选择 Continue |
检查完成后,对话框仍保持打开并显示拒绝消息 |
输入 taken |
模拟规则会拒绝该标签 |
输入 sample |
检查期间对话框保持打开,随后关闭,返回值为 accepted |
输入 error |
会显示失败消息,并且仍可再次提交 |
| 提交标签后,在检查期间编辑它 | 检查结果会被丢弃,表单会要求再次提交 |
| 检查期间反复提交 | 只有第一次尝试会启动异步检查 |
检查期间选择 Cancel |
对话框会关闭,不会等待待处理的结果 |
输入框获得焦点时,也可尝试按 Enter 提交。隐式提交取决于表单结构和平台惯例;处理 submit 的原因,是为了涵盖实际触发表单提交的受支持交互。MDN:HTMLFormElement submit event
表单发送普通请求时会有什么变化?
对于现有的请求发送表单,请保留其真实的 action、method、字段名称、按钮值和提交覆盖设置。将模拟验证器替换为实际的异步检查。打开和关闭对话框的代码仅适用于本演示;取消、验证、请求提交这一顺序应放在表单的提交处理程序中。
保留字段以供最终提交使用。在等待期间禁用控件需要谨慎处理:浏览器构造提交条目列表时会排除已禁用字段。此示例允许用户继续编辑,并会拒绝过期的验证结果。HTML 标准:构造条目列表
如果存在其他提交侦听器,它们会观察到第一次被取消的尝试和后续尝试。应协调这些侦听器,避免重复执行相同工作,或在获得授权的提交过程中更改已验证的值。requestSubmit() 也不保证提交完成:其他侦听器可能会取消它,原生约束也可能阻止它。其返回值是 undefined,而非表示完成状态的 Promise。MDN:HTMLFormElement.requestSubmit()
关键约定很明确:同步取消,验证一组确定的值,丢弃过期结果,并授权一次新的原生提交尝试。这样,异步验证便能在工作流中拥有明确的位置,同时保留浏览器的表单生命周期。
来源
DOM 标准 — WHATWG
HTML 标准:表单提交算法 — WHATWG
HTML 标准:表单元素 — WHATWG
HTML 标准:构造条目列表 — WHATWG
async function — MDN Web Docs
Event:preventDefault() 方法 — MDN Web Docs
HTMLFormElement:submit 事件 — MDN Web Docs
HTMLFormElement:submit() 方法 — MDN Web Docs
HTMLFormElement:requestSubmit() 方法 — MDN Web Docs
Dialog 元素 — MDN Web Docs
HTMLDialogElement:showModal() 方法 — MDN Web Docs