# Day 31:CLI 驗證流程實戰 - 用閉包實作部分套用與 API 封裝 **Published by:** [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/) **Published on:** 2026-01-05 **URL:** https://paragraph.com/@gcake/day-31 ## Content 專案背景這是一個 Node.js CLI 互動式輸入驗證系統,使用 readline 模組讀取使用者輸入,並透過可組合的驗證器檢查輸入正確性。核心設計目標包含模組化、可測試性與可擴展性。系統架構分層設計┌─────────────────────────────────────────────────────┐ │ Application Layer (main.js) │ │ 定義 questionConfigs,呼叫 runCliQuestions │ └────────────────┬────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Orchestration Layer (runCliQuestions.js) │ │ 組裝依賴:createRl、askWithValidator、session │ └────────────────┬────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Core Logic Layer (runCliQuestionsCore.js) │ │ 管理 rl 生命週期,用閉包產生 askWithValidatorBoundToRl│ └────────────────┬────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ I/O Adapter Layer (askWithValidator.js) │ │ 封裝 readline 互動邏輯 │ └────────────────┬────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Validator Layer (validators.js) │ │ 提供可組合的驗證器函式 │ └─────────────────────────────────────────────────────┘ 設計原則對應層級職責SOLID 原則Application定義業務問題配置SRP:只負責配置組裝Orchestration依賴注入與組裝DIP:依賴抽象而非具體Core Logic流程控制邏輯OCP:擴展不修改核心I/O Adapter封裝底層 I/O 細節ISP:介面隔離Validator驗證規則實作SRP:單一驗證職責核心問題:為什麼需要閉包封裝 API問題起源底層 I/O 模組的 API// askWithValidator.js export async function askWithValidator(rl, prompt, validator) { while (true) { const raw = await questionOnce(rl, prompt); try { const value = validator(raw); return value; } catch (error) { console.log(error.message); } } } 特性:需要 rl(readline 實例)作為第一個參數,直接依賴具體的 I/O 實作。高階流程模組的需求// runCliQuestionsCore.js export async function askOneRound(questionConfigs, askWithValidator) { const answers = {}; for (const config of questionConfigs) { // 希望只傳 (prompt, validator),不想知道 rl 是什麼 const answer = await askWithValidator(config.prompt, config.validator); answers[config.name] = answer; } return answers; } 特性:只關心抽象的「問問題」能力,不應該知道底層實作細節。API 層級不一致模組需要的 API實際的 APIaskOneRoundask(prompt, validator)askWithValidator(rl, prompt, validator)如果直接呼叫,會導致職責污染:// ❌ 錯誤:askOneRound 必須知道 rl,違反職責分離 export async function askOneRound(questionConfigs, askWithValidator, rl) { const answers = {}; for (const config of questionConfigs) { const answer = await askWithValidator(rl, config.prompt, config.validator); answers[config.name] = answer; } return answers; } 問題分析:askOneRound 被 rl 污染,必須知道 readline 的存在測試時必須 mock readline,而不是 mock 抽象行為違反依賴反轉原則 (DIP):高階模組依賴低階細節解決方案:用閉包實作部分套用部分套用(Partial Application)概念定義:固定一個函式的部分參數,回傳一個新函式只接受剩餘參數。 通用範例:// 原始函式:三個參數 function add(a, b, c) { return a + b + c; } // 部分套用:固定第一個參數 function addWith5(b, c) { return add(5, b, c); } addWith5(2, 3); // 10 用閉包實作:function createAdder(a) { // 閉包:回傳的函式「記住」外層的 a return function (b, c) { return a + b + c; }; } const addWith5 = createAdder(5); addWith5(2, 3); // 10 應用:封裝 readline 細節在 runCliQuestionsCore 中的實作:// runCliQuestionsCore.js export async function runCliQuestionsCore( questionConfigs, askWithValidator, runCliSession, rounds = 1 ) { return runCliSession(async (rl) => { const result = []; // 關鍵:用閉包固定第一個參數 rl const askWithValidatorBoundToRl = (prompt, validator) => askWithValidator(rl, prompt, validator); for (let round = 0; round < rounds; round += 1) { const oneRoundResult = await askOneRound( questionConfigs, askWithValidatorBoundToRl ); result.push(oneRoundResult); } return result; }); } 執行流程拆解步驟 1:建立詞法環境 根據 ECMAScript 規範,當 runCliSession 執行時:JavaScript 引擎建立執行上下文 (Execution Context)建立宣告式環境記錄 (Declarative Environment Record),儲存 rl 變數rl 的值是 createRl() 回傳的 readline interface步驟 2:宣告閉包函式const askWithValidatorBoundToRl = (prompt, validator) => askWithValidator(rl, prompt, validator); 這個箭頭函式在宣告時:內部有 [[Environment]] 屬性,指向外層的 Environment Record即使外層函式執行完畢,只要這個函式存在,rl 就不會被 GC 回收步驟 3:傳遞簡化後的 APIconst oneRoundResult = await askOneRound( questionConfigs, askWithValidatorBoundToRl ); askOneRound 拿到的是簽章為 (prompt, validator) 的函式。 步驟 4:執行時存取閉包變數// 在 askOneRound 裡 const answer = await askWithValidator(config.prompt, config.validator); 實際執行的是:askWithValidator(rl, config.prompt, config.validator) 這裡的 rl 來自當初 runCliSession 回呼時的詞法環境。記憶體狀態圖解┌─────────────────────────────────────────────────┐ │ runCliSession 的 Lexical Environment │ │ │ │ rl: ReadlineInterface { ... } ← 私有變數 │ └────────────────┬────────────────────────────────┘ │ │ 閉包持有這個環境的參照 │ ▼ ┌─────────────────────────────────────────────────┐ │ askWithValidatorBoundToRl: Function │ │ │ │ (prompt, validator) => { │ │ askWithValidator(rl, prompt, validator) │ │ } │ │ │ │ ← 這個函式「記住」上方的 rl │ └─────────────────────────────────────────────────┘ │ │ 傳遞給 askOneRound │ ▼ ┌─────────────────────────────────────────────────┐ │ askOneRound 的執行上下文 │ │ │ │ 呼叫 askWithValidator(prompt, validator) │ │ ↓ │ │ 實際執行:askWithValidator(rl, prompt, validator)│ └─────────────────────────────────────────────────┘ 設計優勢分析職責分離 (Single Responsibility Principle)模組職責依賴細節askWithValidator操作 readline,處理重試邏輯知道 rl、console.logaskOneRound遍歷問題清單,收集答案只知道「有個函式可以問問題」runCliQuestionsCore組裝依賴,管理多輪問答知道 rl,但不直接操作依賴反轉 (Dependency Inversion Principle)高階模組不依賴低階模組的具體實作,雙方都依賴抽象:┌─────────────────────────────────────────┐ │ askOneRound (高階模組) │ │ 依賴:ask(prompt, validator) 抽象 │ └──────────────────▲──────────────────────┘ │ │ 透過閉包注入 │ ┌──────────────────┴──────────────────────┐ │ askWithValidator (低階模組) │ │ 實作:askWithValidator(rl, prompt, ...) │ └─────────────────────────────────────────┘ 未來可以替換實作:// 替換成從檔案讀取的版本 const askFromFile = (prompt, validator) => { const input = fs.readFileSync('inputs.txt', 'utf-8'); return validator(input); }; // askOneRound 完全不用改 await askOneRound(questionConfigs, askFromFile); 可測試性提升測試時不需要 readline:// askOneRound.test.js import { describe, it, expect } from 'vitest'; import { askOneRound } from './runCliQuestionsCore.js'; describe('askOneRound', () => { it('應該正確收集所有問題的答案', async () => { const mockAsk = async (prompt, validator) => { if (prompt.includes('偶數')) return validator('42'); if (prompt.includes('正整數')) return validator('5'); return validator('0'); }; const questionConfigs = [ { name: 'evenNum', prompt: '請輸入偶數', validator: (v) => ({ value: parseInt(v, 10) }), }, { name: 'posNum', prompt: '請輸入正整數', validator: (v) => ({ value: parseInt(v, 10) }), }, ]; const result = await askOneRound(questionConfigs, mockAsk); expect(result).toEqual({ evenNum: { value: 42 }, posNum: { value: 5 }, }); }); }); 實作方式對比方案 A:參數一路傳(不用閉包)export async function askOneRound(questionConfigs, askWithValidator, rl) { const answers = {}; for (const config of questionConfigs) { const answer = await askWithValidator(rl, config.prompt, config.validator); answers[config.name] = answer; } return answers; } 評估:面向優點缺點可讀性參數傳遞清楚每層都要傳 rl職責-違反 SRP / DIP測試-必須 mock rl方案 B:使用 bindexport async function runCliQuestionsCore( questionConfigs, askWithValidator, runCliSession, rounds = 1 ) { return runCliSession(async (rl) => { const result = []; const askBound = askWithValidator.bind(null, rl); for (let round = 0; round < rounds; round += 1) { const oneRoundResult = await askOneRound(questionConfigs, askBound); result.push(oneRoundResult); } return result; }); } 評估:面向優點缺點簡潔性一行搞定對初學者較難理解功能等價於閉包版本-語意明確表達「綁定參數」-方案 C:使用 Class 封裝class CliQuestionSession { constructor(rl, askWithValidator) { this.rl = rl; this.askWithValidator = askWithValidator; } async ask(prompt, validator) { return this.askWithValidator(this.rl, prompt, validator); } async askOneRound(questionConfigs) { const answers = {}; for (const config of questionConfigs) { const answer = await this.ask(config.prompt, config.validator); answers[config.name] = answer; } return answers; } } 評估:面向優點缺點狀態管理適合需要記錄重試次數等狀態目前不需要狀態累積風格OOP 風格Node.js CLI 慣用 FP 風格複雜度-需管理實例生命週期完整程式碼範例核心邏輯層// util/runCliQuestionsCore.js /** * 執行一輪問答 * @param {Array} questionConfigs - 問題配置陣列 * @param {Function} askWithValidator - 問問題的函式 * @returns {Promise} 答案物件 */ export async function askOneRound(questionConfigs, askWithValidator) { const answers = {}; for (const config of questionConfigs) { const answer = await askWithValidator(config.prompt, config.validator); answers[config.name] = answer; } return answers; } /** * 執行多輪問答的核心邏輯 * @param {Array} questionConfigs - 問題配置陣列 * @param {Function} askWithValidator - 原始的問問題函式 * @param {Function} runCliSession - Session 管理函式 * @param {number} rounds - 執行輪數 * @returns {Promise} 每輪答案的陣列 */ export async function runCliQuestionsCore( questionConfigs, askWithValidator, runCliSession, rounds = 1 ) { return runCliSession(async (rl) => { const result = []; // 用閉包實作部分套用 const askWithValidatorBoundToRl = (prompt, validator) => askWithValidator(rl, prompt, validator); for (let round = 0; round < rounds; round += 1) { const oneRoundResult = await askOneRound( questionConfigs, askWithValidatorBoundToRl ); result.push(oneRoundResult); } return result; }); } I/O 層// util/askWithValidator.js /** * 詢問使用者輸入(只問一次) * @param {ReadlineInterface} rl - readline 實例 * @param {string} prompt - 提示訊息 * @returns {Promise} 使用者輸入的原始字串 */ export async function questionOnce(rl, prompt) { const userInput = await rl.question(prompt); return userInput; } /** * 反覆詢問使用者,直到驗證通過 * @param {ReadlineInterface} rl - readline 實例 * @param {string} prompt - 提示訊息 * @param {Function} validator - 驗證器 * @returns {Promise} 驗證通過的值 */ export async function askWithValidator(rl, prompt, validator) { while (true) { const raw = await questionOnce(rl, prompt); try { const result = validator(raw); return result; } catch (error) { console.log('❌', error.message); } } } 驗證器組合// util/composeValidators.js /** * 組合多個驗證器成一個驗證鏈 * @param {Array} validators - 驗證器陣列 * @returns {Function} 組合後的驗證器 */ export function composeValidators(validators) { return (value) => { let current = value; for (const validate of validators) { const result = validate(current); current = result.value; } return { value: current }; }; } 常見問題與解決方案問題 1:readline API 版本混用導致卡住症狀輸入後遊標卡住,無法正常結束。根本原因createRl 使用 node:readline/promises,但 questionOnce 用 callback 版本 API:// ❌ 錯誤:Promise 版的 Interface,用 callback 版的 API export function questionOnce(rl, prompt) { return new Promise((resolve) => { rl.question(prompt, (userInput) => { // callback 不會被觸發 resolve(userInput); }); }); } 解決方案改用 Promise 版 API:// ✅ 正確:搭配 node:readline/promises export async function questionOnce(rl, prompt) { const userInput = await rl.question(prompt); return userInput; } 經驗法則import 來源Interface.question API適合的寫法node:readline/promisesawait rl.question()直接 awaitnode:readlinerl.question(p, cb)傳 callback 或自己包成 Promise混用❌ callback 不會被觸發必須統一問題 2:config 欄位名稱不一致症狀答案物件的 key 變成 undefined。根本原因askOneRound 使用 config.key,但 config 只有 name 欄位:// ❌ 錯誤 answers[config.key] = answer; // config 實際結構 { name: "evenInput", // 只有 name prompt: "請輸入偶數", validator: fn } 解決方案統一使用 config.name:// ✅ 正確 answers[config.name] = answer; 問題 3:驗證器回傳結構與業務邏輯不一致症狀遞迴函式導致堆疊溢位 (Maximum call stack size exceeded)。根本原因驗證器回傳 { value: 8 },但業務邏輯期望 number:// processor 直接呼叫業務邏輯 processor: umleven // 實際呼叫變成 umleven({ value: 8 }) // ❌ 期望 number,收到 object 解決方案在 processor 中拆解結構:// ✅ 正確 processor: (validated) => { const n = validated.value; return umleven(n); } 或在 processUserInputs 統一處理:export function processUserInputs(userInputRounds, questionConfigs) { return userInputRounds.map((roundInputs) => { const processedValues = {}; for (const config of questionConfigs) { const validated = roundInputs[config.name]; // 自動判斷:如果是 { value } 就拆出來 const input = validated && typeof validated === 'object' && 'value' in validated ? validated.value : validated; processedValues[config.name] = config.processor ? config.processor(input) : input; } return processedValues; }); } JSDoc 型別標註實作定義核心型別// util/types.js // @ts-check /** * 驗證結果 * @typedef {Object} ValidationResult * @property {any} value - 驗證後的值 */ /** * 驗證器函式 * @typedef {(input: string | ValidationResult) => ValidationResult} Validator */ /** * 後處理函式 * @typedef {(validated: ValidationResult) => any} Processor */ /** * 問題配置物件 * @typedef {Object} QuestionConfig * @property {string} name - 用來當答案物件的 key * @property {string} prompt - 提示訊息 * @property {Validator} validator - 驗證器函式 * @property {Processor} [processor] - 選擇性的後處理函式 */ export {}; 標註驗證器// @ts-check /** * 確保輸入是整數字串 * @param {string} raw - 使用者原始輸入 * @returns {ValidationResult} 包含原始字串的驗證結果 * @throws {Error} 當輸入不是整數字串時拋出錯誤 */ export function ensureIntegerString(raw) { if (!/^-?\d+$/.test(raw)) { throw new Error('請輸入整數'); } return { value: raw }; } /** * 將字串轉換為整數 * @param {{ value: string }} validated - 前一個驗證器的結果 * @returns {ValidationResult} 包含數字的驗證結果 */ export function toInt(validated) { return { value: parseInt(validated.value, 10) }; } 在配置檔使用型別// q6/q6config.js // @ts-check /** * @type {import('../util/types.js').QuestionConfig[]} */ export const questionConfigs = [ { name: "evenInput", prompt: "請輸入一個大於4的偶數:", validator: composeValidators([ ensureIntegerString, toInt, (v) => isEvenNum(v, 4), ]), processor: (validated) => umleven(validated.value), }, ]; 除錯策略使用 console.log 追蹤執行路徑在關鍵位置加上標記:console.log('[DEBUG] 模組名: 狀態描述', 相關變數); 檢查 import 來源與 API 版本當遇到「明明呼叫了卻沒反應」:檢查 import 路徑(node:readline vs node:readline/promises)查官方文件確認 API 簽章確認有沒有混用不同版本的 API優先檢查組裝層單元測試過但整合卡住時:config 欄位名稱是否統一函式簽章是否一致閉包是否正確封裝依賴設計原則總結原則實作方式體現在程式碼SRP每個模組只負責一件事askWithValidator 只管 I/O,askOneRound 只管流程OCP新增功能不修改舊程式碼新增驗證規則只要加 validatorDIP高階模組依賴抽象askOneRound 依賴抽象函式,不依賴 readline封裝隱藏實作細節rl 只在少數模組可見參考資源MDN - 閉包ECMAScript 規範 - Abstract ClosuresNode.js Readline DocumentationJavaScript Closures and Partial ApplicationTypeScript JSDoc ReferenceVitest Testing Guide ## 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