終於來到了鐵人賽的最後一天!回顧這 30 天的挑戰,感觸良多。
我在軟體開發領域已經工作八年,從最初的後端工程師,到後來的全端工程師,再到區塊鏈開發,甚至成立了自己的工作室,接了不少案子。但在這次的鐵人賽挑戰中,第一次從零開始打造一個完整的 SaaS 產品,才發現有太多需要注意和學習的地方。
以前在公司工作時,大多是進入一個已經建立好框架和架構的環境,專注在功能開發和優化上。但這次從頭打造 Kyo System,從前端的 Landing Page 設計、用戶認證流程、到數據視覺化儀表板還有OTP服務,每一個環節都需要自己做決策、選擇技術棧、考慮效能和使用者體驗。
這個過程雖然充滿挑戰,但也讓我在前端開發的理解上更進一步。更重要的是,成功地為工作室打造出一套可以實際應用的 SaaS 產品架構,這是這次挑戰最大的收穫。
// TypeScript 帶來的型別安全
interface OTPVerification {
phoneNumber: string;
code: string;
requestId: string;
}
const verifyOTP = async (data: OTPVerification): Promise<AuthResponse> => {
// 編譯時期就能發現型別錯誤,大幅減少 runtime error
}
選擇原因:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-mantine': ['@mantine/core', '@mantine/hooks'],
'vendor-charts': ['recharts'],
},
},
},
},
})
選擇原因:
實際收穫:
<Stack spacing="md">
<TextInput
label="手機號碼"
placeholder="+886 912345678"
required
icon={<IconPhone size={16} />}
/>
<Button loading={isLoading} fullWidth>
發送驗證碼
</Button>
</Stack>
選擇原因:
實際收穫:
interface AppStore {
user: User | null;
setUser: (user: User | null) => void;
notifications: Notification[];
addNotification: (notification: Notification) => void;
}
const useAppStore = create<AppStore>((set) => ({
user: null,
setUser: (user) => set({ user }),
notifications: [],
addNotification: (notification) =>
set((state) => ({
notifications: [...state.notifications, notification],
})),
}))
選擇原因:
實際收穫:
const { data, isLoading, error } = useQuery({
queryKey: ['otp-stats', dateRange],
queryFn: () => fetchOTPStats(dateRange),
staleTime: 5 * 60 * 1000, // 5 分鐘
refetchInterval: 30 * 1000, // 30 秒自動刷新
})
選擇原因:
實際收穫:
學到的關鍵概念:
// A/B Testing 架構
const LandingPage = () => {
const variant = useABTest('hero-cta-test', ['variant-a', 'variant-b'])
return (
<Hero>
{variant === 'variant-a' ? (
<Button size="xl">免費試用 30 天</Button>
) : (
<Button size="xl">立即開始</Button>
)}
</Hero>
)
}
實踐經驗:
轉換漏斗優化:
造訪首頁 (100%)
→ 滾動到 Features (70%)
→ 點擊 CTA (12%)
→ 註冊頁面 (10%)
→ 完成註冊 (7%)
優化後:
造訪首頁 (100%)
→ 滾動到 Features (75%)
→ 點擊 CTA (18%)
→ 註冊頁面 (15%)
→ 完成註冊 (11%)
JWT Token 管理策略:
// Token 自動刷新機制
const setupTokenRefresh = () => {
const token = getAccessToken()
if (!token) return
const { exp } = jwtDecode(token)
const expiresIn = exp * 1000 - Date.now()
const refreshTime = expiresIn - 5 * 60 * 1000 // 提前 5 分鐘
setTimeout(async () => {
try {
const newToken = await refreshAccessToken()
setAccessToken(newToken)
setupTokenRefresh() // 遞迴設定下一次刷新
} catch (error) {
// Token 刷新失敗,導向登入頁
logout()
}
}, refreshTime)
}
TOTP 多因素認證實作:
// 使用 speakeasy 生成 TOTP secret
const generateMFASecret = () => {
const secret = speakeasy.generateSecret({
name: 'Kyo System',
issuer: 'Kyo OTP',
length: 32,
})
return {
secret: secret.base32,
qrCode: secret.otpauth_url,
}
}
// 驗證 TOTP code
const verifyTOTP = (secret: string, token: string) => {
return speakeasy.totp.verify({
secret,
encoding: 'base32',
token,
window: 2, // 允許前後 2 個時間窗口
})
}
安全性提升:
Socket.io 客戶端架構:
// WebSocket Hook
const useWebSocket = () => {
const [socket, setSocket] = useState<Socket | null>(null)
const [connected, setConnected] = useState(false)
useEffect(() => {
const token = getAccessToken()
const newSocket = io(WS_URL, {
auth: { token },
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
})
newSocket.on('connect', () => {
setConnected(true)
console.log('WebSocket connected')
})
newSocket.on('disconnect', () => {
setConnected(false)
console.log('WebSocket disconnected')
})
newSocket.on('notification', (data) => {
showNotification(data)
})
setSocket(newSocket)
return () => {
newSocket.close()
}
}, [])
return { socket, connected }
}
即時通知系統:
// 通知管理
interface Notification {
id: string
type: 'success' | 'error' | 'warning' | 'info'
title: string
message: string
timestamp: Date
read: boolean
}
const NotificationCenter = () => {
const { socket } = useWebSocket()
const { notifications, addNotification, markAsRead } = useNotificationStore()
useEffect(() => {
if (!socket) return
socket.on('otp:sent', (data) => {
addNotification({
type: 'success',
title: 'OTP 已發送',
message: `已發送 OTP 到 ${data.phoneNumber}`,
})
})
socket.on('otp:verified', (data) => {
addNotification({
type: 'success',
title: 'OTP 驗證成功',
message: `用戶 ${data.userId} 已完成驗證`,
})
})
}, [socket])
return (
<NotificationList
notifications={notifications}
onMarkAsRead={markAsRead}
/>
)
}
效能優化:
Recharts 進階應用:
// 自訂 Tooltip
const CustomTooltip = ({ active, payload }: any) => {
if (!active || !payload?.length) return null
return (
<Paper p="sm" shadow="md">
<Text size="sm" weight={500}>
{format(new Date(payload[0].payload.date), 'yyyy/MM/dd')}
</Text>
<Group spacing="xs" mt="xs">
<Badge color="blue">發送: {payload[0].value}</Badge>
<Badge color="green">成功: {payload[1].value}</Badge>
<Badge color="red">失敗: {payload[2].value}</Badge>
</Group>
</Paper>
)
}
// 互動式圖表
const OTPStatsChart = ({ data }: { data: ChartData[] }) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null)
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip content={<CustomTooltip />} />
<Legend />
<Line
type="monotone"
dataKey="sent"
stroke="#228be6"
strokeWidth={2}
activeDot={{ r: 8 }}
/>
<Line
type="monotone"
dataKey="success"
stroke="#40c057"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="failed"
stroke="#fa5252"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
)
}
即時數據更新:
// 使用 TanStack Query 的 refetchInterval
const { data: stats } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: fetchDashboardStats,
refetchInterval: 30000, // 30 秒自動刷新
refetchIntervalInBackground: false, // 背景不刷新,節省資源
})
// WebSocket 即時更新
useEffect(() => {
if (!socket) return
socket.on('stats:updated', (newStats) => {
queryClient.setQueryData(['dashboard-stats'], newStats)
})
}, [socket])
效能優化技巧:
useMemo
快取圖表數據計算單元測試 (Vitest):
// Component 測試
describe('OTPForm', () => {
it('should validate phone number format', async () => {
const { getByRole, getByText } = render(<OTPForm />)
const input = getByRole('textbox', { name: /phone/i })
const submitButton = getByRole('button', { name: /send/i })
// 輸入無效號碼
fireEvent.change(input, { target: { value: '123' } })
fireEvent.click(submitButton)
expect(getByText(/invalid phone number/i)).toBeInTheDocument()
})
it('should call API when form is valid', async () => {
const mockSendOTP = vi.fn()
const { getByRole } = render(<OTPForm onSubmit={mockSendOTP} />)
fireEvent.change(getByRole('textbox'), {
target: { value: '+886912345678' },
})
fireEvent.click(getByRole('button'))
await waitFor(() => {
expect(mockSendOTP).toHaveBeenCalledWith('+886912345678')
})
})
})
E2E 測試 (Playwright):
// 完整用戶流程測試
test('user can complete OTP verification flow', async ({ page }) => {
// 1. 造訪首頁
await page.goto('http://localhost:5173')
// 2. 點擊登入
await page.click('text=登入')
// 3. 輸入手機號碼
await page.fill('input[name="phoneNumber"]', '+886912345678')
await page.click('button:has-text("發送驗證碼")')
// 4. 等待 API 回應
await page.waitForSelector('text=驗證碼已發送')
// 5. 輸入驗證碼
await page.fill('input[name="code"]', '123456')
await page.click('button:has-text("驗證")')
// 6. 驗證成功後應導向儀表板
await expect(page).toHaveURL(/.*dashboard/)
await expect(page.locator('h1')).toContainText('儀表板')
})
無障礙測試 (axe-core):
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
test('should not have accessibility violations', async () => {
const { container } = render(<App />)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
測試覆蓋率目標:
問題: 在 Mantine 與 Ant Design、Chakra UI 之間猶豫不決
考量因素:
最終決策: Mantine
學到的經驗:
不要盲目追求「最流行」的方案,要根據專案需求選擇最適合的工具。做 POC (Proof of Concept) 是很好的驗證方式。
問題: 初版儀表板載入時間超過 5 秒,使用者體驗不佳
分析過程:
# 使用 Vite Bundle Analyzer
npm run build -- --analyze
# 發現問題:
# 1. 所有依賴打包在一起 (vendor.js 3.5MB)
# 2. 沒有 code splitting
# 3. 圖表庫沒有 tree-shaking
解決方案:
// 路由層級的 lazy loading
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Analytics = lazy(() => import('./pages/Analytics'))
const Settings = lazy(() => import('./pages/Settings'))
const App = () => (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
)
// vite.config.ts
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-ui': ['@mantine/core', '@mantine/hooks', '@mantine/form'],
'vendor-charts': ['recharts'],
'vendor-query': ['@tanstack/react-query'],
},
},
},
}
// 只在需要時載入圖表元件
const ChartsSection = lazy(() => import('./components/ChartsSection'))
const Analytics = () => {
const [showCharts, setShowCharts] = useState(false)
return (
<div>
<Button onClick={() => setShowCharts(true)}>
顯示圖表分析
</Button>
{showCharts && (
<Suspense fallback={<Skeleton height={400} />}>
<ChartsSection />
</Suspense>
)}
</div>
)
}
優化結果:
問題: 安全性與便利性的取捨
具體情境:
解決方案:
// Axios Interceptor 自動刷新 token
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const newToken = await refreshAccessToken()
setAccessToken(newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
return axios(originalRequest)
} catch (refreshError) {
// Refresh token 也過期,導向登入頁
logout()
return Promise.reject(refreshError)
}
}
return Promise.reject(error)
}
)
// 延長 Refresh Token 有效期
const login = async (credentials: LoginData, rememberMe: boolean) => {
const response = await api.post('/auth/login', {
...credentials,
refreshTokenTTL: rememberMe ? '30d' : '7d',
})
if (rememberMe) {
localStorage.setItem('rememberMe', 'true')
}
}
// 偵測使用者活動,延長 session
const useActivityDetection = () => {
useEffect(() => {
let lastActivity = Date.now()
const updateActivity = () => {
lastActivity = Date.now()
}
const checkInactivity = setInterval(() => {
const inactiveTime = Date.now() - lastActivity
if (inactiveTime > 30 * 60 * 1000) { // 30 分鐘無活動
logout()
}
}, 60 * 1000) // 每分鐘檢查
window.addEventListener('mousemove', updateActivity)
window.addEventListener('keypress', updateActivity)
return () => {
clearInterval(checkInactivity)
window.removeEventListener('mousemove', updateActivity)
window.removeEventListener('keypress', updateActivity)
}
}, [])
}
結果:
基於這 30 天的經驗,我學到:
✅ DO:
- 專注核心功能 (對 Kyo System 來說就是 OTP 發送與驗證)
- 選擇熟悉的技術棧
- 快速上線,收集使用者反饋
❌ DON'T:
- 一開始就想做完美的架構
- 追求最新、最酷的技術
- 過度設計,想太多邊緣情境
Git Commit 規範:
feat: 新功能
fix: 修復 bug
docs: 文件更新
style: 程式碼格式調整
refactor: 重構
test: 測試相關
chore: 雜項 (依賴更新等)
範例:
feat(auth): add MFA support with TOTP
fix(dashboard): resolve chart rendering issue on Safari
Code Review 檢查清單:
// 好的 UX 設計
const SendOTPButton = () => {
const [loading, setLoading] = useState(false)
const [countdown, setCountdown] = useState(0)
const handleSend = async () => {
setLoading(true)
try {
await sendOTP()
showNotification({ message: '驗證碼已發送' })
setCountdown(60) // 60 秒後才能再次發送
} catch (error) {
showNotification({
color: 'red',
message: error.message || '發送失敗,請稍後再試',
})
} finally {
setLoading(false)
}
}
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
return () => clearTimeout(timer)
}
}, [countdown])
return (
<Button
onClick={handleSend}
loading={loading}
disabled={countdown > 0}
>
{countdown > 0 ? `重新發送 (${countdown}s)` : '發送驗證碼'}
</Button>
)
}
UX 關鍵點:
根據我的經驗,推薦以下學習路徑:
Level 1: 基礎
├─ HTML/CSS/JavaScript
├─ Git 版本控制
├─ Chrome DevTools
└─ 基礎演算法與資料結構
Level 2: 框架入門
├─ React 基礎
├─ TypeScript
├─ 狀態管理 (Zustand/Redux)
└─ 路由 (React Router)
Level 3: 進階開發
├─ 建構工具 (Vite/Webpack)
├─ 測試 (Vitest, Playwright)
├─ 效能優化
└─ 無障礙設計
Level 4: 專業領域
├─ 伺服器端渲染 (Next.js)
├─ 效能監控與分析
└─ 安全性最佳實踐
Level 5: 全端與架構
├─ Node.js 後端
├─ 雲端服務 (AWS/GCP)
├─ CI/CD
└─ 系統設計
推薦資源:
❌ 不好的做法 (過度抽象):
// 為了一個簡單的 button 創建複雜的抽象層
const ButtonFactory = createFactory({
strategies: {
primary: PrimaryButtonStrategy,
secondary: SecondaryButtonStrategy,
},
decorators: [LoadingDecorator, DisabledDecorator],
middleware: [ValidationMiddleware, AnalyticsMiddleware],
})
✅ 好的做法 (簡單直接):
const Button = ({ variant, loading, onClick, children }) => (
<button
className={`btn btn-${variant}`}
disabled={loading}
onClick={onClick}
>
{loading ? <Spinner /> : children}
</button>
)
原則: KISS (Keep It Simple, Stupid) - 優先選擇簡單的解決方案
❌ 效能陷阱:
const Dashboard = () => {
// 每次 render 都重新創建 function
const handleClick = () => {
console.log('clicked')
}
// 每次 render 都重新計算
const expensiveValue = calculateExpensiveValue(data)
return <ChildComponent onClick={handleClick} value={expensiveValue} />
}
✅ 效能優化:
const Dashboard = () => {
// 使用 useCallback 快取 function
const handleClick = useCallback(() => {
console.log('clicked')
}, [])
// 使用 useMemo 快取計算結果
const expensiveValue = useMemo(
() => calculateExpensiveValue(data),
[data]
)
return <ChildComponent onClick={handleClick} value={expensiveValue} />
}
❌ 安全漏洞:
// XSS 漏洞
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// CSRF 漏洞
fetch('/api/transfer', {
method: 'POST',
body: JSON.stringify({ amount: 1000 }),
})
✅ 安全實踐:
// 使用安全的 HTML sanitizer
import DOMPurify from 'dompurify'
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />
// 加入 CSRF token
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-Token': getCsrfToken(),
},
body: JSON.stringify({ amount: 1000 }),
})
❌ 缺乏錯誤處理:
const UserProfile = () => {
const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser })
return <div>{data.name}</div> // data 可能是 undefined!
}
✅ 完整的錯誤處理:
const UserProfile = () => {
const { data, isLoading, error } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
})
if (isLoading) return <Skeleton />
if (error) return <ErrorMessage error={error} />
if (!data) return <EmptyState />
return <div>{data.name}</div>
}
┌─────────────────────────────────────────────────────────────┐
│ Kyo System Frontend │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Landing │ │ Auth │ │Dashboard│
│ Page │ │ Pages │ │ Pages │
└─────────┘ └─────────┘ └─────────┘
│ │ │
└─────────────────────┼─────────────────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ UI │ │ State │ │ Data │
│ Layer │ │ Manager │ │ Layer │
└─────────┘ └─────────┘ └─────────┘
│ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐
│ Mantine │ │ Zustand │ │ React │
│ UI │ │ │ │ Query │
└─────────┘ └─────────┘ └─────────┘
│
│
┌─────────┴─────────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ HTTP │ │ WebSocket│
│ API │ │ Socket │
└──────────┘ └──────────┘
│ │
└─────────┬─────────┘
│
▼
┌──────────────────┐
│ Backend API │
│ (Fastify + TS) │
└──────────────────┘
src/
├── app/
│ ├── App.tsx # 應用程式進入點
│ ├── Router.tsx # 路由設定
│ └── providers/ # Context Providers
│ ├── QueryProvider.tsx # React Query
│ ├── ThemeProvider.tsx # Mantine Theme
│ └── AuthProvider.tsx # 認證 Context
│
├── pages/
│ ├── landing/
│ │ ├── LandingPage.tsx
│ │ ├── HeroSection.tsx
│ │ ├── FeaturesSection.tsx
│ │ └── PricingSection.tsx
│ │
│ ├── auth/
│ │ ├── LoginPage.tsx
│ │ ├── RegisterPage.tsx
│ │ └── MFASetupPage.tsx
│ │
│ └── dashboard/
│ ├── DashboardPage.tsx
│ ├── AnalyticsPage.tsx
│ └── SettingsPage.tsx
│
├── components/
│ ├── ui/ # 共用 UI 元件
│ │ ├── Button.tsx
│ │ ├── Input.tsx
│ │ └── Card.tsx
│ │
│ ├── charts/ # 圖表元件
│ │ ├── LineChart.tsx
│ │ ├── BarChart.tsx
│ │ └── PieChart.tsx
│ │
│ └── layout/ # 版面元件
│ ├── Header.tsx
│ ├── Sidebar.tsx
│ └── Footer.tsx
│
├── features/ # 功能模組
│ ├── otp/
│ │ ├── components/
│ │ │ ├── OTPForm.tsx
│ │ │ └── OTPVerifyForm.tsx
│ │ ├── hooks/
│ │ │ └── useOTP.ts
│ │ └── api/
│ │ └── otpApi.ts
│ │
│ ├── notifications/
│ │ ├── components/
│ │ │ └── NotificationCenter.tsx
│ │ ├── hooks/
│ │ │ └── useWebSocket.ts
│ │ └── store/
│ │ └── notificationStore.ts
│ │
│ └── analytics/
│ ├── components/
│ │ └── StatsCard.tsx
│ └── hooks/
│ └── useAnalytics.ts
│
├── stores/ # Zustand stores
│ ├── authStore.ts
│ ├── uiStore.ts
│ └── notificationStore.ts
│
├── hooks/ # 共用 Hooks
│ ├── useAuth.ts
│ ├── useLocalStorage.ts
│ └── useDebounce.ts
│
├── lib/ # 工具函數
│ ├── api/
│ │ ├── client.ts # Axios instance
│ │ └── endpoints.ts # API endpoints
│ │
│ ├── utils/
│ │ ├── format.ts # 格式化函數
│ │ ├── validation.ts # 驗證函數
│ │ └── storage.ts # LocalStorage 封裝
│ │
│ └── constants/
│ ├── routes.ts # 路由常數
│ └── config.ts # 設定檔
│
└── types/ # TypeScript 型別定義
├── api.ts
├── models.ts
└── common.ts
每個功能 (feature) 都是獨立的模組,包含:
優點:
建立內部元件庫 @kyong/kyo-ui
:
// packages/kyo-ui/src/Button/Button.tsx
export const Button = ({ variant, size, loading, children, ...props }) => {
return (
<button
className={clsx(
'kyo-button',
`kyo-button--${variant}`,
`kyo-button--${size}`,
{ 'kyo-button--loading': loading }
)}
{...props}
>
{loading && <Spinner />}
{children}
</button>
)
}
// 在應用中使用
import { Button } from '@kyong/kyo-ui'
優點:
// lib/api/client.ts
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000,
})
// lib/api/endpoints.ts
export const endpoints = {
auth: {
login: '/auth/login',
register: '/auth/register',
refresh: '/auth/refresh',
},
otp: {
send: '/otp/send',
verify: '/otp/verify',
},
} as const
// features/otp/api/otpApi.ts
export const otpApi = {
send: (data: SendOTPData) =>
apiClient.post(endpoints.otp.send, data),
verify: (data: VerifyOTPData) =>
apiClient.post(endpoints.otp.verify, data),
}
優點:
// lib/constants/config.ts
export const config = {
apiUrl: import.meta.env.VITE_API_URL,
wsUrl: import.meta.env.VITE_WS_URL,
environment: import.meta.env.MODE,
isDevelopment: import.meta.env.DEV,
isProduction: import.meta.env.PROD,
features: {
enableMFA: import.meta.env.VITE_ENABLE_MFA === 'true',
enableAnalytics: import.meta.env.VITE_ENABLE_ANALYTICS === 'true',
},
}
// 使用 feature flag
const SettingsPage = () => {
return (
<div>
<h1>設定</h1>
{config.features.enableMFA && (
<MFASettings />
)}
{config.features.enableAnalytics && (
<AnalyticsSettings />
)}
</div>
)
}
// 未來可能的架構
// app/dashboard/page.tsx (Server Component)
async function DashboardPage() {
// 在 server 端獲取資料
const stats = await fetchDashboardStats()
return (
<div>
<h1>儀表板</h1>
{/* Client Component 處理互動 */}
<StatsChart data={stats} />
</div>
)
}
優點:
// 使用 const type parameters
function processData<const T extends readonly string[]>(data: T) {
return data
}
const result = processData(['a', 'b', 'c'] as const)
// type: ['a', 'b', 'c']
// 使用 satisfies operator
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
} satisfies Config
Web Vitals 監控:
import { onCLS, onFID, onLCP } from 'web-vitals'
onCLS(console.log) // Cumulative Layout Shift
onFID(console.log) // First Input Delay
onLCP(console.log) // Largest Contentful Paint
// 上傳到分析服務
function sendToAnalytics(metric) {
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(metric),
})
}
Resource Hints:
<!-- DNS Prefetch -->
<link rel="dns-prefetch" href="https://api.example.com">
<!-- Preconnect -->
<link rel="preconnect" href="https://api.example.com">
<!-- Prefetch -->
<link rel="prefetch" href="/dashboard">
<!-- Preload -->
<link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
30 天的鐵人賽挑戰終於來到尾聲。
回顧這段旅程,從第一天的專案規劃、技術選型,到最後完成一個可以實際運作的 SaaS 產品前端,這個過程充滿了學習與成長。
雖然我已經有八年的開發經驗,但這次從零開始打造 SaaS 產品的經驗,讓我對前端開發有了更深層的理解。以前在公司工作時,框架和架構都已經建立好了,這次自己從頭設計,才發現有太多細節需要考量:
這些問題在既有專案中可能不會遇到,但在從零開始時,每一個決策都需要深思熟慮。
技術日新月異,前端領域更是如此。React 19、TypeScript 5.x、新的建構工具、新的框架,層出不窮。保持學習的熱情,持續精進自己的技能,是我們避免被AI淘汰的方法。
這次鐵人賽對我來說,不只是一個挑戰,更是一個整理知識、深化理解的過程。透過寫作,我對前端技術有了更系統化的認識,加上自己在前端這條路上走得很淺,也發現了許多之前忽略的細節和需要學習的知識。
Kyo System 的前端架構已經成功建立,這套架構不僅可以用在 OTP 服務,未來也可以擴展到工作室的其他 SaaS 產品。這是這次鐵人賽最大的成果。
前端開發是一個既有挑戰又有趣的領域。從設計美觀的介面,到優化使用者體驗,再到打造高效能的應用,每一個環節都值得深入研究。