# Day 36:重構 CLI 驗證流程:從過度抽象到剛好夠用 **Published by:** [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/) **Published on:** 2026-01-12 **URL:** https://paragraph.com/@gcake/day-36 ## Content 主題:在 Node.js 環境下,如何為 CLI 程式設計「可讀、好維護」的輸入驗證流程。一級函式與驗證器設計在 JavaScript 中,函式是一級公民,可以:存在變數、陣列、物件中當作參數傳入其他函式當作回傳值傳出擁有屬性與方法[1]這非常適合拿來實作「驗證器」:// 單一職責的驗證器 export function validatePositiveInt(raw, min = 1, max = Infinity) { const trimmed = String(raw).trim(); if (!/^[+-]?[0-9]+$/.test(trimmed)) { throw new Error("請輸入整數"); } const num = parseInt(trimmed, 10); if (num < min || num > max) { if (max === Infinity) { throw new Error(`請輸入大於等於 ${min} 的正整數`); } throw new Error(`請輸入 ${min} 到 ${max} 之間的正整數`); } return num; } 設計重點:驗證器是「普通函式」,接 string、丟錯誤或回傳轉型後的值。名稱直接表意(validatePositiveInt),不需要額外文件。[1]反例:過度抽象的 composeValidators曾經的設計:為了「高可重用性」,先做一個驗證器組合器 composeValidators,然後所有驗證流程都要經過這個組合器。// 抽象過度的範例(不推薦) import { composeValidators } from "./composeValidators.js"; import { validateIntegerString, toInt, validatePositiveInt, } from "./validators.js"; export const config = { validator: composeValidators([ validateIntegerString, // 格式檢查 toInt, // 型別轉換 validatePositiveInt, // 範圍檢查 ]), }; 問題:需要先理解 composeValidators 的執行順序與錯誤傳遞規則,認知負擔高。每層可能拋出不同錯誤訊息,很難維持一致性。回傳值若被包成 { value } 之類的物件,只是多一層樣板。 (「為了統一而統一」,但沒有實質收益。)對單純的 CLI 練習題或小工具來說,這種抽象層級明顯超過實際需求。改用「單一函式驗證器」的結構重構後的目標:一個驗證需求,一個函式。直接回傳值,不包殼。所有錯誤訊息在函式內統一管理。// util/validators.js function validateFormat(input, pattern, errorMsg) { const trimmed = String(input).trim(); if (!pattern.test(trimmed)) { throw new Error(errorMsg); } return trimmed; } function validateRange(num, min, max, errorMsg) { if (num < min || (max !== undefined && num > max)) { throw new Error(errorMsg); } return num; } export function validateNonNegativeInt(raw) { const trimmed = validateFormat(raw, /^[+-]?[0-9]+$/, "請輸入整數"); const num = parseInt(trimmed, 10); return validateRange(num, 0, undefined, "請輸入非負整數"); } export function validateEvenInt(raw, min = 2) { const trimmed = validateFormat(raw, /^[+-]?[0-9]+$/, "請輸入整數"); const num = parseInt(trimmed, 10); if (num < 0) { throw new Error("請輸入非負整數"); } if (num % 2 !== 0) { throw new Error("請輸入偶數"); } return validateRange(num, min, undefined, `請輸入大於等於 ${min} 的偶數`); } 使用方式:import { validateNonNegativeInt } from "./util/validators.js"; const config = { name: "visitNum", prompt: "請輸入人數:", validator: validateNonNegativeInt, }; 優點:不需要閱讀 composeValidators 就能理解驗證邏輯。錯誤訊息路徑清楚:「格式」→「範圍」→「業務規則」。型別由 JS 自己承擔(直接回傳 number),不多包一層物件。[1]CLI 輸入:為什麼改用遞迴而不用 while情境:寫一個 CLI 工具,重複詢問使用者輸入,直到通過驗證。初版:while(true) + return 退出// 反例:while(true) + return import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; function createRl() { return createInterface({ input, output }); } export async function askWithValidator(prompt, validator) { let rl = createRl(); while (true) { const raw = await rl.question(prompt); try { const value = validator(raw); rl.close(); return value; // 同時結束迴圈與函式 } catch (error) { console.log("輸入錯誤,請重新輸入"); rl.close(); rl = createRl(); } } } 問題:return 同時負責「跳出迴圈」與「結束函式」,職責混在一起。除錯時不容易判斷是迴圈控制有誤,還是函式邏輯有誤。while(true) 需要讀者在腦中模擬可能的結束條件,本身認知負擔就高。[2][3]即使改成 break 也一樣會有「必須在腦中跑迴圈」的問題,只是職責稍微清楚一些:// 仍然偏難讀:需要外部變數 + 模擬迴圈狀態 export async function askWithValidator(prompt, validator) { let rl = createRl(); let result; while (true) { const raw = await rl.question(prompt); try { result = validator(raw); break; } catch (error) { console.log("輸入錯誤,請重新輸入"); rl.close(); rl = createRl(); } } rl.close(); return result; } 重構:單一遞迴函式,去掉 while更直覺的方式是改成「錯了就再問一次」的遞迴寫法:// 版本 1:每次呼叫都建立/關閉一次 rl import readline from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; export function createRl() { return readline.createInterface({ input, output }); } export async function askWithValidatorUntilPass(prompt, validator) { const rl = createRl(); const raw = await rl.question(prompt); try { const value = validator(raw); rl.close(); return value; } catch (error) { console.log(error.message); rl.close(); return askWithValidatorUntilPass(prompt, validator); } } 閱讀時的心智模型變成:問一次。驗證成功 → 回傳。驗證失敗 → 顯示錯誤 → 再呼叫自己一次。幾乎不需要在腦中模擬狀態。對「重試直到成功」這種流程,遞迴比 while 更自然。[3][2]進一步優化:外層管理 readline 生命週期為了避免每次遞迴都重新建立 readline.Interface,可以把資源管理抽到外層,內層只管重試:import readline from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; export function createRl() { return readline.createInterface({ input, output }); } export async function askWithValidatorUntilPass(prompt, validator) { const rl = createRl(); async function attempt() { const raw = await rl.question(prompt); try { return validator(raw); } catch (error) { console.log(error.message || "輸入錯誤,請重新輸入"); return attempt(); } } try { return await attempt(); } finally { rl.close(); } } 結構上的分工:外層:建立與關閉 rl(資源管理)。內層 attempt():只負責「問 → 驗證 → 成功或重試」。這樣既避免了 while,也讓職責非常清晰。Node 官方文件也建議為 readline/promises 建立 interface 後,以 async/await 形式操作問題流程。[4][5]統一 async/await 與錯誤處理風格在 CLI 主程式中,常見兩種寫法:// 寫法 A:async 函式 + 外層 .catch() async function main() { // ... } main().catch((err) => { console.error("程式發生未預期錯誤:", err); process.exit(1); }); // 寫法 B:async 函式 + 內部 try/catch async function main() { try { // ... } catch (err) { console.error("程式發生未預期錯誤:", err); process.exit(1); } } main(); 為了風格一致與可讀性,多數情況下選擇「函式內使用 async/await,就同時用 try/catch 在函式內處理錯誤」會比較好;這與 MDN 對 async function + try...catch 的示範風格相符。[6][7] 範例:import { askWithValidatorUntilPass } from "./util/askWithValidatorUntilPass.js"; import { validatePositiveInt } from "./util/validators.js"; async function main() { console.log("=== 細菌分裂總數量計算機 ===\n"); try { const hours = await askWithValidatorUntilPass( "請輸入經過的小時數:", (raw) => validatePositiveInt(raw, 1, 48), ); const result = 2 ** hours; console.log("\n結果:", result); } catch (err) { console.error("程式發生未預期錯誤:", err); process.exit(1); } } main(); 設計原則整理函式命名即文件 validatePositiveInt、validateEvenInt 比 validate、check 清楚許多。[1]錯誤訊息分層格式錯誤:「請輸入整數」範圍錯誤:「請輸入非負整數」或「請輸入 1–10 之間的整數」業務規則錯誤:「請輸入偶數」直接回傳,不過度包裝 讓錯誤靠 throw,成功靠回傳值,不需要額外 { value, errors } 外殼。避免不必要的抽象 沒有實際需求時,不要先設計通用的 validator pipe/chain;一個清楚的函式往往已足夠。降低認知負擔優先 在迭代流程上,優先順序大致可以是:認知負擔:遞迴 < 條件明確的迴圈 < while(true) + break < while(true) + return 風格一致性若用 async/await,就搭配 try/catch。一致的錯誤處理方式讓程式碼更好維護。[7][6]1234567 ## Publication Information - [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/): Publication homepage - [All Posts](https://paragraph.com/@gcake/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@gcake): Subscribe to updates