# Day 31：CLI 驗證流程實戰 - 用閉包實作部分套用與 API 封裝

By [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake) · 2026-01-05

---

專案背景
----

這是一個 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

實際的 API

askOneRound

`ask(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：傳遞簡化後的 API**

    const 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.log

`askOneRound`

遍歷問題清單，收集答案

只知道「有個函式可以問問題」

`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：使用 `bind`

    export 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<Object>} 答案物件
     */
    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<Array>} 每輪答案的陣列
     */
    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<string>} 使用者輸入的原始字串
     */
    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<any>} 驗證通過的值
     */
    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<Function>} 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/promises`

`await rl.question()`

直接 await

`node:readline`

`rl.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 版本

當遇到「明明呼叫了卻沒反應」：

1.  檢查 import 路徑（`node:readline` vs `node:readline/promises`）
    
2.  查官方文件確認 API 簽章
    
3.  確認有沒有混用不同版本的 API
    

### 優先檢查組裝層

單元測試過但整合卡住時：

1.  config 欄位名稱是否統一
    
2.  函式簽章是否一致
    
3.  閉包是否正確封裝依賴
    

設計原則總結
------

原則

實作方式

體現在程式碼

SRP

每個模組只負責一件事

askWithValidator 只管 I/O，askOneRound 只管流程

OCP

新增功能不修改舊程式碼

新增驗證規則只要加 validator

DIP

高階模組依賴抽象

askOneRound 依賴抽象函式，不依賴 readline

封裝

隱藏實作細節

rl 只在少數模組可見

參考資源
----

*   [MDN - 閉包](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Closures)
    
*   [ECMAScript 規範 - Abstract Closures](https://tc39.es/ecma262/)
    
*   [Node.js Readline Documentation](https://nodejs.org/api/readline.html)
    
*   [JavaScript Closures and Partial Application](https://kyleshevlin.com/just-enough-fp-partial-application/)
    
*   [TypeScript JSDoc Reference](https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html)
    
*   [Vitest Testing Guide](https://vitest.dev/guide/)

---

*Originally published on [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/day-31)*
