在第一个 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()

关键约定很明确:同步取消,验证一组确定的值,丢弃过期结果,并授权一次新的原生提交尝试。这样,异步验证便能在工作流中拥有明确的位置,同时保留浏览器的表单生命周期。

来源