# Day 20-21:前端新手技能樹;驗證器流程模組化;Vitest - vi 的 Mock/ Spy 方法 **Published by:** [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/) **Published on:** 2025-12-18 **URL:** https://paragraph.com/@gcake/day-20-21 ## Content 前端新手技能樹 課程影片 #0-#4, #9, #10身體狀態欠佳,輕量閱讀也做不到,就把影片當成背景知識,技術筆記抓重點、讓之後有力氣再練功就好。 整串是給「前端新手技能樹 #0-#4、#9、#10」用的隨手小抄。Compiler / Bundler / CLICompiler(編譯器):把一種語言轉成另一種語言,例如 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 / JS 前端鐵三角HTML:語意與結構,是「骨架」,負責定義頁面上有什麼元素,例如標題、段落、按鈕、表單。CSS:長相與排版,是「外觀肌肉 / 皮膚」,控制顏色、大小、位置、字體、RWD 等視覺表現。JS:行為與互動,是「大腦與神經」,負責處理事件(點擊、輸入)、更新畫面、跟後端 API 溝通。實務情境:做一個「送出表單」按鈕HTML: → 有一個可以點的按鈕。CSS:把按鈕變成你想要的顏色、圓角、hover 效果。JS:決定按下去要驗證什麼、傳去哪裡、成功時怎麼提示使用者。建議:先用 HTML 把骨架打出來,再慢慢補 CSS、最後再加 JS 行為,避免一開始就糊在一起。 Git / GitHub 與 Learn Git Branching核心觀念: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 / When / What 拆 JS 功能(100 行挑戰思維)想一個小功能時,可以用這三個問題來拆:Who:誰在操作?對應到「哪個使用者 / 哪個 DOM 元素在觸發事件」。例:使用者點擊「加入購物車」按鈕。When:什麼時候要發生?對應到「事件類型或時機」。例:按下按鈕(click)、輸入文字(input)、頁面載入完成(DOMContentLoaded)。What:要做什麼?就是事件 handler 裡面的邏輯。例:把商品 id 加進陣列、更新畫面上的數字、存到 localStorage。100 行挑戰精神:用原生 JS + HTML + CSS 寫一個你有興趣的小功能(倒數計時器、todo list、抽獎轉盤、打怪計算器)。限制自己程式碼控制在大約 100 行內,逼自己做到:拆小 function,命名清楚。把 Who / When / What 寫成乾淨的事件綁定 +邏輯。避免一次塞一大坨寫不完、看不懂的 code。 框架與撰寫規範(含 airbnb style)框架做的事: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 串接。各模組的責任與核心概念模組責任createRl建立 readline 介面(stdin / stdout)runCliSessionCLI 生命週期(init / close)questionOnce單次提問(rl.question → Promise)askWithValidator單欄位:反覆提問+單一 validatorcomposeValidators多個 validator → 一個 validatorquestionConfigs宣告欄位、提示、驗證器組合runCliQuestions一次跑完整個互動流程、回傳結果main真正的業務邏輯怎麼用這些輸入 各模組函式的依賴關係[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] 使用 (作為輸入資料) 1. createRl:與 Node API 對接的最底層包裝export function createRl() { return readline.createInterface({ input, output }); } 核心概念:將 readline/promises 的建立動作集中於一處,形成統一的工廠函式。若未來想替換輸入來源(例如測試用 fake stream),或切換實作,只需修改此模組。其它模組可以透過 spy/mock 取代這個函式,避免實際啟動 readline。2. runCliSession:CLI 的「安全使用器」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 作為「需釋放資源」的物件,無論流程如何結束都能被妥善關閉。3. questionOnce:一次提問的最小單元export function questionOnce(rl, prompt) { return new Promise((resolve) => { rl.question(prompt, (answer) => { resolve(answer); }); }); } 核心概念:把 callback 型 rl.question(prompt, cb) 封裝成 Promise。只負責「顯示一個 prompt,取得一行輸入字串」。後續互動邏輯(例如重複提問)皆以此為基礎。4. askWithValidator:單欄位反覆提問直到驗證通過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 組合後的「總驗證器」。5. composeValidators:多步驟驗證的組合器(高階函式 HOF)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] 可以一眼看出完整的驗證流程。6. validators:單一職責的細顆粒驗證器整數路線例子: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,由外層互動邏輯統一處理訊息與重試。7. questionConfigs:用設定描述每一題的「提問內容+驗證方法」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,只負責宣告「問什麼題+怎麼驗證」。8. runCliQuestions:跑完所有互動流程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 的角色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(...),集中處理。Vitest - vi 的 Mock / Spy 方法為什麼需要 Mock / Spy?在 TDD 過程中,希望針對上述每個模組「單獨」驗證行為:不希望測試真的開啟 readline、等待人工輸入。希望控制輸入序列(例如第一次輸入錯、第二次輸入對)。希望確認:runCliSession 是否有呼叫 createRl 和 rl.close。runCliQuestions 是否有依序對每題呼叫 askWithValidator。這些需求可以透過 mock function 與 spy 建立「測試替身 test double」達成。Mock function:vi.fn()、vi.mock(...)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:vi.spyOn(...)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 vs Spy:概念對照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); }); ## 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