線上課程觀課進度管理小工具開發日誌
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」。
目標:建立可重用的 CLI 專案架構,實踐設計模式與測試策略
每個功能重複程式碼(驗證邏輯、CLI 輸入處理)
缺乏統一架構
驗證邏輯混在業務邏輯中
難以維護和擴展
建立統一的 CLI 模板
抽象驗證器為可重用模組
分離業務邏輯和基礎設施
保持核心邏輯純淨
┌──────────────────────────────────────┐
│ CLI Layer (Config + Validators) │ ← 輸入驗證
├──────────────────────────────────────┤
│ Processing Layer (processUserInputs)│ ← 流程處理
├──────────────────────────────────────┤
│ Business Logic (Core Functions) │ ← 業務運算
└──────────────────────────────────────┘
project/
├── features/
│ ├── feature1/
│ │ ├── config.js # CLI 配置(驗證器 + processor)
│ │ ├── main.js # 程式入口(統一模板)
│ │ ├── logic.js # 核心業務邏輯
│ │ └── logic.test.js # 單元測試
│ └── feature2/
│ └── ...
│
├── shared/
│ └── utils.js # 共用函式(可被多個功能重用)
│
└── util/
├── validators.js # 驗證器模組
├── composeValidators.js # 驗證器組合
├── processUserInputs.js # 處理使用者輸入
├── runCliQuestions.js # CLI 問答流程
├── askWithValidator.js # 帶驗證的問答
└── runCliSession.js # CLI Session 管理
概念:將差異封裝在配置中,保持執行邏輯統一。
// features/calculator/config.js
export const questionConfigs = [
{
name: "number", // 答案的 key
prompt: "請輸入數字:", // CLI 提示
validator: composeValidators([...]), // 驗證器組合
processor: calculateResult, // 業務邏輯(選擇性)
},
];
優點:
聲明式配置,易讀易維護
新增功能只需複製模板
驗證邏輯可組合重用
概念:將驗證器串接,像 Unix 管道一樣處理資料。
// util/composeValidators.js
export function composeValidators(validators) {
return (value) => {
let current = value;
for (const validate of validators) {
const result = validate(current);
current = result.value ?? result;
}
return { value: current };
};
}
使用範例:
validator: composeValidators([
ensureIntegerString, // 1. 確保是整數字串
toInt, // 2. 轉換為數字
isPositiveInt, // 3. 驗證是正整數
])
資料流:
使用者輸入 "5"
↓
ensureIntegerString("5") → { value: "5" } ✅
↓
toInt("5") → { value: 5 } ✅
↓
isPositiveInt(5) → { value: 5 } ✅
↓
最終結果:{ value: 5 }
概念:在 Config 中指定核心邏輯,由框架自動執行。
// config.js
{
name: "age",
validator: composeValidators([...]),
processor: calculateDiscount, // ← 接收單一值
}
// 核心邏輯
function calculateDiscount(age) {
return age < 18 ? 0.5 : 1.0; // 未成年享半價
}
// config.js
{
name: "operand2",
validator: composeValidators([...]),
needsAllFields: true, // ← 標記需要所有欄位
processor: ({ operand1, operand2 }) => calculate(operand1, operand2),
}
框架自動處理:
// util/processUserInputs.js
if (config.needsAllFields) {
// 傳入所有欄位
processedValues[config.name] = config.processor(extractedValues);
} else {
// 傳入單一值
processedValues[config.name] = config.processor(input);
}
export function ensureIntegerString(input) {
if (!/^-?\d+$/.test(input)) {
throw new Error('請輸入整數');
}
return { value: input };
}
測試案例:
"123" → { value: "123" }
"12.5" → Error
"abc" → Error
export function toInt(input) {
const num = parseInt(input, 10);
if (isNaN(num)) {
throw new Error('轉換失敗');
}
return { value: num };
}
export function isNonNegativeInt(input) {
const num = input.value ?? input;
if (num < 0) {
throw new Error('請輸入非負整數(0, 1, 2...)');
}
return { value: num };
}
適用情境:年齡、數量、計數等不可為負的數值。
export function isPositiveInt(input, min = 1, max = Infinity) {
const num = input.value ?? input;
if (num < min || num > max) {
throw new Error(max === Infinity
? `請輸入大於等於 ${min} 的正整數`
: `請輸入 ${min} 到 ${max} 之間的正整數`);
}
return { value: num };
}
使用範例:
// 任意正整數
isPositiveInt(input) // >= 1
// 限制最小值
isPositiveInt(input, 10) // >= 10
// 限制範圍
isPositiveInt(input, 1, 100) // 1 到 100
適用情境:分數(1-100)、月份(1-12)、評分等級等。
export function isAlphabetic(input) {
const str = input.value ?? input;
if (!/^[A-Za-z]+$/.test(str)) {
throw new Error('請輸入英文字母');
}
return { value: str };
}
適用情境:名字、代碼等純字母輸入。
功能:
自動提取 { value: ... } 包裝
執行 processor
支援單欄位和多欄位模式
export function processUserInputs(userInputRounds, questionConfigs) {
return userInputRounds.map((roundInputs) => {
const processedValues = {};
// 第一步:提取所有欄位的值
const extractedValues = {};
for (const config of questionConfigs) {
const validated = roundInputs[config.name];
const input =
validated && typeof validated === "object" && "value" in validated
? validated.value
: validated;
extractedValues[config.name] = input;
}
// 第二步:處理每個欄位
for (const config of questionConfigs) {
const input = extractedValues[config.name];
if (config.processor) {
if (config.needsAllFields) {
processedValues[config.name] = config.processor(extractedValues);
} else {
processedValues[config.name] = config.processor(input);
}
} else {
processedValues[config.name] = input;
}
}
return processedValues;
});
}
// features/calculator/main.js
import { runCliQuestions } from '../../util/runCliQuestions.js';
import { processUserInputs } from '../../util/processUserInputs.js';
import { questionConfigs } from './config.js';
async function main() {
console.log('=== Calculator ===\n');
const userInputRounds = await runCliQuestions(questionConfigs);
const processedRounds = processUserInputs(userInputRounds, questionConfigs);
const [firstRound] = processedRounds;
console.log('\n結果:', firstRound.result);
}
main().catch((err) => {
console.error('程式發生未預期錯誤:', err);
process.exit(1);
});
// features/processor/main.js
import { processData } from './logic.js';
async function main() {
console.log('=== Data Processor ===\n');
const data = [1, 2, 3, 4, 5]; // 固定資料
const result = processData(data);
console.log(result);
}
main().catch((err) => {
console.error('程式發生未預期錯誤:', err);
process.exit(1);
});
// features/discount/config.js
import { ensureIntegerString, toInt, isNonNegativeInt } from "../../util/validators.js";
import { composeValidators } from "../../util/composeValidators.js";
import { calculateDiscount } from "./logic.js";
export const questionConfigs = [
{
name: "age",
prompt: "請輸入年齡:",
validator: composeValidators([
ensureIntegerString,
toInt,
isNonNegativeInt,
]),
processor: calculateDiscount,
},
];
// features/discount/logic.js
export function calculateDiscount(age) {
const FULL_PRICE = 100;
const CHILD_AGE_LIMIT = 12;
const SENIOR_AGE_LIMIT = 65;
if (age <= CHILD_AGE_LIMIT || age >= SENIOR_AGE_LIMIT) {
return FULL_PRICE * 0.5; // 兒童/長者享半價
}
return FULL_PRICE;
}
特點:
假設輸入已驗證(不在函式內驗證)
純函式(無副作用)
使用常數提升可讀性
// features/discount/logic.test.js
import { describe, test, expect } from "vitest";
import { calculateDiscount } from "./logic.js";
describe("calculateDiscount", () => {
test("兒童(12 歲以下)享半價", () => {
expect(calculateDiscount(0)).toBe(50);
expect(calculateDiscount(12)).toBe(50);
});
test("成人全價", () => {
expect(calculateDiscount(13)).toBe(100);
expect(calculateDiscount(64)).toBe(100);
});
test("長者(65 歲以上)享半價", () => {
expect(calculateDiscount(65)).toBe(50);
expect(calculateDiscount(80)).toBe(50);
});
});
// features/compare/config.js
import { ensureIntegerString, toInt } from "../../util/validators.js";
import { composeValidators } from "../../util/composeValidators.js";
import { compareNumbers } from "./logic.js";
export const questionConfigs = [
{
name: "num1",
prompt: "請輸入第一個數字:",
validator: composeValidators([
ensureIntegerString,
toInt,
]),
},
{
name: "num2",
prompt: "請輸入第二個數字:",
validator: composeValidators([
ensureIntegerString,
toInt,
]),
needsAllFields: true, // 🔧 標記需要多欄位
processor: ({ num1, num2 }) => compareNumbers(num1, num2),
},
];
// features/compare/logic.js
export function compareNumbers(num1, num2) {
if (num1 > num2) return "第一個數字較大";
if (num1 < num2) return "第二個數字較大";
return "兩數相等";
}
場景:多個功能需要使用相同的演算法
// shared/isPrime.js(共用模組)
export function isPrime(num) {
if (num <= 1) return false;
if (num === 2) return true;
if (num % 2 === 0) return false;
const limit = Math.sqrt(num);
for (let i = 3; i <= limit; i += 2) {
if (num % i === 0) return false;
}
return true;
}
// features/primeChecker/logic.js
import { isPrime } from "../../shared/isPrime.js";
export function checkPrime(num) {
return isPrime(num) ? "是質數" : "不是質數";
}
// features/primeFilter/logic.js
import { isPrime } from "../../shared/isPrime.js";
export function filterPrimes(numbers) {
return numbers.filter(num => isPrime(num));
}
依賴關係:
shared/isPrime.js ← 定義共用邏輯
↓
features/primeChecker/ ← 重用
features/primeFilter/ ← 重用
優點:
避免程式碼重複(DRY 原則)
統一邏輯
易於維護(修改一處,全部生效)
概念:從物件中提取值,並賦值給變數。
// 資料結構
const users = [
{ name: 'Alice', age: 30, city: 'Taipei' },
{ name: 'Bob', age: 25, city: 'Tokyo' }
];
// 傳統寫法
users.forEach((user) => {
console.log(`${user.name} 住在 ${user.city}`);
});
// 解構寫法 ✅
users.forEach(({ name, city }) => {
// ^^^^^^^^^^^^^^
// 直接按屬性名取值,建立變數
console.log(`${name} 住在 ${city}`);
});
運作原理:
// forEach 傳入每個元素
const user = { name: 'Alice', age: 30, city: 'Taipei' };
// 參數解構等同於
const { name, city } = user;
// 相當於
const name = user.name;
const city = user.city;
關鍵規則:
屬性名必須一致
順序不重要(只看屬性名)
可以只取部分屬性
差異對比:
// ❌ 難以理解(魔術數字)
const total = quantity * 500 - 50 - Math.floor((quantity - 1) / 5) * 100;
// ✅ 易於理解(自我解釋的程式碼)
const pricePerUnit = 500;
const firstDiscountAmount = 50;
const bulkDiscountAmount = 100;
const bulkDiscountThreshold = 5;
const rawTotal = quantity * pricePerUnit;
const firstDiscount = firstDiscountAmount;
const bulkDiscountTimes = Math.floor((quantity - 1) / bulkDiscountThreshold);
const bulkDiscount = bulkDiscountTimes * bulkDiscountAmount;
return rawTotal - firstDiscount - bulkDiscount;
優點:
常數名稱即業務規則
計算步驟清晰
易於修改規則
三層架構範例:
// 第一層:建立範圍(內部工具)
function createRange(start, end) {
const length = end - start + 1;
return Array.from({ length }, (_, index) => start + index);
}
// 第二層:過濾符合條件的數字
export function filterEvenNumbers(start, end) {
return createRange(start, end).filter(num => num % 2 === 0);
}
// 第三層:統計與彙整
export function analyzeNumbers(start, end) {
const evens = filterEvenNumbers(start, end);
return {
count: evens.length,
sum: evens.reduce((a, b) => a + b, 0),
average: evens.reduce((a, b) => a + b, 0) / evens.length
};
}
特點:
由簡單函式組合成複雜功能
每個函式職責單一
易於測試(可獨立測試每層)
錯誤設計:
// ❌ 違反 SRP
export function calculateDiscount(age) {
if (typeof age !== 'number') {
throw new Error('請輸入數字'); // ← 驗證器的責任
}
if (age < 0) {
throw new Error('年齡不可為負'); // ← 驗證器的責任
}
return age < 18 ? 0.5 : 1.0;
}
正確設計:
// ✅ 核心邏輯假設輸入已驗證
export function calculateDiscount(age) {
return age < 18 ? 0.5 : 1.0;
}
// 驗證在 Config 中
validator: composeValidators([
ensureIntegerString,
toInt,
isNonNegativeInt,
])
理由:
符合 SRP(單一職責原則)
避免驗證邏輯重複
核心邏輯保持純淨
測試更簡單
差異:
方法 | 用途 | 輸出到 | 顏色 |
|---|---|---|---|
console.log() | 正常訊息 | stdout | 白色/預設 |
console.error() | 錯誤訊息 | stderr | 紅色 |
console.warn() | 警告訊息 | stderr | 黃色 |
實務應用:
# 分離正常和錯誤輸出
node main.js > output.txt 2> error.txt
# stdout → output.txt
# stderr → error.txt
決策:
main().catch((err) => {
console.error('程式發生未預期錯誤:', err); // ← 使用 error
process.exit(1); // 回傳錯誤退出碼
});
理由:
符合 Unix/Node.js 標準
方便 CI/CD 工具識別錯誤
支援 shell 重導向分離輸出
決策:統一使用 camelCase(符合 Airbnb Style Guide)
// ✅ 推薦:camelCase(函式內部常數)
const pricePerUnit = 500;
const discountRate = 0.1;
const maxQuantity = 100;
// ❌ 不推薦:UPPER_SNAKE_CASE(除非是匯出的全域配置)
const PRICE_PER_UNIT = 500;
例外:匯出的配置常數
// ✅ 可接受(匯出的全域常數)
export const API_BASE_URL = 'https://api.example.com';
export const MAX_RETRY_ATTEMPTS = 3;
export const DEFAULT_TIMEOUT = 5000;
原則:不在核心邏輯測試中測試驗證
// ✅ 驗證器的測試(util/validators.test.js)
describe("isNonNegativeInt", () => {
test("拒絕負數", () => {
expect(() => isNonNegativeInt({ value: -1 })).toThrow();
});
});
// ✅ 核心邏輯的測試(features/discount/logic.test.js)
describe("calculateDiscount", () => {
test("正確計算折扣", () => {
expect(calculateDiscount(12)).toBe(50);
expect(calculateDiscount(18)).toBe(100);
});
});
理由:
符合 SRP(分層測試)
避免重複測試
核心邏輯假設輸入已驗證
// ✅ 避免重複
test.each([
[10, 100],
[20, 200],
[30, 300],
])("輸入 %i 應回傳 %i", (input, expected) => {
expect(calculate(input)).toBe(expected);
});
// 複雜物件測試
test("產生正確的報表", () => {
const result = generateReport(data);
expect(result).toMatchInlineSnapshot(`
{
"summary": "...",
"details": [...]
}
`);
});
describe("計算邏輯驗證", () => {
test("折扣計算關係正確", () => {
// 驗證「關係」而非「結果」
const price10 = calculatePrice(10);
const price20 = calculatePrice(20);
// 數量加倍,價格應該接近加倍(考慮折扣)
expect(price20 / price10).toBeGreaterThan(1.8);
expect(price20 / price10).toBeLessThan(2.2);
});
});
<type>(<scope>): <subject>
<body>
<footer>
feat: 新功能
refactor: 重構
fix: 修復 bug
test: 新增測試
docs: 文件更新
git commit -m "feat(discount): 完成折扣計算功能
- 新增 calculateDiscount 核心邏輯與測試
- 新增 config(單欄位模式)
- 新增 main(統一模板)
- 驗證器使用 isNonNegativeInt(年齡不可為負)
業務規則:
- 基本價格 100 元
- 12 歲(含)以下享半價
- 65 歲(含)以上享半價
技術:Config-driven + 驗證器組合 + 純函式"
util/
├── validators.js # 7 個驗證器
├── composeValidators.js # 驗證器組合
├── processUserInputs.js # 處理流程(支援單/多欄位)
├── runCliQuestions.js # CLI 問答
├── askWithValidator.js # 帶驗證的輸入
└── runCliSession.js # CLI Session 管理
shared/
└── utils.js # 共用函式(跨功能重用)
類別 | 技術點 |
|---|---|
CLI 架構 | Config-driven、驗證器組合 |
陣列方法 | map、filter、reduce、forEach |
演算法 | 質數判斷、範圍生成、數值計算 |
模組重用 | 跨功能共用函式 |
解構賦值 | 參數解構、物件解構 |
測試策略 | 參數化、快照、邏輯驗證 |
驗證器 → 負責驗證
核心邏輯 → 負責業務運算
Main 檔 → 負責組裝流程
抽象共同邏輯 → util/
共用函式 → shared/
保持核心邏輯獨特性 → features/
// ✅ 簡單
export function sum(numbers) {
return numbers.reduce((a, b) => a + b, 0);
}
// ✅ 配置驅動(新增功能只需複製 Config)
export const questionConfigs = [
{
name: "input",
validator: composeValidators([...]),
processor: processLogic,
},
];
分層清晰:CLI → Processing → Business Logic
模組化:驗證器、處理流程、核心邏輯分離
可擴展:新增功能只需複製模板
模組重用:跨功能共用函式
組合優於繼承:composeValidators 串接驗證器
配置驅動:差異封裝在 Config 中
純函式:核心邏輯無副作用、易測試
解構賦值:提升程式碼可讀性
分層測試:驗證器、核心邏輯分開測試
參數化測試:test.each 避免重複
快照測試:視覺化驗證複雜輸出
邏輯驗證:測試業務關係(不只測結果)
TDD:先寫測試,再實作
小步提交:每個功能獨立 commit
重構不改功能:測試保證行為不變
規範統一:Airbnb Style Guide(camelCase)
程式碼減少:約 60% 重複程式碼消除
模板化:70% 功能使用完全相同的 Main 檔
測試覆蓋:100% 核心邏輯有單元測試
質數判斷優化:
// 基礎版本
export function isPrime(num) {
if (num <= 1) return false;
for (let i = 2; i < num; i++) {
if (num % i === 0) return false;
}
return true;
}
// 優化版本(跳過偶數 + 只檢查到 sqrt(n))✅
export function isPrime(num) {
if (num <= 1) return false;
if (num === 2) return true;
if (num % 2 === 0) return false; // 排除偶數
const limit = Math.sqrt(num);
for (let i = 3; i <= limit; i += 2) { // 只檢查奇數
if (num % i === 0) return false;
}
return true;
}
效能提升:
基礎版本:O(n)
優化版本:O(√n),且跳過所有偶數
Node.js Console:https://nodejs.org/api/console.html
Vitest:https://vitest.dev/guide
MDN JavaScript:https://developer.mozilla.org/zh-TW/docs/Web/JavaScript
ECMAScript 規範:https://tc39.es/ecma262
Composition Pattern:函式組合
Strategy Pattern:驗證器策略
Template Method Pattern:Main 檔模板
Clean Code by Robert C. Martin
Refactoring by Martin Fowler
Test-Driven Development by Kent Beck
Airbnb JavaScript Style Guide
這個架構展示了如何從零散的程式碼,建立一個可維護、可擴展、可測試的 CLI 專案架構。
核心成就:
統一架構模板
可重用模組系統(驗證器 + 共用函式)
完整測試覆蓋
模組重用實踐
符合業界規範(Airbnb Style Guide)
最重要的收穫:
"好的架構不是一次設計出來的,而是透過重構逐步演進的。"
關鍵技術:
🏗 Config-Driven 模式(配置驅動)
🔄 Validator Composition(驗證器組合)
📦 模組重用(跨功能共用)
解構賦值(提升可讀性)
🧪 分層測試(測試策略)
持續改進,保持程式碼的整潔和優雅!🚀
目標:建立可重用的 CLI 專案架構,實踐設計模式與測試策略
每個功能重複程式碼(驗證邏輯、CLI 輸入處理)
缺乏統一架構
驗證邏輯混在業務邏輯中
難以維護和擴展
建立統一的 CLI 模板
抽象驗證器為可重用模組
分離業務邏輯和基礎設施
保持核心邏輯純淨
┌──────────────────────────────────────┐
│ CLI Layer (Config + Validators) │ ← 輸入驗證
├──────────────────────────────────────┤
│ Processing Layer (processUserInputs)│ ← 流程處理
├──────────────────────────────────────┤
│ Business Logic (Core Functions) │ ← 業務運算
└──────────────────────────────────────┘
project/
├── features/
│ ├── feature1/
│ │ ├── config.js # CLI 配置(驗證器 + processor)
│ │ ├── main.js # 程式入口(統一模板)
│ │ ├── logic.js # 核心業務邏輯
│ │ └── logic.test.js # 單元測試
│ └── feature2/
│ └── ...
│
├── shared/
│ └── utils.js # 共用函式(可被多個功能重用)
│
└── util/
├── validators.js # 驗證器模組
├── composeValidators.js # 驗證器組合
├── processUserInputs.js # 處理使用者輸入
├── runCliQuestions.js # CLI 問答流程
├── askWithValidator.js # 帶驗證的問答
└── runCliSession.js # CLI Session 管理
概念:將差異封裝在配置中,保持執行邏輯統一。
// features/calculator/config.js
export const questionConfigs = [
{
name: "number", // 答案的 key
prompt: "請輸入數字:", // CLI 提示
validator: composeValidators([...]), // 驗證器組合
processor: calculateResult, // 業務邏輯(選擇性)
},
];
優點:
聲明式配置,易讀易維護
新增功能只需複製模板
驗證邏輯可組合重用
概念:將驗證器串接,像 Unix 管道一樣處理資料。
// util/composeValidators.js
export function composeValidators(validators) {
return (value) => {
let current = value;
for (const validate of validators) {
const result = validate(current);
current = result.value ?? result;
}
return { value: current };
};
}
使用範例:
validator: composeValidators([
ensureIntegerString, // 1. 確保是整數字串
toInt, // 2. 轉換為數字
isPositiveInt, // 3. 驗證是正整數
])
資料流:
使用者輸入 "5"
↓
ensureIntegerString("5") → { value: "5" } ✅
↓
toInt("5") → { value: 5 } ✅
↓
isPositiveInt(5) → { value: 5 } ✅
↓
最終結果:{ value: 5 }
概念:在 Config 中指定核心邏輯,由框架自動執行。
// config.js
{
name: "age",
validator: composeValidators([...]),
processor: calculateDiscount, // ← 接收單一值
}
// 核心邏輯
function calculateDiscount(age) {
return age < 18 ? 0.5 : 1.0; // 未成年享半價
}
// config.js
{
name: "operand2",
validator: composeValidators([...]),
needsAllFields: true, // ← 標記需要所有欄位
processor: ({ operand1, operand2 }) => calculate(operand1, operand2),
}
框架自動處理:
// util/processUserInputs.js
if (config.needsAllFields) {
// 傳入所有欄位
processedValues[config.name] = config.processor(extractedValues);
} else {
// 傳入單一值
processedValues[config.name] = config.processor(input);
}
export function ensureIntegerString(input) {
if (!/^-?\d+$/.test(input)) {
throw new Error('請輸入整數');
}
return { value: input };
}
測試案例:
"123" → { value: "123" }
"12.5" → Error
"abc" → Error
export function toInt(input) {
const num = parseInt(input, 10);
if (isNaN(num)) {
throw new Error('轉換失敗');
}
return { value: num };
}
export function isNonNegativeInt(input) {
const num = input.value ?? input;
if (num < 0) {
throw new Error('請輸入非負整數(0, 1, 2...)');
}
return { value: num };
}
適用情境:年齡、數量、計數等不可為負的數值。
export function isPositiveInt(input, min = 1, max = Infinity) {
const num = input.value ?? input;
if (num < min || num > max) {
throw new Error(max === Infinity
? `請輸入大於等於 ${min} 的正整數`
: `請輸入 ${min} 到 ${max} 之間的正整數`);
}
return { value: num };
}
使用範例:
// 任意正整數
isPositiveInt(input) // >= 1
// 限制最小值
isPositiveInt(input, 10) // >= 10
// 限制範圍
isPositiveInt(input, 1, 100) // 1 到 100
適用情境:分數(1-100)、月份(1-12)、評分等級等。
export function isAlphabetic(input) {
const str = input.value ?? input;
if (!/^[A-Za-z]+$/.test(str)) {
throw new Error('請輸入英文字母');
}
return { value: str };
}
適用情境:名字、代碼等純字母輸入。
功能:
自動提取 { value: ... } 包裝
執行 processor
支援單欄位和多欄位模式
export function processUserInputs(userInputRounds, questionConfigs) {
return userInputRounds.map((roundInputs) => {
const processedValues = {};
// 第一步:提取所有欄位的值
const extractedValues = {};
for (const config of questionConfigs) {
const validated = roundInputs[config.name];
const input =
validated && typeof validated === "object" && "value" in validated
? validated.value
: validated;
extractedValues[config.name] = input;
}
// 第二步:處理每個欄位
for (const config of questionConfigs) {
const input = extractedValues[config.name];
if (config.processor) {
if (config.needsAllFields) {
processedValues[config.name] = config.processor(extractedValues);
} else {
processedValues[config.name] = config.processor(input);
}
} else {
processedValues[config.name] = input;
}
}
return processedValues;
});
}
// features/calculator/main.js
import { runCliQuestions } from '../../util/runCliQuestions.js';
import { processUserInputs } from '../../util/processUserInputs.js';
import { questionConfigs } from './config.js';
async function main() {
console.log('=== Calculator ===\n');
const userInputRounds = await runCliQuestions(questionConfigs);
const processedRounds = processUserInputs(userInputRounds, questionConfigs);
const [firstRound] = processedRounds;
console.log('\n結果:', firstRound.result);
}
main().catch((err) => {
console.error('程式發生未預期錯誤:', err);
process.exit(1);
});
// features/processor/main.js
import { processData } from './logic.js';
async function main() {
console.log('=== Data Processor ===\n');
const data = [1, 2, 3, 4, 5]; // 固定資料
const result = processData(data);
console.log(result);
}
main().catch((err) => {
console.error('程式發生未預期錯誤:', err);
process.exit(1);
});
// features/discount/config.js
import { ensureIntegerString, toInt, isNonNegativeInt } from "../../util/validators.js";
import { composeValidators } from "../../util/composeValidators.js";
import { calculateDiscount } from "./logic.js";
export const questionConfigs = [
{
name: "age",
prompt: "請輸入年齡:",
validator: composeValidators([
ensureIntegerString,
toInt,
isNonNegativeInt,
]),
processor: calculateDiscount,
},
];
// features/discount/logic.js
export function calculateDiscount(age) {
const FULL_PRICE = 100;
const CHILD_AGE_LIMIT = 12;
const SENIOR_AGE_LIMIT = 65;
if (age <= CHILD_AGE_LIMIT || age >= SENIOR_AGE_LIMIT) {
return FULL_PRICE * 0.5; // 兒童/長者享半價
}
return FULL_PRICE;
}
特點:
假設輸入已驗證(不在函式內驗證)
純函式(無副作用)
使用常數提升可讀性
// features/discount/logic.test.js
import { describe, test, expect } from "vitest";
import { calculateDiscount } from "./logic.js";
describe("calculateDiscount", () => {
test("兒童(12 歲以下)享半價", () => {
expect(calculateDiscount(0)).toBe(50);
expect(calculateDiscount(12)).toBe(50);
});
test("成人全價", () => {
expect(calculateDiscount(13)).toBe(100);
expect(calculateDiscount(64)).toBe(100);
});
test("長者(65 歲以上)享半價", () => {
expect(calculateDiscount(65)).toBe(50);
expect(calculateDiscount(80)).toBe(50);
});
});
// features/compare/config.js
import { ensureIntegerString, toInt } from "../../util/validators.js";
import { composeValidators } from "../../util/composeValidators.js";
import { compareNumbers } from "./logic.js";
export const questionConfigs = [
{
name: "num1",
prompt: "請輸入第一個數字:",
validator: composeValidators([
ensureIntegerString,
toInt,
]),
},
{
name: "num2",
prompt: "請輸入第二個數字:",
validator: composeValidators([
ensureIntegerString,
toInt,
]),
needsAllFields: true, // 🔧 標記需要多欄位
processor: ({ num1, num2 }) => compareNumbers(num1, num2),
},
];
// features/compare/logic.js
export function compareNumbers(num1, num2) {
if (num1 > num2) return "第一個數字較大";
if (num1 < num2) return "第二個數字較大";
return "兩數相等";
}
場景:多個功能需要使用相同的演算法
// shared/isPrime.js(共用模組)
export function isPrime(num) {
if (num <= 1) return false;
if (num === 2) return true;
if (num % 2 === 0) return false;
const limit = Math.sqrt(num);
for (let i = 3; i <= limit; i += 2) {
if (num % i === 0) return false;
}
return true;
}
// features/primeChecker/logic.js
import { isPrime } from "../../shared/isPrime.js";
export function checkPrime(num) {
return isPrime(num) ? "是質數" : "不是質數";
}
// features/primeFilter/logic.js
import { isPrime } from "../../shared/isPrime.js";
export function filterPrimes(numbers) {
return numbers.filter(num => isPrime(num));
}
依賴關係:
shared/isPrime.js ← 定義共用邏輯
↓
features/primeChecker/ ← 重用
features/primeFilter/ ← 重用
優點:
避免程式碼重複(DRY 原則)
統一邏輯
易於維護(修改一處,全部生效)
概念:從物件中提取值,並賦值給變數。
// 資料結構
const users = [
{ name: 'Alice', age: 30, city: 'Taipei' },
{ name: 'Bob', age: 25, city: 'Tokyo' }
];
// 傳統寫法
users.forEach((user) => {
console.log(`${user.name} 住在 ${user.city}`);
});
// 解構寫法 ✅
users.forEach(({ name, city }) => {
// ^^^^^^^^^^^^^^
// 直接按屬性名取值,建立變數
console.log(`${name} 住在 ${city}`);
});
運作原理:
// forEach 傳入每個元素
const user = { name: 'Alice', age: 30, city: 'Taipei' };
// 參數解構等同於
const { name, city } = user;
// 相當於
const name = user.name;
const city = user.city;
關鍵規則:
屬性名必須一致
順序不重要(只看屬性名)
可以只取部分屬性
差異對比:
// ❌ 難以理解(魔術數字)
const total = quantity * 500 - 50 - Math.floor((quantity - 1) / 5) * 100;
// ✅ 易於理解(自我解釋的程式碼)
const pricePerUnit = 500;
const firstDiscountAmount = 50;
const bulkDiscountAmount = 100;
const bulkDiscountThreshold = 5;
const rawTotal = quantity * pricePerUnit;
const firstDiscount = firstDiscountAmount;
const bulkDiscountTimes = Math.floor((quantity - 1) / bulkDiscountThreshold);
const bulkDiscount = bulkDiscountTimes * bulkDiscountAmount;
return rawTotal - firstDiscount - bulkDiscount;
優點:
常數名稱即業務規則
計算步驟清晰
易於修改規則
三層架構範例:
// 第一層:建立範圍(內部工具)
function createRange(start, end) {
const length = end - start + 1;
return Array.from({ length }, (_, index) => start + index);
}
// 第二層:過濾符合條件的數字
export function filterEvenNumbers(start, end) {
return createRange(start, end).filter(num => num % 2 === 0);
}
// 第三層:統計與彙整
export function analyzeNumbers(start, end) {
const evens = filterEvenNumbers(start, end);
return {
count: evens.length,
sum: evens.reduce((a, b) => a + b, 0),
average: evens.reduce((a, b) => a + b, 0) / evens.length
};
}
特點:
由簡單函式組合成複雜功能
每個函式職責單一
易於測試(可獨立測試每層)
錯誤設計:
// ❌ 違反 SRP
export function calculateDiscount(age) {
if (typeof age !== 'number') {
throw new Error('請輸入數字'); // ← 驗證器的責任
}
if (age < 0) {
throw new Error('年齡不可為負'); // ← 驗證器的責任
}
return age < 18 ? 0.5 : 1.0;
}
正確設計:
// ✅ 核心邏輯假設輸入已驗證
export function calculateDiscount(age) {
return age < 18 ? 0.5 : 1.0;
}
// 驗證在 Config 中
validator: composeValidators([
ensureIntegerString,
toInt,
isNonNegativeInt,
])
理由:
符合 SRP(單一職責原則)
避免驗證邏輯重複
核心邏輯保持純淨
測試更簡單
差異:
方法 | 用途 | 輸出到 | 顏色 |
|---|---|---|---|
console.log() | 正常訊息 | stdout | 白色/預設 |
console.error() | 錯誤訊息 | stderr | 紅色 |
console.warn() | 警告訊息 | stderr | 黃色 |
實務應用:
# 分離正常和錯誤輸出
node main.js > output.txt 2> error.txt
# stdout → output.txt
# stderr → error.txt
決策:
main().catch((err) => {
console.error('程式發生未預期錯誤:', err); // ← 使用 error
process.exit(1); // 回傳錯誤退出碼
});
理由:
符合 Unix/Node.js 標準
方便 CI/CD 工具識別錯誤
支援 shell 重導向分離輸出
決策:統一使用 camelCase(符合 Airbnb Style Guide)
// ✅ 推薦:camelCase(函式內部常數)
const pricePerUnit = 500;
const discountRate = 0.1;
const maxQuantity = 100;
// ❌ 不推薦:UPPER_SNAKE_CASE(除非是匯出的全域配置)
const PRICE_PER_UNIT = 500;
例外:匯出的配置常數
// ✅ 可接受(匯出的全域常數)
export const API_BASE_URL = 'https://api.example.com';
export const MAX_RETRY_ATTEMPTS = 3;
export const DEFAULT_TIMEOUT = 5000;
原則:不在核心邏輯測試中測試驗證
// ✅ 驗證器的測試(util/validators.test.js)
describe("isNonNegativeInt", () => {
test("拒絕負數", () => {
expect(() => isNonNegativeInt({ value: -1 })).toThrow();
});
});
// ✅ 核心邏輯的測試(features/discount/logic.test.js)
describe("calculateDiscount", () => {
test("正確計算折扣", () => {
expect(calculateDiscount(12)).toBe(50);
expect(calculateDiscount(18)).toBe(100);
});
});
理由:
符合 SRP(分層測試)
避免重複測試
核心邏輯假設輸入已驗證
// ✅ 避免重複
test.each([
[10, 100],
[20, 200],
[30, 300],
])("輸入 %i 應回傳 %i", (input, expected) => {
expect(calculate(input)).toBe(expected);
});
// 複雜物件測試
test("產生正確的報表", () => {
const result = generateReport(data);
expect(result).toMatchInlineSnapshot(`
{
"summary": "...",
"details": [...]
}
`);
});
describe("計算邏輯驗證", () => {
test("折扣計算關係正確", () => {
// 驗證「關係」而非「結果」
const price10 = calculatePrice(10);
const price20 = calculatePrice(20);
// 數量加倍,價格應該接近加倍(考慮折扣)
expect(price20 / price10).toBeGreaterThan(1.8);
expect(price20 / price10).toBeLessThan(2.2);
});
});
<type>(<scope>): <subject>
<body>
<footer>
feat: 新功能
refactor: 重構
fix: 修復 bug
test: 新增測試
docs: 文件更新
git commit -m "feat(discount): 完成折扣計算功能
- 新增 calculateDiscount 核心邏輯與測試
- 新增 config(單欄位模式)
- 新增 main(統一模板)
- 驗證器使用 isNonNegativeInt(年齡不可為負)
業務規則:
- 基本價格 100 元
- 12 歲(含)以下享半價
- 65 歲(含)以上享半價
技術:Config-driven + 驗證器組合 + 純函式"
util/
├── validators.js # 7 個驗證器
├── composeValidators.js # 驗證器組合
├── processUserInputs.js # 處理流程(支援單/多欄位)
├── runCliQuestions.js # CLI 問答
├── askWithValidator.js # 帶驗證的輸入
└── runCliSession.js # CLI Session 管理
shared/
└── utils.js # 共用函式(跨功能重用)
類別 | 技術點 |
|---|---|
CLI 架構 | Config-driven、驗證器組合 |
陣列方法 | map、filter、reduce、forEach |
演算法 | 質數判斷、範圍生成、數值計算 |
模組重用 | 跨功能共用函式 |
解構賦值 | 參數解構、物件解構 |
測試策略 | 參數化、快照、邏輯驗證 |
驗證器 → 負責驗證
核心邏輯 → 負責業務運算
Main 檔 → 負責組裝流程
抽象共同邏輯 → util/
共用函式 → shared/
保持核心邏輯獨特性 → features/
// ✅ 簡單
export function sum(numbers) {
return numbers.reduce((a, b) => a + b, 0);
}
// ✅ 配置驅動(新增功能只需複製 Config)
export const questionConfigs = [
{
name: "input",
validator: composeValidators([...]),
processor: processLogic,
},
];
分層清晰:CLI → Processing → Business Logic
模組化:驗證器、處理流程、核心邏輯分離
可擴展:新增功能只需複製模板
模組重用:跨功能共用函式
組合優於繼承:composeValidators 串接驗證器
配置驅動:差異封裝在 Config 中
純函式:核心邏輯無副作用、易測試
解構賦值:提升程式碼可讀性
分層測試:驗證器、核心邏輯分開測試
參數化測試:test.each 避免重複
快照測試:視覺化驗證複雜輸出
邏輯驗證:測試業務關係(不只測結果)
TDD:先寫測試,再實作
小步提交:每個功能獨立 commit
重構不改功能:測試保證行為不變
規範統一:Airbnb Style Guide(camelCase)
程式碼減少:約 60% 重複程式碼消除
模板化:70% 功能使用完全相同的 Main 檔
測試覆蓋:100% 核心邏輯有單元測試
質數判斷優化:
// 基礎版本
export function isPrime(num) {
if (num <= 1) return false;
for (let i = 2; i < num; i++) {
if (num % i === 0) return false;
}
return true;
}
// 優化版本(跳過偶數 + 只檢查到 sqrt(n))✅
export function isPrime(num) {
if (num <= 1) return false;
if (num === 2) return true;
if (num % 2 === 0) return false; // 排除偶數
const limit = Math.sqrt(num);
for (let i = 3; i <= limit; i += 2) { // 只檢查奇數
if (num % i === 0) return false;
}
return true;
}
效能提升:
基礎版本:O(n)
優化版本:O(√n),且跳過所有偶數
Node.js Console:https://nodejs.org/api/console.html
Vitest:https://vitest.dev/guide
MDN JavaScript:https://developer.mozilla.org/zh-TW/docs/Web/JavaScript
ECMAScript 規範:https://tc39.es/ecma262
Composition Pattern:函式組合
Strategy Pattern:驗證器策略
Template Method Pattern:Main 檔模板
Clean Code by Robert C. Martin
Refactoring by Martin Fowler
Test-Driven Development by Kent Beck
Airbnb JavaScript Style Guide
這個架構展示了如何從零散的程式碼,建立一個可維護、可擴展、可測試的 CLI 專案架構。
核心成就:
統一架構模板
可重用模組系統(驗證器 + 共用函式)
完整測試覆蓋
模組重用實踐
符合業界規範(Airbnb Style Guide)
最重要的收穫:
"好的架構不是一次設計出來的,而是透過重構逐步演進的。"
關鍵技術:
🏗 Config-Driven 模式(配置驅動)
🔄 Validator Composition(驗證器組合)
📦 模組重用(跨功能共用)
解構賦值(提升可讀性)
🧪 分層測試(測試策略)
持續改進,保持程式碼的整潔和優雅!🚀
Share Dialog
Share Dialog
No comments yet