在第一個 await 之前呼叫 event.preventDefault(),執行非同步驗證,然後使用 form.requestSubmit() 開始新的提交嘗試。一次性防護機制可讓第二次嘗試繼續進行,而不會重複非同步檢查。

本教學適合在 Mac 上編輯小型瀏覽器介面的人。你將建立一個本機 HTML 範例,其中表單只有在合成標籤檢查通過後才會關閉對話框。同一種事件處理模式也適用於會導覽或傳送請求的一般表單。

為什麼非同步提交處理常式不會延遲提交?

瀏覽器不會等待事件監聽器傳回的 promise。DOM 事件派送演算法會呼叫監聽器並繼續執行;HTML 提交演算法會先檢查 submit 事件是否已取消,再決定是否繼續。將 async 加到監聽器不會在這個生命週期中加入等待步驟。DOM 標準:事件派送、HTML 標準:表單提交演算法

非同步函式會同步執行,直到遇到第一個 await,然後才以非同步方式繼續。因此,即使等待的 promise 已經 fulfilled,也必須在第一個 await 之前取消事件。之後再呼叫 preventDefault(),無法撤銷瀏覽器已做出的提交決定。MDN:非同步函式

取消會結束原本的嘗試,不會暫停它以便稍後恢復。驗證成功後,程式碼必須明確要求再次提交。此外,preventDefault() 不會停止事件傳播,因此其他監聽器仍可觀察到該事件。MDN:Event.preventDefault()

請監聽表單的 submit 事件。這涵蓋透過按鈕提交、支援的隱式提交方式(例如按下 Enter),以及呼叫 requestSubmit() 的情況。只處理按鈕點擊的監聽器無法涵蓋所有入口。原生限制驗證通常會先執行:必填欄位無效時,會阻止提交,也不會觸發 submit。MDN:HTMLFormElement submit 事件

哪個方法能保留表單的提交行為?

如果檢查成功後應回到正常的表單生命週期,請使用 requestSubmit()。直接呼叫 submit() 會略過限制驗證和 submit 事件。這種捷徑會改變非同步處理常式原本應保留的行為。MDN:HTMLFormElement.submit()

做法 會發生什麼事 在此處是否適用
從監聽器傳回 promise 事件派送會繼續,不會等待 promise 不會延遲預設動作
取消事件,然後呼叫 submit() 提交會略過原生驗證和 submit 監聽器 會改變生命週期
取消事件,然後呼叫 requestSubmit(submitter) 原生驗證和 submit 事件會再次執行 保留預期的提交流程

等待前先擷取 event.submitter。將該按鈕傳給 requestSubmit(),即可保留按鈕的作用,包括提交覆寫設定,以及適用時對 name/value 的貢獻。如果沒有提交按鈕,請不帶引數呼叫 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 瀏覽器中執行完整的對話框範例。

防護機制如何保留順序並處理變更?

第一次提交時,處理常式會立即取消事件,並擷取標籤和發起操作的按鈕。檢查尚未完成時,其他 submit 事件也會被取消,但不會因此啟動另一個驗證。這可避免一連串快速提交造成多個檢查重疊執行。

檢查完成後,處理常式會確認對話框仍開啟,而且標籤未曾變更。修訂計數器能偵測使用者編輯,即使使用者改了文字後又恢復原狀也一樣。值比較也會捕捉直接變更值、但欄位文字不同的情況。

檢查通過後,會立即設定 allowNextSubmit,接著呼叫 requestSubmit()。這個方法會再次執行原生驗證和 submit 事件流程。監聽器再次進入時,會取用這次許可並直接返回,不取消事件。接著瀏覽器便能執行預設的關閉對話框動作。這個順序符合標準規定的先驗證、再觸發 submit 事件的流程。HTML 標準:表單提交演算法

內層的 finally 會在沒有發生 submit 事件或方法擲出例外時清除許可。外層的 finally 會在拒絕、例外、結果過時或重試成功後清除 pending。因此,嘗試未成功時,表單仍可再次檢查。

關閉對話框時也會遞增修訂版本。先前開啟時取得的結果,不能授權後續再次開啟時的提交。就這份獨立文件而言,這些檢查已涵蓋可用的互動路徑。如果加入更多欄位或會更新欄位的指令碼,請擴充快照和修訂版本處理,納入驗證器所依賴的每個值。

測試成功、拒絕和中斷的情況

在本機瀏覽器範例中執行以下檢查。預期結果是根據提供的程式碼推論而來,並非宣稱已取得瀏覽器測試結果。

操作 預期結果
將標籤留空並選擇 Continue 原生必填欄位驗證會在非同步處理常式執行前阻止提交
輸入 ab 並選擇 Continue 檢查完成後,對話框仍開啟並顯示拒絕訊息
輸入 taken 合成規則會拒絕該值
輸入 sample 檢查期間對話框保持開啟,之後關閉並以 accepted 作為回傳值
輸入 error 會顯示失敗訊息,且仍可再次提交
提交標籤後,在檢查期間編輯它 結果會被丟棄,表單會要求再次提交
檢查期間重複提交 只有第一次嘗試會啟動非同步檢查
檢查期間選擇 Cancel 對話框會立即關閉,不會等待待處理的結果

也可以在輸入欄位取得焦點時按 Enter 提交。隱式提交取決於表單結構和平台慣例;處理 submit 的用意,是涵蓋實際觸發表單提交的任何受支援互動方式。MDN:HTMLFormElement submit 事件

表單改為傳送一般請求時會有什麼變化?

若是現有會傳送請求的表單,請保留其實際的 action、method、欄位名稱、按鈕值和提交覆寫設定。將合成驗證器換成實際的非同步檢查。開啟和關閉對話框的程式碼只適用於此示範;取消、驗證、要求提交的流程則應放在表單的提交處理常式中。

請讓欄位保持可用,以便最後提交。等待期間停用控制項需要特別留意:瀏覽器建立提交項目清單時會排除停用欄位。範例讓使用者仍可編輯,並丟棄過時的驗證結果。HTML 標準:建立項目清單

如果還有其他 submit 監聽器,它們會觀察到第一次已取消的嘗試,以及後續嘗試。請協調這些監聽器,避免它們重複執行相同工作,或在授權通過時變更已驗證的值。requestSubmit() 也不保證提交完成:其他監聽器可能會取消提交,原生限制也可能阻止提交。它的回傳值是 undefined,不是完成 promise。MDN:HTMLFormElement.requestSubmit()

核心規則很明確:同步取消事件、驗證一組明確定義的值、丟棄過時結果,並授權一次新的原生提交嘗試。這讓非同步驗證在工作流程中有明確的位置,同時保留瀏覽器的表單生命週期。

來源