我分析了一個活動售票系統的 150+ commits 前端改動,發現開發團隊反覆遇到同樣的三大陷阱。這不是理論,而是真實開發中的血淚史。
✖ build error solve (ts)
✖ fix: 修復 TypeScript 編譯錯誤與 UI 調整
✖ build error fixed
你會看到三四個 commit 在重複修「同一種類型」的編譯錯誤。
@types 型別定義一開始只裝了 jspdf 和 xlsx,但沒裝型別定義:
# ❌ 錯誤:只裝了套件
npm install jspdf xlsx
# 編譯時炸裂
# Property 'jsPDF' does not exist on type 'Window & typeof globalThis'
# Cannot find module 'xlsx'
# ✅ 正確:一定要裝型別定義
npm install jspdf xlsx
npm install --save-dev @types/jspdf @types/xlsx
一個 commit 就在修這個:
fe410179 build: Add `@types/jspdf` and `@types/xlsx` dependencies
教訓:每次 npm install 套件 之前,先問自己:「這個套件有沒有官方型別定義?」
你的後端改了 API response 的結構,但前端的 DTO 沒更新:
// ❌ 舊 API response
interface OrderResponse {
id: string;
total_amount: number; // ← 欄位名稱改了
}
// ✅ 新 API response(後端改過了)
interface OrderResponse {
id: string;
total: number; // ← 欄位名稱改了
}
編譯時 TypeScript 會卡在 Property 'total_amount' does not exist 的地獄。
一堆 commit 在改這個:
2008cb50 fix(payment): 修復 V4 重構後遺留的 deleted_at 引用問題
11be4891 fix: 修復 TypeScript 編譯錯誤與 UI 調整
特別是 Chakra UI 的使用。Chakra v2 vs v3 版本不相容:
// ❌ Chakra v2 style
<Box spacing={2}> {/* ← spacing 是 v2 的屬性 */}
// ✅ Chakra v3 style
<Box gap={2}> {/* ← gap 是 v3 的屬性 */}
commit 提到專案有同時依賴兩個版本的 Chakra,所以一直在改:
53c3de45 refactor: 使用 Common 元件替換活動與公司篩選器
// ❌ 型別推論不足
const memoizedT = useMemo(() => {
return (key: string) => t(key);
}, [t]); // TypeScript 不知道你在做什麼
// ✅ 明確指定型別
const memoizedT = useCallback((key: string): string => {
return t(key);
}, [t]);
這個 commit 在修這個:
c3f81b7a refactor: memoize the `t` function returned by `useTranslation`
using `useCallback` to improve performance.
建立一個「型別檢查 CI 步驟」,不要靠開發者手動檢查:
# package.json 的 scripts
{
"scripts": {
"type-check": "tsc --noEmit",
"build": "npm run type-check && vite build",
"dev": "tsc --noEmit && vite"
}
}
每次開發前先跑 npm run type-check,編譯錯誤立刻看到。
ddcb9c38 fix: update API base URL from port 8003 to 8080 across
multiple components
一個 commit,改了一個 port,結果涉及 20+ 個檔案。
你的前端到處都有這樣的程式碼:
// ❌ components/EventList.tsx
const res = await fetch('http://localhost:8003/api/v1/events');
// ❌ hooks/useOrder.ts
const res = await fetch('http://localhost:8003/api/v1/orders');
// ❌ utils/api.ts
const BASE_URL = 'http://localhost:8003';
// ❌ services/UserService.ts
const BASE_URL = 'http://localhost:8003';
後端改了 port 從 8003 → 8080,你要一個一個改。而且當你有「開發」、「測試」、「正式」三個環境,每個都要另外改一遍。
光是這個改動,就牽動:
.env.development vs .env.production
結果一個改動變成 20 個檔案的蝴蝶效應。
第一步:建立一個集中的 API 配置檔
// src/config/api.ts
const API_CONFIG = {
development: {
BASE_URL: process.env.REACT_APP_API_URL || 'http://localhost:8080',
TIMEOUT: 10000,
},
production: {
BASE_URL: process.env.REACT_APP_API_URL || 'https://api.example.com',
TIMEOUT: 15000,
},
};
export const getApiConfig = () => {
const env = process.env.NODE_ENV as 'development' | 'production';
return API_CONFIG[env];
};
export const API_BASE_URL = getApiConfig().BASE_URL;
第二步:所有 API 呼叫都從同一個地方拿
// ✅ hooks/useOrder.ts
import { API_BASE_URL } from '@/config/api';
export const useOrder = () => {
const fetchOrder = async (id: string) => {
const res = await fetch(`${API_BASE_URL}/api/v1/orders/${id}`);
// ...
};
return { fetchOrder };
};
第三步:.env 檔定義環境變數
# .env.development
VITE_API_URL=http://localhost:8080
# .env.production
VITE_API_URL=https://api.futuresign.example.com
Vite 會自動把 VITE_ 開頭的變數注入到 import.meta.env 裡:
const API_BASE_URL = import.meta.env.VITE_API_URL;
這樣改一個環境變數,全部地方都跟著改。
e3d1c362 perf: 為 Tabs 元件加上 isLazy 避免不必要渲染
945f8126 perf(events): 修復活動頁面記憶體問題
e3d56c0e perf(invoices): 優化發票統編查詢效能(N+1 → 1 次請求)
一堆 commit 在修「記憶體問題」和「無用渲染」。
最常見的洩漏場景:
// ❌ 記憶體洩漏:subscribe 沒有 unsubscribe
useEffect(() => {
const subscription = eventBus.subscribe('order-updated', handleUpdate);
// ❌ 沒有在 unmount 時取消訂閱
}, []);
// ✅ 正確做法:return cleanup 函式
useEffect(() => {
const subscription = eventBus.subscribe('order-updated', handleUpdate);
return () => {
subscription.unsubscribe(); // cleanup
};
}, []);
另一個常見的:
// ❌ 定時器沒清理
useEffect(() => {
const timer = setInterval(() => {
setOrderStatus(getStatus());
}, 5000);
// ❌ 沒有 clearInterval
}, []);
// ✅ 正確做法
useEffect(() => {
const timer = setInterval(() => {
setOrderStatus(getStatus());
}, 5000);
return () => clearInterval(timer);
}, []);
// ❌ 依賴陣列永遠為空,但內部用了 orderId
useEffect(() => {
const timer = setTimeout(() => {
fetch(`/api/orders/${orderId}`); // orderId 是外部的
}, 1000);
}, []); // ❌ 依賴陣列漏了 orderId
// 結果:
// - 第一次 render:orderId = "123",timer 用 "123"
// - orderId 變成 "456":timer 還是用 "123"(bug)
// - timer 一直跑著,佔著記憶體
// ✅ 正確做法
useEffect(() => {
const timer = setTimeout(() => {
fetch(`/api/orders/${orderId}`);
}, 1000);
return () => clearTimeout(timer);
}, [orderId]); // ✅ 加入 orderId
commit 提到:
78e3f494 refactor: adjust `useEffect` dependencies in `MyEventsPage`
to exclude `router` and suppress exhaustive-deps linting.
但更常見的是反向的陷阱:該加到依賴陣列但沒加。
// ❌ 所有 TabPanels 都會 render,即使沒有被看到
<Tabs>
<TabList>
<Tab>訂單</Tab>
<Tab>發票</Tab>
<Tab>退款</Tab>
</TabList>
<TabPanels>
<TabPanel><OrderTable data={orders} /></TabPanel> {/* render */}
<TabPanel><InvoiceTable data={invoices} /></TabPanel> {/* render */}
<TabPanel><RefundTable data={refunds} /></TabPanel> {/* render */}
</TabPanels>
</Tabs>
// ✅ 加上 isLazy,只有 active tab 才會 render
<Tabs isLazy>
{/* ... */}
</Tabs>
這個 commit 就在修這個:
e3d1c362 perf: 為 Tabs 元件加上 isLazy 避免不必要渲染
前端沒有直接的 N+1(那是後端的問題),但前端可能會觸發 N+1:
// ❌ 頁面加載時做的請求鏈
const orders = await fetch('/api/orders'); // 1 次
orders.forEach(async (order) => {
const items = await fetch(`/api/orders/${order.id}/items`); // N 次
const vendor = await fetch(`/api/vendors/${order.vendor_id}`); // N 次
});
// 結果:1 + 2N 次請求
// ✅ 讓後端 preload + 單一請求
const data = await fetch('/api/orders?include=items,vendor');
// 結果:1 次請求,後端用 GORM Preload 或 SQL JOIN 搞定
commit 提到:
e3d56c0e perf(invoices): 優化發票統編查詢效能(N+1 → 1 次請求)
6636a786 perf: GetBatchOrderSummary 用 Preload 一次撈完,消除 N+1
npm install --save-dev eslint-plugin-react-hooks
// .eslintrc.js
{
"extends": ["plugin:react-hooks/recommended"],
"rules": {
"react-hooks/exhaustive-deps": "error", // 強制
}
}
這樣寫錯依賴陣列,ESLint 就會叫你修。
// hooks/useAsync.ts
export const useAsync = <T,>(
asyncFn: () => Promise<T>,
dependencies: React.DependencyList
) => {
const [state, setState] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let isMounted = true; // 防止 unmount 後的 setState
(async () => {
try {
const data = await asyncFn();
if (isMounted) setState(data);
} catch (err) {
if (isMounted) setError(err as Error);
} finally {
if (isMounted) setLoading(false);
}
})();
return () => {
isMounted = false; // cleanup:標記 component 已 unmount
};
}, dependencies);
return { state, loading, error };
};
用的時候:
const { state: orders, loading } = useAsync(
() => fetch('/api/orders').then(r => r.json()),
[] // 一次加載,空依賴陣列
);
Chrome 有 React DevTools extension,可以看到:
打開 Profiler,跑一遍頁面,看哪個 component render 次數最多,那就是你的洩漏點。
| 陷阱 | 根本原因 | 預防方式 |
|---|---|---|
| TypeScript 編譯 | 型別定義分散、缺少 @types | CI type-check、自動化檢查 |
| 環境變數 | API URL 硬寫在各地 | 集中配置、環境變數注入 |
| 記憶體洩漏 | 副作用沒清理、useEffect 依賴寫錯 | ESLint + custom hooks + DevTools Profiler |
共同模式:都是因為「散亂」而不是「無知」。
Day 19:「別被無限循環 hooks 牽著走 — 從 dependency array 到 zustand 狀態管理」
你會看到當應用大到一定程度,useEffect 和 useState 的組合開始變得脆弱,該怎麼換一個思路。