線上課程觀課進度管理小工具開發日誌
Day 6:JavaScript 程式碼執行排序:遞迴函數、Call Stack、Task Queue
Call Stack;遞迴函數與 call stack 的關係;Task Queue(非同步的概念再釐清)
Day 9:「修煉圈圈 Practice Rings」Web app 開發日誌 - 2025 六角 AI Vibe Coding 體驗營期末大魔王作業
這份筆記旨在記錄 2025 六角 Vibe Coding 體驗營每日作業 Day 21 的期末專案成果,從一個簡單的個人痛點出發,透過與 AI(在我這個情境中是 Perplexity)協作,在一天內完成了一個包含前後端的全端網頁小工具:「修煉圈圈 Practice Rings」。
<100 subscribers
線上課程觀課進度管理小工具開發日誌
Day 6:JavaScript 程式碼執行排序:遞迴函數、Call Stack、Task Queue
Call Stack;遞迴函數與 call stack 的關係;Task Queue(非同步的概念再釐清)
Day 9:「修煉圈圈 Practice Rings」Web app 開發日誌 - 2025 六角 AI Vibe Coding 體驗營期末大魔王作業
這份筆記旨在記錄 2025 六角 Vibe Coding 體驗營每日作業 Day 21 的期末專案成果,從一個簡單的個人痛點出發,透過與 AI(在我這個情境中是 Perplexity)協作,在一天內完成了一個包含前後端的全端網頁小工具:「修煉圈圈 Practice Rings」。
這是一個 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:單一驗證職責 |
// 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 |
|---|---|---|
askOneRound |
|
|
如果直接呼叫,會導致職責污染:
// ❌ 錯誤: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):高階模組依賴低階細節
定義:固定一個函式的部分參數,回傳一個新函式只接受剩餘參數。
通用範例:
// 原始函式:三個參數
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
在 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)│
└─────────────────────────────────────────────────┘
模組 | 職責 | 依賴細節 |
|---|---|---|
| 操作 readline,處理重試邏輯 | 知道 rl、console.log |
| 遍歷問題清單,收集答案 | 只知道「有個函式可以問問題」 |
| 組裝依賴,管理多輪問答 | 知道 rl,但不直接操作 |
高階模組不依賴低階模組的具體實作,雙方都依賴抽象:
┌─────────────────────────────────────────┐
│ 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 },
});
});
});
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 |
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;
});
}
評估:
面向 | 優點 | 缺點 |
|---|---|---|
簡潔性 | 一行搞定 | 對初學者較難理解 |
功能 | 等價於閉包版本 | - |
語意 | 明確表達「綁定參數」 | - |
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;
});
}
// 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 };
};
}
輸入後遊標卡住,無法正常結束。
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 | 適合的寫法 |
|---|---|---|
|
| 直接 await |
|
| 傳 callback 或自己包成 Promise |
混用 | callback 不會被觸發 | 必須統一 |
答案物件的 key 變成 undefined。
askOneRound 使用 config.key,但 config 只有 name 欄位:
// ❌ 錯誤
answers[config.key] = answer;
// config 實際結構
{
name: "evenInput", // 只有 name
prompt: "請輸入偶數",
validator: fn
}
統一使用 config.name:
// ✅ 正確
answers[config.name] = answer;
遞迴函式導致堆疊溢位 (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;
});
}
// 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('[DEBUG] 模組名: 狀態描述', 相關變數);
當遇到「明明呼叫了卻沒反應」:
檢查 import 路徑(node:readline vs node:readline/promises)
查官方文件確認 API 簽章
確認有沒有混用不同版本的 API
單元測試過但整合卡住時:
config 欄位名稱是否統一
函式簽章是否一致
閉包是否正確封裝依賴
原則 | 實作方式 | 體現在程式碼 |
|---|---|---|
SRP | 每個模組只負責一件事 | askWithValidator 只管 I/O,askOneRound 只管流程 |
OCP | 新增功能不修改舊程式碼 | 新增驗證規則只要加 validator |
DIP | 高階模組依賴抽象 | askOneRound 依賴抽象函式,不依賴 readline |
封裝 | 隱藏實作細節 | rl 只在少數模組可見 |
這是一個 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:單一驗證職責 |
// 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 |
|---|---|---|
askOneRound |
|
|
如果直接呼叫,會導致職責污染:
// ❌ 錯誤: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):高階模組依賴低階細節
定義:固定一個函式的部分參數,回傳一個新函式只接受剩餘參數。
通用範例:
// 原始函式:三個參數
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
在 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)│
└─────────────────────────────────────────────────┘
模組 | 職責 | 依賴細節 |
|---|---|---|
| 操作 readline,處理重試邏輯 | 知道 rl、console.log |
| 遍歷問題清單,收集答案 | 只知道「有個函式可以問問題」 |
| 組裝依賴,管理多輪問答 | 知道 rl,但不直接操作 |
高階模組不依賴低階模組的具體實作,雙方都依賴抽象:
┌─────────────────────────────────────────┐
│ 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 },
});
});
});
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 |
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;
});
}
評估:
面向 | 優點 | 缺點 |
|---|---|---|
簡潔性 | 一行搞定 | 對初學者較難理解 |
功能 | 等價於閉包版本 | - |
語意 | 明確表達「綁定參數」 | - |
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;
});
}
// 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 };
};
}
輸入後遊標卡住,無法正常結束。
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 | 適合的寫法 |
|---|---|---|
|
| 直接 await |
|
| 傳 callback 或自己包成 Promise |
混用 | callback 不會被觸發 | 必須統一 |
答案物件的 key 變成 undefined。
askOneRound 使用 config.key,但 config 只有 name 欄位:
// ❌ 錯誤
answers[config.key] = answer;
// config 實際結構
{
name: "evenInput", // 只有 name
prompt: "請輸入偶數",
validator: fn
}
統一使用 config.name:
// ✅ 正確
answers[config.name] = answer;
遞迴函式導致堆疊溢位 (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;
});
}
// 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('[DEBUG] 模組名: 狀態描述', 相關變數);
當遇到「明明呼叫了卻沒反應」:
檢查 import 路徑(node:readline vs node:readline/promises)
查官方文件確認 API 簽章
確認有沒有混用不同版本的 API
單元測試過但整合卡住時:
config 欄位名稱是否統一
函式簽章是否一致
閉包是否正確封裝依賴
原則 | 實作方式 | 體現在程式碼 |
|---|---|---|
SRP | 每個模組只負責一件事 | askWithValidator 只管 I/O,askOneRound 只管流程 |
OCP | 新增功能不修改舊程式碼 | 新增驗證規則只要加 validator |
DIP | 高階模組依賴抽象 | askOneRound 依賴抽象函式,不依賴 readline |
封裝 | 隱藏實作細節 | rl 只在少數模組可見 |
Share Dialog
Share Dialog
No comments yet