# 第八堂:期末總結與專案整合
## 開課提醒
1. 記得開錄影
2. 下載作業:<https://github.com/hexschool/js-camp-week8>,`npm install`
3. 根目錄建 `.env`,填 API Path 和 Token(<https://livejs-api.hexschool.io/> 申請)
## 今日上課內容
1. 鳥瞰:五個模組依賴關係
2. 驗證 pattern 深化:多欄位驗證兩種種類
3. Service pattern 深化:validate-then-do + success/error 統一包裝
---
## 一、鳥瞰專案
### 先看全圖再動手
> 直接從 api.js 開填 → 寫到一半會迷路。先全圖,再下筆。
### 五個模組的依賴關係

檔案結構:
```
js-camp-week8/
├── config.js ← 設定層(讀 .env)
├── api.js ← API 層(打後端)
├── utils.js ← 工具層(純邏輯)
├── services/ ← 服務層(業務邏輯)
│ ├── productService.js
│ ├── cartService.js
│ └── orderService.js
└── app.js ← 入口層(串起來跑)
```
| 層 | 責任 | 可打後端?| 依賴誰 |
|---|---|---|---|
| config | 讀 .env | ❌ | 不依賴 |
| api | 打後端 | ✅ 唯一可以 | config |
| utils | 純邏輯 | ❌ | 不依賴 |
| services | 組 api + utils | ❌(透過 api)| api + utils |
| app | 串 services 跑流程 | ❌ | services + utils |
> 依賴是**樹狀不是堆疊** — services 同時呼叫 api 和 utils(兩個平行下層),utils 不碰 api。
### 為什麼要拆模組
| | 全塞 app.js | 分層 |
|---|---|---|
| 行數 | 500+ 一大包 | 每檔 50~100 |
| 改 API url | 找半天 | 只改 config |
| 換驗證規則 | 找半天 | 只改 utils |
| 獨立測試 | 要開網路跑真 API | utils 能離線測 |
| 多人協作 | 打架 | 一人顧一層 |
> 把第七堂 Calculator 物件的概念放大 — 收散函式 → 收檔案。
> 學完這章 → 寫任務一 api.js。第一波:套第七堂 axios + `.env`,打通 13 個 API 測試。
---
## 二、驗證 pattern 深化
### 驗證的兩種種類
| 種類 | 使用時機 | 回傳格式 |
|---|---|---|
| 一次回一個錯 | 單一規則、一錯就停 | `{ isValid, error }` |
| 一次回所有錯 | 多欄位、一次列出所有錯 | `{ isValid, errors: [...] }` |
> 使用者體驗差很多:一次看完所有錯改一次 OK;一個一個報錯要送 5 次表單。
### 一次回一個錯:購物車數量 1–99 正整數
```jsx
function validateQuantity(qty) {
if (!Number.isInteger(qty)) return { isValid: false, error: '必須是整數' };
if (qty < 1) return { isValid: false, error: '不可小於 1' };
if (qty > 99) return { isValid: false, error: '不可大於 99' };
return { isValid: true };
}
validateQuantity(5); // { isValid: true }
validateQuantity(0); // { isValid: false, error: '不可小於 1' }
validateQuantity(5.5); // { isValid: false, error: '必須是整數' }
```
> `Number.isInteger` 同時擋小數與非數字,一石二鳥。
### 一次回所有錯:會員註冊五欄位
```jsx
function validateSignup(data) {
const errors = [];
if (!data.name) errors.push('姓名不可為空');
if (!/^09\d{8}$/.test(data.tel || '')) errors.push('電話格式錯誤');
if (!data.email?.includes('@')) errors.push('Email 格式錯誤');
if (!data.address) errors.push('地址不可為空');
if (!['ATM', 'Credit Card', 'Apple Pay'].includes(data.payment)) {
errors.push('付款方式不正確');
}
return { isValid: errors.length === 0, errors };
}
validateSignup({ name: '', tel: '1234', email: 'abc', address: '', payment: 'Bitcoin' });
// { isValid: false, errors: ['姓名不可為空', '電話格式錯誤', 'Email 格式錯誤', '地址不可為空', '付款方式不正確'] }
```
> `data.email?.includes('@')` 裡的 `?.` 是 optional chaining — data.email 是 undefined 也不會炸。
### 選擇指南
| 情境 | 用哪個 |
|---|---|
| 單一輸入(年齡、密碼長度) | 一次回一個錯 |
| 多欄位表單(註冊、訂單) | 一次回所有錯 |
| 送 API 前的最後防線 | 一次回所有錯 |
### toLocaleString 千分位
```jsx
(1000).toLocaleString('zh-TW'); // '1,000'
(1234567).toLocaleString('zh-TW'); // '1,234,567'
function formatCurrency(amount) {
return `NT$ ${Number(amount).toLocaleString('zh-TW')}`;
}
formatCurrency(18000); // 'NT$ 18,000' ← Louvre 雙人床架
```
### getDaysAgo 的坑:要先 startOf('day')
```jsx
// ❌ 直接 diff:同一天但時分不同,會算成 -1 天
dayjs().diff(dayjs.unix(ts), 'day');
// ✅ 先 startOf('day'):對齊到 00:00 再比
const today = dayjs().startOf('day');
const target = dayjs.unix(ts).startOf('day');
const days = today.diff(target, 'day');
if (days === 0) return '今天';
return `${days} 天前`;
```
---
## 三、Service pattern 深化
### Service 是什麼
| 職責 | 說明 |
|---|---|
| 組 api + utils | 拉低層工具 + 後端呼叫,做一件完整業務 |
| 統一回傳格式 | 永遠回 `{ success, data }` 或 `{ success, error }` |
| 先驗證再執行 | 資料不合格不打 API(validate-then-do) |
### 兩種 Service 形狀
| 形狀 | 何時用 | 結構 |
|---|---|---|
| 純讀取 | GET 類、篩選類 | `api → filter/map → return` |
| validate-then-do | 寫入類(add / update / place) | `validate → call API → early return` |
### 獨立範例:WOWOROOM 床架下單服務
```jsx
// 情境:客人下訂床架的 service(不是作業直接要寫的函式)
function validateOrder(order) {
const errors = [];
if (!order.bedId) errors.push('請選床架');
if (!order.name) errors.push('姓名必填');
if (!order.address) errors.push('地址必填');
return { isValid: errors.length === 0, errors };
}
async function submitOrderAPI(order) {
const res = await axios.post('/shop/orders', { data: order });
return res.data;
}
async function orderBed(order) {
// 1. 驗證失敗 → early return
const v = validateOrder(order);
if (!v.isValid) return { success: false, errors: v.errors };
// 2. 打 API
const result = await submitOrderAPI(order);
// 3. API 失敗 → early return
if (!result.status) return { success: false, error: result.message };
// 4. 成功
return { success: true, data: result };
}
orderBed({ bedId: '', name: '', address: '' });
// { success: false, errors: ['請選床架', '姓名必填', '地址必填'] }
```
骨架:**驗證錯 → early return** → **打 API** → **API 錯 → early return** → **成功 return**。
### 為什麼統一 { success, data/error }
| | 不統一 | 統一 |
|---|---|---|
| 呼叫端判斷 | 有時檢查 `.error`、有時看 null、有時各自發揮 | 永遠 `if (result.success)` |
| 錯誤訊息 | 有 throw、return null、自訂格式 | 永遠 `result.error` |
| 測試 mock | 每函式寫法不同 | 同一個 pattern 一套搞定 |
> productService 全是純讀取(最簡單),cart 和 order 才有寫入版本要驗證包裝。
### validate-then-do 標準骨架
```jsx
async function serviceAction(input, payload) {
// 1. 驗證錯 → early return
const v = validateXXX(input);
if (!v.isValid) return { success: false, error: v.error };
// 2. 打 API
const result = await apiCall(payload);
// 3. API 錯 → early return
if (!result.status) return { success: false, error: result.message };
// 4. 成功
return { success: true, data: result };
}
```
### 坑點:errors 陣列 vs error 字串
| 函式 | 驗證型 | 失敗回傳 |
|---|---|---|
| `addProductToCart` / `updateProduct` | 一次回一個錯(validateCartQuantity)| `{ success: false, error: '...' }` |
| `placeOrder` | 一次回所有錯(validateOrderUser)| `{ success: false, errors: [...] }` |
> 驗證回 `error`(單數)還是 `errors`(複數)跟著驗證函式走 — 回一個錯用單數 `error`,回所有錯用複數 `errors` 陣列。
---
## 本週任務
必做:
1. 主線任務:<https://rpg.hexschool.com/#/training/12063182914161572765/board/content/12063182914161572766_12063182914161572788?tid=12063182914167567616>
2. 繳交期限:(老闆填入)
目標:`npm test` 跑出 75/75 全綠。
## 正課結束,下方為加碼環節
## AI 實驗室
* [安裝教學](https://drive.google.com/file/d/1JgyF8hBSkcU0p5ZA-OIv440gsRUshkps/view?usp=sharing)
* 講義:https://gonsakon.github.io/claude-code-training/#/
## 下一步
* 完成主線任務,截止 2026/05/10 23:59
* 心得牆
* [Node.js 預習](https://courses.hexschool.com/courses/enrolled/2929773?preview=admin)