一個閱讀計劃包含幾種資料:書名、每週頁數目標,以及偏好的閱讀形式。程式處理數值之前,表單必須先表達這些資料的意思。同時,輸入資料、接受資料、顯示結果與保留資料供下次使用,是不同的步驟,各有不同的成功依據。
本單元逐步建立一個閱讀計劃例子,把DOM、事件與物件狀態模型連接到表單控制項、驗證、Promise,以及 Fetch 使用的回應處理方式。
控制項表達數值,標籤表達意思
定義
表單控制項與提交項目
表單控制項是讓使用者輸入或選擇數值的介面。提交的表單項目通常把控制項的 name 與目前值配對。id 識別文件中的元素,不能代替提交資料使用的名稱。
應根據數值的意思選擇控制項:
| 控制項 | 合適用途 | 重要區別 |
|---|---|---|
input type="text" | 簡短書名 | 即使包含數字,其值仍是文字。 |
textarea | 跨越多行的問題 | 讀取目前的 value,而不是初始子文字。 |
input type="number" | 數值目標 | min、max 與 step 等約束描述允許的數值。 |
input type="email" 或 url | 具有指定語法的數值 | 語法檢查不能證明位址確實存在。 |
input type="checkbox" | 可獨立選擇的項目 | 可以同時選取多個核取方塊。 |
input type="radio" | 一組選項中的單一選擇 | 同一表單內的控制項以共用 name 組成一組。 |
select 與 option | 從選單中選擇 | 已選取的選項提供其值。 |
其他類型,例如 date、color、search 與 tel,會表達額外的輸入意圖,亦可能改變瀏覽器介面。例如 tel 可以影響流動裝置的鍵盤,但不會強制所有電話號碼遵守同一格式。密碼控制項會遮蔽顯示內容;這種外觀不會決定表單的傳送方式。
把標籤的 for 與控制項唯一的 id 配對,能為文字與控制項建立明確關係,亦讓讀者透過標籤選取控制項。fieldset 把相關控制項分組,legend 則為群組命名。每個單選按鈕仍需要自己的標籤與不同的 ID。
例題
閱讀計劃表單
把以下標記放在獨立 HTML 文件的 body 內。後面建立的腳本則放在表單之後。
<form id="reading-plan">
<p>
<label for="book">Book title</label>
<input id="book" name="book" type="text" maxlength="80" required>
</p>
<p>
<label for="pages">Pages this week</label>
<input id="pages" name="pages" type="number"
min="1" max="300" step="1" required>
</p>
<fieldset>
<legend>Preferred format</legend>
<input id="paper" name="format" type="radio" value="paper" checked>
<label for="paper">Paper</label>
<input id="digital" name="format" type="radio" value="digital">
<label for="digital">Digital</label>
</fieldset>
<p>
<label for="topic">Main topic</label>
<select id="topic" name="topic">
<option value="html">HTML</option>
<option value="css">CSS</option>
<option value="javascript">JavaScript</option>
</select>
</p>
<p>
<input id="discussion" name="discussion" type="checkbox" value="yes">
<label for="discussion">Include a discussion session</label>
</p>
<button type="submit">Record plan</button>
<button type="reset">Restore initial values</button>
</form>
<p id="plan-status" role="status"></p>
ID 支援標籤配對與 DOM 選取。單選按鈕共用 name="format",但各自的值區分不同選項。checked 屬性設定初始的紙本選擇。重設表單會還原初始值,包括這個選擇;不一定把所有控制項都清空。
按鈕類型同樣重要。提交按鈕會啟動表單的提交行為;type="button" 則需要自己的事件邏輯。重設按鈕還原預設值。明確指定類型,可以避免加入輔助按鈕時意外啟動提交。
從控制項取得名稱與數值
new FormData(form) 會根據該刻能參與提交的控制項建立資料。啟用的控制項一般需要 name。未選取的核取方塊與單選按鈕不產生項目;停用的控制項亦被略過。有名稱的唯讀文字控制項仍可產生項目,因此唯讀與停用是不同狀態。
假設讀者輸入 Web foundations、選擇 24 頁、保留紙本選項、選擇 HTML,並且沒有選取討論時段,資料項目如下:
book = Web foundations
pages = 24
format = paper
topic = html
pages 這項表單資料是字串 "24"。是否轉換類型,由程式決定。數值輸入控制項亦提供 valueAsNumber;空值或無法轉換的值會得到 NaN。FormData.get("format") 取得已選單選按鈕的值。若有重複名稱,getAll(name) 取得全部相應項目。data.has("discussion") 表達核取方塊是否提供了項目。
思考檢查
哪個名稱識別已選的閱讀形式?
表單有 ID 分別為 paper 與 digital 的單選按鈕,兩者都名為 format。讀者選取 digital 後,會提交哪個項目?若兩個控制項都改用 id="format",卻沒有 name,又會怎樣?
解答 · 區分文件識別名稱與資料項目名稱
已選控制項產生 format=digital。ID 用於文件內部,name 則成為資料鍵。讓兩個控制項共用 ID 會使文件識別含糊,既不能建立預期的單選群組,也不能補上提交所需的名稱。應使用不同 ID 與共用 name。
原生提交與腳本提交
一般表單提交以 action 指定目的地,以 method 選擇資料的傳送方式。沒有指定 method 時通常使用 GET;沒有指定 action 時通常使用目前文件的 URL。
| 方法 | 一般表單資料的位置 | 常見含義 |
|---|---|---|
| GET | 目的地 URL 中經編碼的查詢字串 | 按提供的搜尋條件取得結果。 |
| POST | 請求主體 | 提交資料,交由伺服器處理操作。 |
一般 POST 表單通常使用 application/x-www-form-urlencoded,檔案上載表單則採用另一種編碼。選擇 POST 不會自動產生 JSON。腳本若要建立 JSON 請求,必須序列化物件,並選擇相符的內容類型。方法本身也不會建立檔案,或定義應用程式的儲存政策。
表單 submit 事件的處理函式可以呼叫 event.preventDefault(),再進行自己的處理。應處理表單事件,而不只是某個按鈕的 click,讓鍵盤提交沿用相同邏輯。使用者啟動提交時,瀏覽器正常的約束驗證會先於 submit 事件進行;無效欄位可能令事件無法到達處理函式。
驗證檢查的是明確規則
HTML 約束涵蓋常見規則:required 拒絕必要欄位的空值,min 與 max 限制數值範圍,step="1" 則要求數值輸入以整數步距變化。這些約束不能表達每項應用規則。例如只包含空格的必要文字欄位,與具有實際字元的書名仍有分別。
有效性 API 提供不同操作:
checkValidity()回傳適用約束是否成立。reportValidity()亦要求瀏覽器提供驗證回饋。setCustomValidity(message)設定應用程式特有的錯誤;空字串會清除此自訂錯誤。
相關輸入改變時,必須清除自訂錯誤,否則修正後的控制項仍可能無效。應在欄位附近提供有用解釋;:valid、:invalid、:required、:optional、:enabled、:disabled、:read-only 與 :focus 可以配合視覺呈現。input[type="text"] 這類屬性選擇器則針對指定類型的控制項。樣式標示狀態,卻不定義驗證規則。客戶端檢查提供即時回饋;真正接收資料的服務仍須獨立驗證它接受的值。
非同步工作仍有執行次序
事件處理函式會依次執行 JavaScript 指令。等候輸入輸出則不同:操作可以開始、交回控制權,稍後才提供結果。在瀏覽器的主要 JavaScript 執行緒上,每次只執行一個回呼函式。JavaScript 繼續其他工作時,瀏覽器可以管理網絡活動。
回呼函式是交給其他程式碼、由對方呼叫的函式。周邊操作決定它何時執行,因此回呼不一定是非同步。巢狀回呼可以表達相依工作,但層數增加時,執行順序與錯誤路徑會更難追蹤。
定義
Promise 與非同步後續工作
Promise 代表稍後產生的結果。它最初處於 pending,之後可以 fulfilled 並提供數值,或 rejected 並提供原因。.then() 加入後續工作並回傳新的 Promise;若處理函式拋出錯誤,下一個 Promise 便被拒絕。.catch() 處理鏈中的拒絕結果。
在 .then() 處理函式中回傳下一項非同步操作,會讓鏈等待該操作。若沒有 return,內部操作可能獨自繼續,外層鏈卻已向前執行。async 函式亦回傳 Promise;其內的 await 暫停該函式的後續工作,直至等待的 Promise 完成,但不會凍結整個瀏覽器。等待的步驟若被拒絕,效果如同拋出錯誤,可以用 try/catch 處理。
例題
追蹤同步指令、微任務與計時器
把腳本視為目前任務,先記錄同步輸出,再考慮已排定的後續工作。
console.log("A");
setTimeout(() => console.log("D"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("B");
這段腳本的輸出為 A、B、C、D。同步指令先完成,已完成 Promise 的後續工作以微任務執行,早於稍後的計時器任務。零延遲計時器只是排定後續工作,不會中斷目前腳本,也不保證精確的執行時間。重複工作可以使用 setInterval;保留回傳的 ID,並在應停止時把它交給 clearInterval。
思考檢查
加上 async 是否令每行都等待?
一個 async 函式呼叫 request(...),隨即讀取一個將由 .then() 處理函式賦值的變數。為何仍可能讀到舊值?如何直接表達這項相依關係?
解答 · 等待真正需要的操作結果
把外層函式宣告為 async,不會自動等待其內每個 Promise。沒有 await 的呼叫之後,程式會繼續執行。應以 const response = await request(...) 取得回應,再等待主體轉換,然後才使用資料。另一種做法是回傳依次完成這些相依步驟的 Promise 鏈。
Fetch 把取得回應與讀取主體分成兩步
AJAX 指非同步取得或提交資料,並在不重新導覽整頁的情況下更新網頁的模式。其歷史名稱提及 XML,但這種模式也適用於 JSON 與文字。XMLHttpRequest 提供較早的請求介面;Fetch 則以 Promise 表達回應流程。
瀏覽器的 fetch() 回傳一個代表 Response 的 Promise。Promise 完成表示已取得回應,不表示每項應用操作都成功。狀態為 404 或 500 的回應通常仍令 Promise 完成,因此必須檢查 response.ok 或 response.status。狀態碼為 200 至 299 時,ok 為 true。
讀取主體是另一個步驟:await response.json() 讀取並解析 JSON,await response.text() 則取得文字。即使 HTTP 狀態是 200,JSON 解析仍可能失敗。讀取方法會消耗主體,因此應選擇需要的表示,而不是反覆讀取同一回應。
| 失敗情況 | 出現位置 | 判斷方法 |
|---|---|---|
| 網絡或瀏覽器存取失敗 | Fetch 的 Promise 被拒絕。 | 這次呼叫沒有取得可用回應。 |
| HTTP 錯誤狀態 | 取得回應,但 ok === false。 | 檢查狀態及服務規定的錯誤表示。 |
| JSON 無效 | response.json() 被拒絕。 | 收到位元組與解析預期表示,是不同步驟。 |
| 應用規則拒絕資料 | 服務按其約定回報拒絕。 | 保留使用者輸入,並解釋適用規則。 |
來源由協定方案、主機與埠號組成。瀏覽器的 CORS 規則與接收服務的回應標頭,共同管制跨來源回應的存取;改變請求方法不會授予這種權限。實作本機 HTTP 例子時,應透過開發伺服器開啟網頁。file: 文件與 HTTP 文件的載入環境不同。
完全位於記憶體的回應模型
例題
逐步追蹤同一儲存實例
以下函式使用真正的 Response 物件,提供一個簡單請求介面。狀態保留在回傳函式的閉包內。資源字串 memory:reading-plan 選取這個模型,函式不會發出網絡請求。
function createReadingStore() {
let plan = null;
const reply = (status, data) => new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" }
});
return async function mockFetch(resource, options = {}) {
await Promise.resolve();
if (resource !== "memory:reading-plan") {
return reply(404, { error: "Unknown resource" });
}
const method = options.method ?? "GET";
if (method === "GET") return reply(200, { plan });
if (method !== "PUT") return reply(405, { error: "Unsupported method" });
let incoming;
try {
incoming = JSON.parse(options.body);
} catch {
return reply(400, { error: "Expected JSON" });
}
if (incoming === null || typeof incoming !== "object" ||
typeof incoming.book !== "string" ||
incoming.book.trim().length === 0 || incoming.book.length > 80 ||
!Number.isInteger(incoming.pages) ||
incoming.pages < 1 || incoming.pages > 300 ||
!["paper", "digital"].includes(incoming.format)) {
return reply(422, { error: "Invalid reading plan" });
}
plan = {
book: incoming.book.trim(),
pages: incoming.pages,
format: incoming.format
};
return reply(200, { plan, storage: "memory" });
};
}
const request = createReadingStore();
模型以 GET 讀取目前值,以 PUT 取代該值。其服務約定驗證三個欄位;表單內的主題與討論選項用來示範控制項類型,不會由此模型儲存。暫存的 incoming 物件先通過驗證,plan 才會改變,因此無效輸入不會破壞先前計劃。
回傳函式名為 mockFetch,明確表示其角色。它是請求與回應處理的教學模型,並非瀏覽器 Fetch API 的完整實作。
在模型定義後執行以下程式;它建立獨立的 sample,逐項等待並觀察回應。
async function traceStore() {
const sample = createReadingStore();
const initial = await sample("memory:reading-plan");
console.log(initial.status, (await initial.json()).plan);
const write = await sample("memory:reading-plan", {
method: "PUT",
body: JSON.stringify({ book: "Web sketches", pages: 12, format: "digital" })
});
console.log(write.status, (await write.json()).plan.book);
const read = await sample("memory:reading-plan");
console.log(read.status, (await read.json()).plan.pages);
const invalid = await sample("memory:reading-plan", {
method: "PUT",
body: JSON.stringify({ book: "Web sketches", pages: 0, format: "digital" })
});
console.log(invalid.status, (await invalid.json()).error);
const afterFailure = await sample("memory:reading-plan");
console.log(afterFailure.status, (await afterFailure.json()).plan.pages);
const fresh = await createReadingStore()("memory:reading-plan");
console.log(fresh.status, (await fresh.json()).plan);
}
traceStore().catch(error => console.log(error.message));
| 操作 | 狀態 | 觀察結果 |
|---|---|---|
| 初次 GET | 200 | null |
| 有效 PUT | 200 | Web sketches |
| 成功後 GET | 200 | 12 頁 |
| 零頁數 PUT | 422 | Invalid reading plan |
| 失敗後 GET | 200 | 仍是 12 頁 |
| 新實例 GET | 200 | null |
驗證先於取代,所以拒絕寫入不會清除舊計劃。最後一次讀取使用另一閉包,顯示各實例的記憶體並非共用或持久儲存。
把經驗證的表單接到模型
下一段腳本應放在上一段及表單之後。自訂書名規則拒絕只有空格的文字。處理函式等待結果時,提交按鈕會停用;無論成功或失敗,finally 區塊都會恢復按鈕。
const form = document.querySelector("#reading-plan");
const book = document.querySelector("#book");
const pages = document.querySelector("#pages");
const status = document.querySelector("#plan-status");
const submit = form.querySelector('button[type="submit"]');
book.addEventListener("input", () => book.setCustomValidity(""));
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (book.value.trim() === "") {
book.setCustomValidity("Enter a book title with visible characters.");
book.reportValidity();
return;
}
const data = new FormData(form);
const plan = {
book: book.value.trim(),
pages: pages.valueAsNumber,
format: data.get("format")
};
submit.disabled = true;
status.textContent = "Recording...";
try {
const response = await request("memory:reading-plan", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(plan)
});
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
const receipt = await response.json();
status.textContent = `Recorded in memory: ${receipt.plan.book}`;
form.reset();
} catch (error) {
status.textContent = error.message;
} finally {
submit.disabled = false;
}
});
只有回應檢查與 JSON 讀取都成功後,處理函式才清空表單。若有錯誤,已輸入的值仍可供修正。結果使用 textContent 呈現,因此書名會作為文字顯示。
要檢查目前記憶體中的值,可在前面的腳本之後執行以下附加函式:
async function inspectPlan() {
const response = await request("memory:reading-plan");
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
const data = await response.json();
console.log(data.plan);
}
inspectPlan().catch(error => console.log(error.message));
先等待寫入,再啟動依賴該結果的讀取,便表達了因果次序:驗證、更新模型、接收確認,然後讀取。若寫入尚未完成便開始讀取,就沒有表達這項相依關係。停用提交按鈕只限制一般重複點擊,不能成為所有呼叫者或多個分頁之間的通用排序規則。
常見錯誤
必須按儲存約定解讀確認回應
這個模型更新閉包變數後便回傳成功。同一實例的 GET 可以取得該值;重新載入網頁或建立另一個 store,卻會把 plan 再次初始化為 null。持久儲存需要服務定義儲存操作及其完成保證;成功回應本身不會說明這項保證。
練習
思考檢查
練習 1:預測提交項目
書名控制項名為 book,並設為 readonly;頁數控制項名為 pages,並已停用。紙本已選取,討論選項未選取。哪些控制項會向 new FormData(form) 提供項目?唯讀是否令接收服務可以信任書名?
解答 · 練習 1 解答
有名稱的唯讀書名控制項會提供其值;停用的頁數控制項不會。已選紙本單選按鈕提供 format=paper;未選討論核取方塊沒有項目。唯讀影響的是這個介面,並不免除接收服務的驗證責任,因為請求可以獨立於表單建立。
思考檢查
練習 2:追蹤驗證狀態
書名欄位有三個空格。解釋 required 為何不足、處理函式如何標示錯誤,以及 input 監聽器為何要清除自訂訊息。
解答 · 練習 2 解答
原始值有字元,因此 required 約束本身未必拒絕它。應用程式需要有實際內容的書名,所以檢查去除首尾空白後的值。setCustomValidity 記錄這項規則的錯誤,reportValidity 則要求回饋。下一次輸入時清除自訂錯誤,才能評估修正後的值,而非保留過時的錯誤。
思考檢查
練習 3:辨認失敗步驟
情況 A 取得 HTTP 500 回應,其 JSON 有效;情況 B 取得 HTTP 200,但主體文字是 not JSON;情況 C 在取得可用回應之前失敗。每種情況由哪個 Promise 或檢查發現?
解答 · 練習 3 解答
A 由回應狀態檢查發現,Fetch 的 Promise 仍可完成。B 在讀取 JSON 的 Promise 被拒絕時發現。C 則是 Fetch 操作本身被拒絕。三者最後可能交由同一 catch 顯示錯誤,但它們屬於計算過程中的不同階段。
思考檢查
練習 4:區分已儲存的值與持久性
一個 store 接受有效的 24 頁計劃。同一實例的 GET 取得該計劃,新建 createReadingStore() 實例的 GET 卻得到 null。解釋兩個結果;介面若要承諾重新載入後仍保留計劃,還需要甚麼事實?
解答 · 練習 4 解答
第一個實例在自己的閉包內保留計劃;新實例則有另一個初始化為 null 的閉包。要承諾跨越重新載入保留資料,必須有持久儲存機制,並有依據證明儲存操作已按這種含義完成。模型的 storage: "memory" 確認,刻意描述較短的狀態壽命。