線上課程觀課進度管理小工具開發日誌
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」。
身體狀態欠佳,輕量閱讀也做不到,就把影片當成背景知識,技術筆記抓重點、讓之後有力氣再練功就好。
整串是給「前端新手技能樹 #0-#4、#9、#10」用的隨手小抄。
Compiler(編譯器):把一種語言轉成另一種語言,例如 TypeScript 編成瀏覽器看得懂的 JavaScript,或 JSX 編成純 JS。
Bundler(打包工具):把一堆 JS / CSS / 圖片等模組分析依賴後打成幾個 bundle,例如 Webpack、Vite、Rollup,讓瀏覽器少下載、好快取。
CLI(命令列工具):用指令快速完成重複工作,如 npm init, vite create, git commit,本質就是「用參數呼叫程式的一種介面」。
實務情境小抄:
開新專案:用框架官方 CLI 一次生出基本檔案結構(例如 npm create vite@latest my-app)。
寫 TS / React:編譯器負責轉譯,Bundler 負責打包壓縮、切檔,瀏覽器只接收最後的 JS / CSS。
HTML:語意與結構,是「骨架」,負責定義頁面上有什麼元素,例如標題、段落、按鈕、表單。
CSS:長相與排版,是「外觀肌肉 / 皮膚」,控制顏色、大小、位置、字體、RWD 等視覺表現。
JS:行為與互動,是「大腦與神經」,負責處理事件(點擊、輸入)、更新畫面、跟後端 API 溝通。
實務情境:做一個「送出表單」按鈕
HTML:<button type="submit">送出</button> → 有一個可以點的按鈕。
CSS:把按鈕變成你想要的顏色、圓角、hover 效果。
JS:決定按下去要驗證什麼、傳去哪裡、成功時怎麼提示使用者。
建議:先用 HTML 把骨架打出來,再慢慢補 CSS、最後再加 JS 行為,避免一開始就糊在一起。
核心觀念:
Git:分散式版本控制系統,幫你記錄每一次修改,可以開分支、多線開發,壞掉可以「穿越時空」回到之前狀態。
GitHub:拿來放 Git repository 的雲端服務,搭配 issue / PR / 權限,變成大家協作的基地。
為什麼重要:
不用再有 final_v3_ok_really_final.html 這種檔名;用 commit 訊息記錄每一次變更。
團隊開發時,每個人開自己的 branch,測完再合併進 main,降低互相蓋掉程式碼的機率。
練習建議:
先用 Learn Git Branching 玩遊戲,把 commit、branch、checkout、merge、rebase 變成視覺化概念。
之後再回頭看類似《猴子都能懂的 Git 入門》這種文字教學,把名詞補齊。
想一個小功能時,可以用這三個問題來拆:
Who:誰在操作?對應到「哪個使用者 / 哪個 DOM 元素在觸發事件」。
例:使用者點擊「加入購物車」按鈕。
When:什麼時候要發生?對應到「事件類型或時機」。
例:按下按鈕(click)、輸入文字(input)、頁面載入完成(DOMContentLoaded)。
What:要做什麼?就是事件 handler 裡面的邏輯。
例:把商品 id 加進陣列、更新畫面上的數字、存到 localStorage。
100 行挑戰精神:
用原生 JS + HTML + CSS 寫一個你有興趣的小功能(倒數計時器、todo list、抽獎轉盤、打怪計算器)。
限制自己程式碼控制在大約 100 行內,逼自己做到:
拆小 function,命名清楚。
把 Who / When / What 寫成乾淨的事件綁定 +邏輯。
避免一次塞一大坨寫不完、看不懂的 code。
框架做的事:
React / Vue / Svelte… 提供一套共通模式:
怎麼拆元件、怎麼管理狀態、怎麼做路由。
減少你每天直接操作 DOM 的次數,讓 UI 狀態跟資料同步。
撰寫規範為什麼要先建立:
目標是「多人協作時,每個人寫出來的程式碼看起來像同一個人寫的」。
規範涵蓋:檔案命名、資料夾結構、JS / TS 寫法、註解風格、commit message 格式等等。
常見做法:
選好語言與框架(例如:TypeScript + React)。
設定 ESLint(airbnb 規範)、Prettier 自動排版。
配合 Git hook 或 CI,在 commit / PR 時自動檢查。
這樣未來你在任何框架(React / Vue / Next / Nuxt…)中,都可以沿用同一套「乾淨、可維護」的寫法習慣。
目標:把 Node.js CLI 的「使用者輸入+驗證」流程拆成多個職責單一的小模組,用 Promise / async / await 取代 while loop,讓控制流程線性、可讀性更好,也方便 TDD。驗證不通過視為「可預期錯誤」,在互動流程中處理;只有「整個 CLI 掛掉」才冒泡到 main。
原本的 CLI 驗證流程:在一個 async 函式裡開 readline,用 while (true) 反覆提問與驗證,輸入錯就 continue,正確才 break。雖然可以運作,但有幾個問題:
責任混在一起:
-「建立 / 關閉 CLI」
-「問問題」
-「驗證邏輯」
全部集中在同一個函式。
控制流程不好讀:
while 搭配 async/await 容易產生巢狀,錯誤處理分散。
不利 TDD / 重構:
難以單獨測試某一段邏輯(例如 validator),也難以隔離 readline I/O。
重構目標:
把「CLI 生命週期、提問流程、驗證邏輯」拆成多個責任單一的小模組,以 Promise / async / await 串接。
模組 | 責任 |
|---|---|
| 建立 readline 介面(stdin / stdout) |
| CLI 生命週期(init / close) |
| 單次提問( |
| 單欄位:反覆提問+單一 validator |
| 多個 validator → 一個 validator |
| 宣告欄位、提示、驗證器組合 |
| 一次跑完整個互動流程、回傳結果 |
| 真正的業務邏輯怎麼用這些輸入 |
[main]
│
│ (1) 呼叫 runCliQuestions
│ (2) catch 未預期錯誤
▼
[runCliQuestions]
│
├───▶ (1) 呼叫 [runCliSession]
│ │
│ ├───▶ 呼叫 [createRl] (建立 readline)
│ │
│ ├───▶ 執行傳入的 async callback (處理題目迴圈)
│ │ │
│ │ ▼
│ │ for loop (每一題 q of questionConfigs)
│ │ │
│ │ └───▶ 呼叫 [askWithValidator] (rl, prompt, validator)
│ │ │
│ │ ├───▶ 呼叫 [questionOnce] (rl, prompt)
│ │ │ │
│ │ │ └───▶ Promise wrapper around rl.question
│ │ │
│ │ └───▶ 呼叫 validator(raw)
│ │ │
│ │ └───▶ 實際上是呼叫 [composeValidators] 產生的總驗證器
│ │ │
│ │ ├───▶ [ensureIntegerString]
│ │ ├───▶ [toInt]
│ │ └───▶ [parseEvenNum] 等...
│ │
│ └───▶ finally: rl.close()
│
▼
回傳 { [name]: value } 結果給 main
-----------------------------------------------------------
資料結構層 (Config Layer):
[questionConfigs.js]
│
├───▶ 定義 questionConfigs 陣列
│ │
│ └───▶ { validator: [composeValidators]([v1, v2, v3]) }
│ │
│ ├───▶ [v1] (ensureIntegerString)
│ ├───▶ [v2] (toInt)
│ └───▶ [v3] (parseEvenNum)
│
└───▶ 被 [main] 或 [runCliQuestions] 使用 (作為輸入資料)
export function createRl() {
return readline.createInterface({ input, output });
}
核心概念:
將 readline/promises 的建立動作集中於一處,形成統一的工廠函式。
若未來想替換輸入來源(例如測試用 fake stream),或切換實作,只需修改此模組。
其它模組可以透過 spy/mock 取代這個函式,避免實際啟動 readline。
export async function runCliSession(run) {
const rl = createRl();
try {
return await run(rl);
} finally {
rl.close();
}
}
核心概念:「不論 run 成功或失敗,離開此函式前一定會關閉 rl。」
成功情境:
run(rl) 正常完成並回傳結果。
finally 中呼叫 rl.close(),CLI 乾淨收尾,游標狀態正常。
失敗情境:
run(rl) 執行過程丟出錯誤。
finally 仍會呼叫 rl.close(),避免 CLI 卡在中途。
錯誤繼續往外拋,交由 main 處理。
主程式只要:
runCliSession(async (rl) => {
// 在這裡放心寫 CLI 流程
}).catch((err) => {
console.error('未預期錯誤:', err);
});
此設計確保 readline.Interface 作為「需釋放資源」的物件,無論流程如何結束都能被妥善關閉。
export function questionOnce(rl, prompt) {
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
resolve(answer);
});
});
}
核心概念:
把 callback 型 rl.question(prompt, cb) 封裝成 Promise。
只負責「顯示一個 prompt,取得一行輸入字串」。
後續互動邏輯(例如重複提問)皆以此為基礎。
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);
// 不 return、不 throw → 迴圈繼續 → 再問一次
}
}
}
從使用者角度:
顯示問題。
輸入錯 → CLI 顯示錯誤訊息 → 自動再跳出同一題,讓你重新輸入。
輸入對 → 跳出這一題,回傳最後的值。
核心概念:
控制「反覆提問直到驗證通過」的流程封裝在這一層。
顯示 validator 拋出的錯誤訊息也在這一層處理。
主程式看不到「驗證錯誤」,只會看到「最後驗證成功的值」或「程式真的壞掉的錯誤」。
第三個參數 validator 可以是:
原子驗證器(單一小函式),或
經由 composeValidators 組合後的「總驗證器」。
export function composeValidators(validators) {
return (value) => {
let current = value;
for (const validate of validators) {
current = validate(current);
}
return current;
};
}
核心概念:
輸入:一組 validator 陣列,每一顆型別約為 (v) => v' | throws。
輸出:一個「總 validator」函式:
依陣列順序依次執行:
current = v1(value)
current = v2(current)
…
任一 validator 丟錯 → 後續 validator 不再執行,錯誤原封不動往外拋出。
目的:
讓「多步驟驗證」從程式碼結構(巢狀 if / 巢狀函式)抽象成「資料結構+通用組合器」。
在設定檔中以陣列呈現規則,例如:
[ensureIntegerString, toInt, parseNonNegativeInt, parseEvenNum]
可以一眼看出完整的驗證流程。
整數路線例子:
export function ensureIntegerString(input) {
const str = String(input).trim();
const isIntLiteral = /^[+-]?[0-9]+$/.test(str);
if (!isIntLiteral) {
throw new Error('輸入錯誤,請輸入整數');
}
return str; // 整數字串(已去空白)
}
export function toInt(str) {
return parseInt(str, 10);
}
export function parseNonNegativeInt(num) {
if (!Number.isInteger(num)) {
throw new Error('輸入錯誤,請輸入整數');
}
if (num < 0) {
throw new Error('輸入錯誤,請輸入0或正整數');
}
return num;
}
小數路線例子:
export function ensureDecimalString(input) {
const str = String(input).trim();
const isDecimal =
/^[+-]?(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)$/.test(str);
if (!isDecimal) {
throw new Error('輸入錯誤,請輸入數字');
}
return str;
}
export function toFloat(str) {
return Number(str);
}
核心概念:
每一個 validator 都是「單一職責」:
ensureIntegerString:檢查是否為整數字面量字串。
toInt:純數字字串轉為整數。
parseNonNegativeInt:檢查是否為非負整數值。
parseEvenNum:檢查是否為大於等於某最小值的偶數。
ensureDecimalString:檢查是否為十進位數字字串(可含小數)。
toFloat:數字字串轉為 number。
任一 validator 不滿足條件就丟出 Error,由外層互動邏輯統一處理訊息與重試。
import { composeValidators } from './composeValidators.js';
import {
ensureIntegerString,
toInt,
parseNonNegativeInt,
} from './validators.js';
export const questionConfigs = [
{
name: 'itemCount',
prompt: '請輸入要購買的數量(0 或正整數):',
validator: composeValidators([
ensureIntegerString,
toInt,
parseNonNegativeInt,
]),
},
];
核心概念:
把「每一題要問什麼、要怎麼驗證」變成一個純資料結構:
題目名稱 name:在結果物件中的 key。
prompt: CLI 顯示給使用者的文字。
validator:一條已經 compose 好的驗證 pipeline。
這一層不做任何 IO,只負責宣告「問什麼題+怎麼驗證」。
import { runCliSession } from './runCliSession.js';
import { askWithValidator } from './askWithValidator.js';
export async function runCliQuestions(questionConfigs) {
return runCliSession(async (rl) => {
const result = {};
for (const q of questionConfigs) {
const value = await askWithValidator(rl, q.prompt, q.validator);
result[q.name] = value;
}
return result;
});
}
核心概念:
對外代表「一整段 CLI 問答流程」。
內部:
使用 runCliSession 管理 rl 的建立與關閉。
用 for...of 明確表示「依序處理每一題」。
每一題透過 askWithValidator 完成「提問+重試+驗證」。
最後組成結果物件 { [name]: value } 回傳。
main 只處理兩件事:
呼叫 runCliQuestions(configs) 取得完整的回答物件。
統一處理「未預期錯誤」(例如程式 bug、IO 例外)。
import { questionConfigs } from './q6config.js';
import { runCliQuestions } from '../util/runCliQuestions.js';
async function main() {
const answers = await runCliQuestions(questionConfigs);
console.log('第六題回答:', answers.evenNum);
}
main().catch((err) => {
console.error('程式發生未預期錯誤:', err);
});
驗證錯誤:由 askWithValidator 顯示錯誤並重問。
系統錯誤:冒泡到 main().catch(...),集中處理。
在 TDD 過程中,希望針對上述每個模組「單獨」驗證行為:
不希望測試真的開啟 readline、等待人工輸入。
希望控制輸入序列(例如第一次輸入錯、第二次輸入對)。
希望確認:
runCliSession 是否有呼叫 createRl 和 rl.close。
runCliQuestions 是否有依序對每題呼叫 askWithValidator。
這些需求可以透過 mock function 與 spy 建立「測試替身 test double」達成。
mock:用假的實作取代真實依賴,並記錄呼叫資訊。
const fn = vi.fn(); // 空函式,只記錄呼叫
const fn2 = vi.fn((x) => x*2); // 有行為的 mock function
fn.mock.calls 是一個二維陣列:
fn.mock.calls[0]:第一次呼叫時的「參數陣列」。
fn.mock.calls[0][0]:第一次呼叫的第 0 個參數。
建立 fake readline:
function createFakeRl(answers) {
let callCount = 0;
return {
question: vi.fn((prompt, callback) => {
const answer = answers[callCount];
callCount += 1;
setTimeout(() => callback(answer), 0);
}),
close: vi.fn(),
};
}
用途:
question:
模擬非同步提問行為(透過 setTimeout)。
可檢查被呼叫次數與 prompt。
close:
檢查整個流程是否在最後確實關閉 interface。
spy:在既有物件的方法外掛監聽器,可以選擇是否改變行為。
// util/createRl.js
export function createRl() { /* ... */ }
測試中:
import * as createRlModule from './createRl.js';
import { vi } from 'vitest';
const fakeRl = { close: vi.fn() };
const createRlSpy = vi
.spyOn(createRlModule, 'createRl')
.mockReturnValue(fakeRl);
效果:
測試執行期間呼叫 createRlModule.createRl():
不會使用真實實作,而是回傳 fakeRl。
可以透過 expect(createRlSpy).toHaveBeenCalledTimes(1) 驗證是否有建立 rl。
結合 expect(fakeRl.close).toHaveBeenCalledTimes(1) 可驗證 runCliSession 是否在 finally 中確實關閉資源。
mock(vi.fn / vi.mock):
常用於自建「假函式/假物件」,完全不依賴原本的實作。
範例:
createFakeRl 中的 question、close。
spy(vi.spyOn):
以「既有物件的方法」為目標加上監聽。
預設只觀察呼叫紀錄;可額外用 mockReturnValue 改變回傳值。
範例:
對 createRlModule.createRl 掛 spy,用於測試 runCliSession 是否有呼叫它,以及是否正確關閉 rl。
實務 TDD 流程建議:
先使用 vi.fn() 建立 mock function,熟悉 mock.calls 的結構與常用 matcher。
再使用 vi.spyOn() 對已有模組方法加上監聽,學習如何在「只觀察」與「觀察+改行為」之間做取捨。
透過這些工具,可以逐層以 TDD 驗證:
單一 validator 的行為(純函式)。
validator pipeline 的行為(composeValidators)。
單題互動與重試行為(askWithValidator)。
多題 CLI 流程(runCliQuestions)。
CLI 生命週期管理(runCliSession)。
在重構或調整邏輯時,只要觀察測試是否維持通過,即可確認「核心概念」與使用者互動行為仍然一致。
// cli/createRl.js
import readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
export function createRl() {
return readline.createInterface({ input, output });
}
// cli/runCliSession.js
import { createRl } from './createRl.js';
export async function runCliSession(run) {
const rl = createRl();
try {
return await run(rl);
} finally {
rl.close();
}
}
// cli/questionOnce.js
export function questionOnce(rl, prompt) {
return rl.question(prompt);
}
// cli/askWithValidator.js
import { questionOnce } from './questionOnce.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);
}
}
}
// cli/composeValidators.js
export function composeValidators(validators) {
return (value) => {
let current = value;
for (const validate of validators) {
current = validate(current);
}
return current;
};
}
// cli/validators.js
export function ensureIntegerString(input) {
const str = String(input).trim();
const isIntLiteral = /^[+-]?[0-9]+$/.test(str);
if (!isIntLiteral) {
throw new Error('輸入錯誤,請輸入整數');
}
return str;
}
export function toInt(str) {
return parseInt(str, 10);
}
export function parseNonNegativeInt(num) {
if (!Number.isInteger(num)) {
throw new Error('輸入錯誤,請輸入整數');
}
if (num < 0) {
throw new Error('輸入錯誤,請輸入 0 或正整數');
}
return num;
}
// cli/questionConfigs.js
import { composeValidators } from './composeValidators.js';
import {
ensureIntegerString,
toInt,
parseNonNegativeInt,
} from './validators.js';
export const questionConfigs = [
{
name: 'itemCount',
prompt: '請輸入要購買的數量(0 或正整數):',
validator: composeValidators([
ensureIntegerString,
toInt,
parseNonNegativeInt,
]),
},
];
// cli/runCliQuestions.js
import { runCliSession } from './runCliSession.js';
import { askWithValidator } from './askWithValidator.js';
export async function runCliQuestions(configs) {
return runCliSession(async (rl) => {
const result = {};
for (const q of configs) {
const value = await askWithValidator(rl, q.prompt, q.validator);
result[q.name] = value;
}
return result;
});
}
// cli/main.js
import { questionConfigs } from './questionConfigs.js';
import { runCliQuestions } from './runCliQuestions.js';
async function main() {
const answers = await runCliQuestions(questionConfigs);
console.log('輸入結果:', answers);
}
main().catch((err) => {
console.error('程式發生未預期錯誤:', err);
});
身體狀態欠佳,輕量閱讀也做不到,就把影片當成背景知識,技術筆記抓重點、讓之後有力氣再練功就好。
整串是給「前端新手技能樹 #0-#4、#9、#10」用的隨手小抄。
Compiler(編譯器):把一種語言轉成另一種語言,例如 TypeScript 編成瀏覽器看得懂的 JavaScript,或 JSX 編成純 JS。
Bundler(打包工具):把一堆 JS / CSS / 圖片等模組分析依賴後打成幾個 bundle,例如 Webpack、Vite、Rollup,讓瀏覽器少下載、好快取。
CLI(命令列工具):用指令快速完成重複工作,如 npm init, vite create, git commit,本質就是「用參數呼叫程式的一種介面」。
實務情境小抄:
開新專案:用框架官方 CLI 一次生出基本檔案結構(例如 npm create vite@latest my-app)。
寫 TS / React:編譯器負責轉譯,Bundler 負責打包壓縮、切檔,瀏覽器只接收最後的 JS / CSS。
HTML:語意與結構,是「骨架」,負責定義頁面上有什麼元素,例如標題、段落、按鈕、表單。
CSS:長相與排版,是「外觀肌肉 / 皮膚」,控制顏色、大小、位置、字體、RWD 等視覺表現。
JS:行為與互動,是「大腦與神經」,負責處理事件(點擊、輸入)、更新畫面、跟後端 API 溝通。
實務情境:做一個「送出表單」按鈕
HTML:<button type="submit">送出</button> → 有一個可以點的按鈕。
CSS:把按鈕變成你想要的顏色、圓角、hover 效果。
JS:決定按下去要驗證什麼、傳去哪裡、成功時怎麼提示使用者。
建議:先用 HTML 把骨架打出來,再慢慢補 CSS、最後再加 JS 行為,避免一開始就糊在一起。
核心觀念:
Git:分散式版本控制系統,幫你記錄每一次修改,可以開分支、多線開發,壞掉可以「穿越時空」回到之前狀態。
GitHub:拿來放 Git repository 的雲端服務,搭配 issue / PR / 權限,變成大家協作的基地。
為什麼重要:
不用再有 final_v3_ok_really_final.html 這種檔名;用 commit 訊息記錄每一次變更。
團隊開發時,每個人開自己的 branch,測完再合併進 main,降低互相蓋掉程式碼的機率。
練習建議:
先用 Learn Git Branching 玩遊戲,把 commit、branch、checkout、merge、rebase 變成視覺化概念。
之後再回頭看類似《猴子都能懂的 Git 入門》這種文字教學,把名詞補齊。
想一個小功能時,可以用這三個問題來拆:
Who:誰在操作?對應到「哪個使用者 / 哪個 DOM 元素在觸發事件」。
例:使用者點擊「加入購物車」按鈕。
When:什麼時候要發生?對應到「事件類型或時機」。
例:按下按鈕(click)、輸入文字(input)、頁面載入完成(DOMContentLoaded)。
What:要做什麼?就是事件 handler 裡面的邏輯。
例:把商品 id 加進陣列、更新畫面上的數字、存到 localStorage。
100 行挑戰精神:
用原生 JS + HTML + CSS 寫一個你有興趣的小功能(倒數計時器、todo list、抽獎轉盤、打怪計算器)。
限制自己程式碼控制在大約 100 行內,逼自己做到:
拆小 function,命名清楚。
把 Who / When / What 寫成乾淨的事件綁定 +邏輯。
避免一次塞一大坨寫不完、看不懂的 code。
框架做的事:
React / Vue / Svelte… 提供一套共通模式:
怎麼拆元件、怎麼管理狀態、怎麼做路由。
減少你每天直接操作 DOM 的次數,讓 UI 狀態跟資料同步。
撰寫規範為什麼要先建立:
目標是「多人協作時,每個人寫出來的程式碼看起來像同一個人寫的」。
規範涵蓋:檔案命名、資料夾結構、JS / TS 寫法、註解風格、commit message 格式等等。
常見做法:
選好語言與框架(例如:TypeScript + React)。
設定 ESLint(airbnb 規範)、Prettier 自動排版。
配合 Git hook 或 CI,在 commit / PR 時自動檢查。
這樣未來你在任何框架(React / Vue / Next / Nuxt…)中,都可以沿用同一套「乾淨、可維護」的寫法習慣。
目標:把 Node.js CLI 的「使用者輸入+驗證」流程拆成多個職責單一的小模組,用 Promise / async / await 取代 while loop,讓控制流程線性、可讀性更好,也方便 TDD。驗證不通過視為「可預期錯誤」,在互動流程中處理;只有「整個 CLI 掛掉」才冒泡到 main。
原本的 CLI 驗證流程:在一個 async 函式裡開 readline,用 while (true) 反覆提問與驗證,輸入錯就 continue,正確才 break。雖然可以運作,但有幾個問題:
責任混在一起:
-「建立 / 關閉 CLI」
-「問問題」
-「驗證邏輯」
全部集中在同一個函式。
控制流程不好讀:
while 搭配 async/await 容易產生巢狀,錯誤處理分散。
不利 TDD / 重構:
難以單獨測試某一段邏輯(例如 validator),也難以隔離 readline I/O。
重構目標:
把「CLI 生命週期、提問流程、驗證邏輯」拆成多個責任單一的小模組,以 Promise / async / await 串接。
模組 | 責任 |
|---|---|
| 建立 readline 介面(stdin / stdout) |
| CLI 生命週期(init / close) |
| 單次提問( |
| 單欄位:反覆提問+單一 validator |
| 多個 validator → 一個 validator |
| 宣告欄位、提示、驗證器組合 |
| 一次跑完整個互動流程、回傳結果 |
| 真正的業務邏輯怎麼用這些輸入 |
[main]
│
│ (1) 呼叫 runCliQuestions
│ (2) catch 未預期錯誤
▼
[runCliQuestions]
│
├───▶ (1) 呼叫 [runCliSession]
│ │
│ ├───▶ 呼叫 [createRl] (建立 readline)
│ │
│ ├───▶ 執行傳入的 async callback (處理題目迴圈)
│ │ │
│ │ ▼
│ │ for loop (每一題 q of questionConfigs)
│ │ │
│ │ └───▶ 呼叫 [askWithValidator] (rl, prompt, validator)
│ │ │
│ │ ├───▶ 呼叫 [questionOnce] (rl, prompt)
│ │ │ │
│ │ │ └───▶ Promise wrapper around rl.question
│ │ │
│ │ └───▶ 呼叫 validator(raw)
│ │ │
│ │ └───▶ 實際上是呼叫 [composeValidators] 產生的總驗證器
│ │ │
│ │ ├───▶ [ensureIntegerString]
│ │ ├───▶ [toInt]
│ │ └───▶ [parseEvenNum] 等...
│ │
│ └───▶ finally: rl.close()
│
▼
回傳 { [name]: value } 結果給 main
-----------------------------------------------------------
資料結構層 (Config Layer):
[questionConfigs.js]
│
├───▶ 定義 questionConfigs 陣列
│ │
│ └───▶ { validator: [composeValidators]([v1, v2, v3]) }
│ │
│ ├───▶ [v1] (ensureIntegerString)
│ ├───▶ [v2] (toInt)
│ └───▶ [v3] (parseEvenNum)
│
└───▶ 被 [main] 或 [runCliQuestions] 使用 (作為輸入資料)
export function createRl() {
return readline.createInterface({ input, output });
}
核心概念:
將 readline/promises 的建立動作集中於一處,形成統一的工廠函式。
若未來想替換輸入來源(例如測試用 fake stream),或切換實作,只需修改此模組。
其它模組可以透過 spy/mock 取代這個函式,避免實際啟動 readline。
export async function runCliSession(run) {
const rl = createRl();
try {
return await run(rl);
} finally {
rl.close();
}
}
核心概念:「不論 run 成功或失敗,離開此函式前一定會關閉 rl。」
成功情境:
run(rl) 正常完成並回傳結果。
finally 中呼叫 rl.close(),CLI 乾淨收尾,游標狀態正常。
失敗情境:
run(rl) 執行過程丟出錯誤。
finally 仍會呼叫 rl.close(),避免 CLI 卡在中途。
錯誤繼續往外拋,交由 main 處理。
主程式只要:
runCliSession(async (rl) => {
// 在這裡放心寫 CLI 流程
}).catch((err) => {
console.error('未預期錯誤:', err);
});
此設計確保 readline.Interface 作為「需釋放資源」的物件,無論流程如何結束都能被妥善關閉。
export function questionOnce(rl, prompt) {
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
resolve(answer);
});
});
}
核心概念:
把 callback 型 rl.question(prompt, cb) 封裝成 Promise。
只負責「顯示一個 prompt,取得一行輸入字串」。
後續互動邏輯(例如重複提問)皆以此為基礎。
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);
// 不 return、不 throw → 迴圈繼續 → 再問一次
}
}
}
從使用者角度:
顯示問題。
輸入錯 → CLI 顯示錯誤訊息 → 自動再跳出同一題,讓你重新輸入。
輸入對 → 跳出這一題,回傳最後的值。
核心概念:
控制「反覆提問直到驗證通過」的流程封裝在這一層。
顯示 validator 拋出的錯誤訊息也在這一層處理。
主程式看不到「驗證錯誤」,只會看到「最後驗證成功的值」或「程式真的壞掉的錯誤」。
第三個參數 validator 可以是:
原子驗證器(單一小函式),或
經由 composeValidators 組合後的「總驗證器」。
export function composeValidators(validators) {
return (value) => {
let current = value;
for (const validate of validators) {
current = validate(current);
}
return current;
};
}
核心概念:
輸入:一組 validator 陣列,每一顆型別約為 (v) => v' | throws。
輸出:一個「總 validator」函式:
依陣列順序依次執行:
current = v1(value)
current = v2(current)
…
任一 validator 丟錯 → 後續 validator 不再執行,錯誤原封不動往外拋出。
目的:
讓「多步驟驗證」從程式碼結構(巢狀 if / 巢狀函式)抽象成「資料結構+通用組合器」。
在設定檔中以陣列呈現規則,例如:
[ensureIntegerString, toInt, parseNonNegativeInt, parseEvenNum]
可以一眼看出完整的驗證流程。
整數路線例子:
export function ensureIntegerString(input) {
const str = String(input).trim();
const isIntLiteral = /^[+-]?[0-9]+$/.test(str);
if (!isIntLiteral) {
throw new Error('輸入錯誤,請輸入整數');
}
return str; // 整數字串(已去空白)
}
export function toInt(str) {
return parseInt(str, 10);
}
export function parseNonNegativeInt(num) {
if (!Number.isInteger(num)) {
throw new Error('輸入錯誤,請輸入整數');
}
if (num < 0) {
throw new Error('輸入錯誤,請輸入0或正整數');
}
return num;
}
小數路線例子:
export function ensureDecimalString(input) {
const str = String(input).trim();
const isDecimal =
/^[+-]?(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)$/.test(str);
if (!isDecimal) {
throw new Error('輸入錯誤,請輸入數字');
}
return str;
}
export function toFloat(str) {
return Number(str);
}
核心概念:
每一個 validator 都是「單一職責」:
ensureIntegerString:檢查是否為整數字面量字串。
toInt:純數字字串轉為整數。
parseNonNegativeInt:檢查是否為非負整數值。
parseEvenNum:檢查是否為大於等於某最小值的偶數。
ensureDecimalString:檢查是否為十進位數字字串(可含小數)。
toFloat:數字字串轉為 number。
任一 validator 不滿足條件就丟出 Error,由外層互動邏輯統一處理訊息與重試。
import { composeValidators } from './composeValidators.js';
import {
ensureIntegerString,
toInt,
parseNonNegativeInt,
} from './validators.js';
export const questionConfigs = [
{
name: 'itemCount',
prompt: '請輸入要購買的數量(0 或正整數):',
validator: composeValidators([
ensureIntegerString,
toInt,
parseNonNegativeInt,
]),
},
];
核心概念:
把「每一題要問什麼、要怎麼驗證」變成一個純資料結構:
題目名稱 name:在結果物件中的 key。
prompt: CLI 顯示給使用者的文字。
validator:一條已經 compose 好的驗證 pipeline。
這一層不做任何 IO,只負責宣告「問什麼題+怎麼驗證」。
import { runCliSession } from './runCliSession.js';
import { askWithValidator } from './askWithValidator.js';
export async function runCliQuestions(questionConfigs) {
return runCliSession(async (rl) => {
const result = {};
for (const q of questionConfigs) {
const value = await askWithValidator(rl, q.prompt, q.validator);
result[q.name] = value;
}
return result;
});
}
核心概念:
對外代表「一整段 CLI 問答流程」。
內部:
使用 runCliSession 管理 rl 的建立與關閉。
用 for...of 明確表示「依序處理每一題」。
每一題透過 askWithValidator 完成「提問+重試+驗證」。
最後組成結果物件 { [name]: value } 回傳。
main 只處理兩件事:
呼叫 runCliQuestions(configs) 取得完整的回答物件。
統一處理「未預期錯誤」(例如程式 bug、IO 例外)。
import { questionConfigs } from './q6config.js';
import { runCliQuestions } from '../util/runCliQuestions.js';
async function main() {
const answers = await runCliQuestions(questionConfigs);
console.log('第六題回答:', answers.evenNum);
}
main().catch((err) => {
console.error('程式發生未預期錯誤:', err);
});
驗證錯誤:由 askWithValidator 顯示錯誤並重問。
系統錯誤:冒泡到 main().catch(...),集中處理。
在 TDD 過程中,希望針對上述每個模組「單獨」驗證行為:
不希望測試真的開啟 readline、等待人工輸入。
希望控制輸入序列(例如第一次輸入錯、第二次輸入對)。
希望確認:
runCliSession 是否有呼叫 createRl 和 rl.close。
runCliQuestions 是否有依序對每題呼叫 askWithValidator。
這些需求可以透過 mock function 與 spy 建立「測試替身 test double」達成。
mock:用假的實作取代真實依賴,並記錄呼叫資訊。
const fn = vi.fn(); // 空函式,只記錄呼叫
const fn2 = vi.fn((x) => x*2); // 有行為的 mock function
fn.mock.calls 是一個二維陣列:
fn.mock.calls[0]:第一次呼叫時的「參數陣列」。
fn.mock.calls[0][0]:第一次呼叫的第 0 個參數。
建立 fake readline:
function createFakeRl(answers) {
let callCount = 0;
return {
question: vi.fn((prompt, callback) => {
const answer = answers[callCount];
callCount += 1;
setTimeout(() => callback(answer), 0);
}),
close: vi.fn(),
};
}
用途:
question:
模擬非同步提問行為(透過 setTimeout)。
可檢查被呼叫次數與 prompt。
close:
檢查整個流程是否在最後確實關閉 interface。
spy:在既有物件的方法外掛監聽器,可以選擇是否改變行為。
// util/createRl.js
export function createRl() { /* ... */ }
測試中:
import * as createRlModule from './createRl.js';
import { vi } from 'vitest';
const fakeRl = { close: vi.fn() };
const createRlSpy = vi
.spyOn(createRlModule, 'createRl')
.mockReturnValue(fakeRl);
效果:
測試執行期間呼叫 createRlModule.createRl():
不會使用真實實作,而是回傳 fakeRl。
可以透過 expect(createRlSpy).toHaveBeenCalledTimes(1) 驗證是否有建立 rl。
結合 expect(fakeRl.close).toHaveBeenCalledTimes(1) 可驗證 runCliSession 是否在 finally 中確實關閉資源。
mock(vi.fn / vi.mock):
常用於自建「假函式/假物件」,完全不依賴原本的實作。
範例:
createFakeRl 中的 question、close。
spy(vi.spyOn):
以「既有物件的方法」為目標加上監聽。
預設只觀察呼叫紀錄;可額外用 mockReturnValue 改變回傳值。
範例:
對 createRlModule.createRl 掛 spy,用於測試 runCliSession 是否有呼叫它,以及是否正確關閉 rl。
實務 TDD 流程建議:
先使用 vi.fn() 建立 mock function,熟悉 mock.calls 的結構與常用 matcher。
再使用 vi.spyOn() 對已有模組方法加上監聽,學習如何在「只觀察」與「觀察+改行為」之間做取捨。
透過這些工具,可以逐層以 TDD 驗證:
單一 validator 的行為(純函式)。
validator pipeline 的行為(composeValidators)。
單題互動與重試行為(askWithValidator)。
多題 CLI 流程(runCliQuestions)。
CLI 生命週期管理(runCliSession)。
在重構或調整邏輯時,只要觀察測試是否維持通過,即可確認「核心概念」與使用者互動行為仍然一致。
// cli/createRl.js
import readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
export function createRl() {
return readline.createInterface({ input, output });
}
// cli/runCliSession.js
import { createRl } from './createRl.js';
export async function runCliSession(run) {
const rl = createRl();
try {
return await run(rl);
} finally {
rl.close();
}
}
// cli/questionOnce.js
export function questionOnce(rl, prompt) {
return rl.question(prompt);
}
// cli/askWithValidator.js
import { questionOnce } from './questionOnce.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);
}
}
}
// cli/composeValidators.js
export function composeValidators(validators) {
return (value) => {
let current = value;
for (const validate of validators) {
current = validate(current);
}
return current;
};
}
// cli/validators.js
export function ensureIntegerString(input) {
const str = String(input).trim();
const isIntLiteral = /^[+-]?[0-9]+$/.test(str);
if (!isIntLiteral) {
throw new Error('輸入錯誤,請輸入整數');
}
return str;
}
export function toInt(str) {
return parseInt(str, 10);
}
export function parseNonNegativeInt(num) {
if (!Number.isInteger(num)) {
throw new Error('輸入錯誤,請輸入整數');
}
if (num < 0) {
throw new Error('輸入錯誤,請輸入 0 或正整數');
}
return num;
}
// cli/questionConfigs.js
import { composeValidators } from './composeValidators.js';
import {
ensureIntegerString,
toInt,
parseNonNegativeInt,
} from './validators.js';
export const questionConfigs = [
{
name: 'itemCount',
prompt: '請輸入要購買的數量(0 或正整數):',
validator: composeValidators([
ensureIntegerString,
toInt,
parseNonNegativeInt,
]),
},
];
// cli/runCliQuestions.js
import { runCliSession } from './runCliSession.js';
import { askWithValidator } from './askWithValidator.js';
export async function runCliQuestions(configs) {
return runCliSession(async (rl) => {
const result = {};
for (const q of configs) {
const value = await askWithValidator(rl, q.prompt, q.validator);
result[q.name] = value;
}
return result;
});
}
// cli/main.js
import { questionConfigs } from './questionConfigs.js';
import { runCliQuestions } from './runCliQuestions.js';
async function main() {
const answers = await runCliQuestions(questionConfigs);
console.log('輸入結果:', answers);
}
main().catch((err) => {
console.error('程式發生未預期錯誤:', err);
});
Share Dialog
Share Dialog
No comments yet