Day 20-21:前端新手技能樹;驗證器流程模組化;Vitest - vi 的 Mock/ Spy 方法

前端新手技能樹 課程影片 #0-#4, #9, #10

身體狀態欠佳,輕量閱讀也做不到,就把影片當成背景知識,技術筆記抓重點、讓之後有力氣再練功就好。
整串是給「前端新手技能樹 #0-#4、#9、#10」用的隨手小抄。


Compiler / Bundler / CLI

  • 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。

Play Video

HTML / CSS / JS 前端鐵三角

  • HTML:語意與結構,是「骨架」,負責定義頁面上有什麼元素,例如標題、段落、按鈕、表單。

  • CSS:長相與排版,是「外觀肌肉 / 皮膚」,控制顏色、大小、位置、字體、RWD 等視覺表現。

  • JS:行為與互動,是「大腦與神經」,負責處理事件(點擊、輸入)、更新畫面、跟後端 API 溝通。

實務情境:做一個「送出表單」按鈕

  • HTML:<button type="submit">送出</button> → 有一個可以點的按鈕。

  • CSS:把按鈕變成你想要的顏色、圓角、hover 效果。

  • JS:決定按下去要驗證什麼、傳去哪裡、成功時怎麼提示使用者。

建議:先用 HTML 把骨架打出來,再慢慢補 CSS、最後再加 JS 行為,避免一開始就糊在一起。

Play Video

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 入門》這種文字教學,把名詞補齊。

Play Video

用 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。

Play Video

框架與撰寫規範(含 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…)中,都可以沿用同一套「乾淨、可維護」的寫法習慣。

Play Video

驗證器流程模組化

目標:把 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)

runCliSession

CLI 生命週期(init / close)

questionOnce

單次提問(rl.question → Promise)

askWithValidator

單欄位:反覆提問+單一 validator

composeValidators

多個 validator → 一個 validator

questionConfigs

宣告欄位、提示、驗證器組合

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 → 迴圈繼續 → 再問一次
    }
  }
}

從使用者角度:

  1. 顯示問題。

  2. 輸入錯 → CLI 顯示錯誤訊息 → 自動再跳出同一題,讓你重新輸入。

  3. 輸入對 → 跳出這一題,回傳最後的值。

核心概念:

  • 控制「反覆提問直到驗證通過」的流程封裝在這一層。

  • 顯示 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 只處理兩件事:

  1. 呼叫 runCliQuestions(configs) 取得完整的回答物件。

  2. 統一處理「未預期錯誤」(例如程式 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 流程建議:

  1. 先使用 vi.fn() 建立 mock function,熟悉 mock.calls 的結構與常用 matcher。

  2. 再使用 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);
});