642 lines
15 KiB
Markdown
642 lines
15 KiB
Markdown
---
|
||
name: frontend-patterns
|
||
description: 針對 React、Next.js、狀態管理、效能優化以及 UI 最佳實踐的前端開發模式。
|
||
---
|
||
|
||
# 前端開發模式 (Frontend Development Patterns)
|
||
|
||
適用於 React、Next.js 及高效能使用者介面的現代化前端開發模式。
|
||
|
||
## 何時啟用
|
||
|
||
- 建構 React 組件 (如:組合、Props 傳遞、渲染)。
|
||
- 管理狀態 (useState, useReducer, Zustand, Context)。
|
||
- 實作資料獲取 (SWR, React Query, Server Components)。
|
||
- 優化效能 (Memoization, 虛擬化、程式碼分割)。
|
||
- 處理表單 (驗證、受控輸入、Zod Schema)。
|
||
- 處理客戶端路由與導覽。
|
||
- 建構具備無障礙功能 (A11y) 與響應式設計的 UI 模式。
|
||
|
||
## 組件模式 (Component Patterns)
|
||
|
||
### 組合優於繼承 (Composition Over Inheritance)
|
||
|
||
```typescript
|
||
// ✅ 推薦:組件組合
|
||
interface CardProps {
|
||
children: React.ReactNode
|
||
variant?: 'default' | 'outlined'
|
||
}
|
||
|
||
export function Card({ children, variant = 'default' }: CardProps) {
|
||
return <div className={`card card-${variant}`}>{children}</div>
|
||
}
|
||
|
||
export function CardHeader({ children }: { children: React.ReactNode }) {
|
||
return <div className="card-header">{children}</div>
|
||
}
|
||
|
||
export function CardBody({ children }: { children: React.ReactNode }) {
|
||
return <div className="card-body">{children}</div>
|
||
}
|
||
|
||
// 用法
|
||
<Card>
|
||
<CardHeader>標題</CardHeader>
|
||
<CardBody>內容</CardBody>
|
||
</Card>
|
||
```
|
||
|
||
### 複合組件 (Compound Components)
|
||
|
||
```typescript
|
||
interface TabsContextValue {
|
||
activeTab: string
|
||
setActiveTab: (tab: string) => void
|
||
}
|
||
|
||
const TabsContext = createContext<TabsContextValue | undefined>(undefined)
|
||
|
||
export function Tabs({ children, defaultTab }: {
|
||
children: React.ReactNode
|
||
defaultTab: string
|
||
}) {
|
||
const [activeTab, setActiveTab] = useState(defaultTab)
|
||
|
||
return (
|
||
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
|
||
{children}
|
||
</TabsContext.Provider>
|
||
)
|
||
}
|
||
|
||
export function TabList({ children }: { children: React.ReactNode }) {
|
||
return <div className="tab-list">{children}</div>
|
||
}
|
||
|
||
export function Tab({ id, children }: { id: string, children: React.ReactNode }) {
|
||
const context = useContext(TabsContext)
|
||
if (!context) throw new Error('Tab 必須在 Tabs 內使用')
|
||
|
||
return (
|
||
<button
|
||
className={context.activeTab === id ? 'active' : ''}
|
||
onClick={() => context.setActiveTab(id)}
|
||
>
|
||
{children}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// 用法
|
||
<Tabs defaultTab="overview">
|
||
<TabList>
|
||
<Tab id="overview">概覽</Tab>
|
||
<Tab id="details">詳情</Tab>
|
||
</TabList>
|
||
</Tabs>
|
||
```
|
||
|
||
### 渲染 Props 模式 (Render Props Pattern)
|
||
|
||
```typescript
|
||
interface DataLoaderProps<T> {
|
||
url: string
|
||
children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode
|
||
}
|
||
|
||
export function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
|
||
const [data, setData] = useState<T | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<Error | null>(null)
|
||
|
||
useEffect(() => {
|
||
fetch(url)
|
||
.then(res => res.json())
|
||
.then(setData)
|
||
.catch(setError)
|
||
.finally(() => setLoading(false))
|
||
}, [url])
|
||
|
||
return <>{children(data, loading, error)}</>
|
||
}
|
||
|
||
// 用法
|
||
<DataLoader<Market[]> url="/api/markets">
|
||
{(markets, loading, error) => {
|
||
if (loading) return <Spinner />
|
||
if (error) return <Error error={error} />
|
||
return <MarketList markets={markets!} />
|
||
}}
|
||
</DataLoader>
|
||
```
|
||
|
||
## 自定義 Hook 模式
|
||
|
||
### 狀態管理 Hook
|
||
|
||
```typescript
|
||
export function useToggle(initialValue = false): [boolean, () => void] {
|
||
const [value, setValue] = useState(initialValue)
|
||
|
||
const toggle = useCallback(() => {
|
||
setValue(v => !v)
|
||
}, [])
|
||
|
||
return [value, toggle]
|
||
}
|
||
|
||
// 用法
|
||
const [isOpen, toggleOpen] = useToggle()
|
||
```
|
||
|
||
### 非同步資料獲取 Hook
|
||
|
||
```typescript
|
||
interface UseQueryOptions<T> {
|
||
onSuccess?: (data: T) => void
|
||
onError?: (error: Error) => void
|
||
enabled?: boolean
|
||
}
|
||
|
||
export function useQuery<T>(
|
||
key: string,
|
||
fetcher: () => Promise<T>,
|
||
options?: UseQueryOptions<T>
|
||
) {
|
||
const [data, setData] = useState<T | null>(null)
|
||
const [error, setError] = useState<Error | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
|
||
const refetch = useCallback(async () => {
|
||
setLoading(true)
|
||
setError(null)
|
||
|
||
try {
|
||
const result = await fetcher()
|
||
setData(result)
|
||
options?.onSuccess?.(result)
|
||
} catch (err) {
|
||
const error = err as Error
|
||
setError(error)
|
||
options?.onError?.(error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [fetcher, options])
|
||
|
||
useEffect(() => {
|
||
if (options?.enabled !== false) {
|
||
refetch()
|
||
}
|
||
}, [key, refetch, options?.enabled])
|
||
|
||
return { data, error, loading, refetch }
|
||
}
|
||
|
||
// 用法
|
||
const { data: markets, loading, error, refetch } = useQuery(
|
||
'markets',
|
||
() => fetch('/api/markets').then(r => r.json()),
|
||
{
|
||
onSuccess: data => console.log('已獲取', data.length, '筆市場資料'),
|
||
onError: err => console.error('失敗:', err)
|
||
}
|
||
)
|
||
```
|
||
|
||
### 防抖 (Debounce) Hook
|
||
|
||
```typescript
|
||
export function useDebounce<T>(value: T, delay: number): T {
|
||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||
|
||
useEffect(() => {
|
||
const handler = setTimeout(() => {
|
||
setDebouncedValue(value)
|
||
}, delay)
|
||
|
||
return () => clearTimeout(handler)
|
||
}, [value, delay])
|
||
|
||
return debouncedValue
|
||
}
|
||
|
||
// 用法
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const debouncedQuery = useDebounce(searchQuery, 500)
|
||
|
||
useEffect(() => {
|
||
if (debouncedQuery) {
|
||
performSearch(debouncedQuery)
|
||
}
|
||
}, [debouncedQuery])
|
||
```
|
||
|
||
## 狀態管理模式
|
||
|
||
### Context + Reducer 模式
|
||
|
||
```typescript
|
||
interface State {
|
||
markets: Market[]
|
||
selectedMarket: Market | null
|
||
loading: boolean
|
||
}
|
||
|
||
type Action =
|
||
| { type: 'SET_MARKETS'; payload: Market[] }
|
||
| { type: 'SELECT_MARKET'; payload: Market }
|
||
| { type: 'SET_LOADING'; payload: boolean }
|
||
|
||
function reducer(state: State, action: Action): State {
|
||
switch (action.type) {
|
||
case 'SET_MARKETS':
|
||
return { ...state, markets: action.payload }
|
||
case 'SELECT_MARKET':
|
||
return { ...state, selectedMarket: action.payload }
|
||
case 'SET_LOADING':
|
||
return { ...state, loading: action.payload }
|
||
default:
|
||
return state
|
||
}
|
||
}
|
||
|
||
const MarketContext = createContext<{
|
||
state: State
|
||
dispatch: Dispatch<Action>
|
||
} | undefined>(undefined)
|
||
|
||
export function MarketProvider({ children }: { children: React.ReactNode }) {
|
||
const [state, dispatch] = useReducer(reducer, {
|
||
markets: [],
|
||
selectedMarket: null,
|
||
loading: false
|
||
})
|
||
|
||
return (
|
||
<MarketContext.Provider value={{ state, dispatch }}>
|
||
{children}
|
||
</MarketContext.Provider>
|
||
)
|
||
}
|
||
|
||
export function useMarkets() {
|
||
const context = useContext(MarketContext)
|
||
if (!context) throw new Error('useMarkets 必須在 MarketProvider 內使用')
|
||
return context
|
||
}
|
||
```
|
||
|
||
## 效能優化 (Performance Optimization)
|
||
|
||
### 備忘錄化 (Memoization)
|
||
|
||
```typescript
|
||
// ✅ 使用 useMemo 處理成本高昂的計算
|
||
const sortedMarkets = useMemo(() => {
|
||
return markets.sort((a, b) => b.volume - a.volume)
|
||
}, [markets])
|
||
|
||
// ✅ 使用 useCallback 處理傳遞給子組件的函式
|
||
const handleSearch = useCallback((query: string) => {
|
||
setSearchQuery(query)
|
||
}, [])
|
||
|
||
// ✅ 使用 React.memo 處理純組件
|
||
export const MarketCard = React.memo<MarketCardProps>(({ market }) => {
|
||
return (
|
||
<div className="market-card">
|
||
<h3>{market.name}</h3>
|
||
<p>{market.description}</p>
|
||
</div>
|
||
)
|
||
})
|
||
```
|
||
|
||
### 程式碼分割與延遲載入 (Code Splitting & Lazy Loading)
|
||
|
||
```typescript
|
||
import { lazy, Suspense } from 'react'
|
||
|
||
// ✅ 延遲載入重量級組件
|
||
const HeavyChart = lazy(() => import('./HeavyChart'))
|
||
const ThreeJsBackground = lazy(() => import('./ThreeJsBackground'))
|
||
|
||
export function Dashboard() {
|
||
return (
|
||
<div>
|
||
<Suspense fallback={<ChartSkeleton />}>
|
||
<HeavyChart data={data} />
|
||
</Suspense>
|
||
|
||
<Suspense fallback={null}>
|
||
<ThreeJsBackground />
|
||
</Suspense>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 長列表虛擬化 (Virtualization)
|
||
|
||
```typescript
|
||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||
|
||
export function VirtualMarketList({ markets }: { markets: Market[] }) {
|
||
const parentRef = useRef<HTMLDivElement>(null)
|
||
|
||
const virtualizer = useVirtualizer({
|
||
count: markets.length,
|
||
getScrollElement: () => parentRef.current,
|
||
estimateSize: () => 100, // 預估行高
|
||
overscan: 5 // 額外渲染的項目數量
|
||
})
|
||
|
||
return (
|
||
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
|
||
<div
|
||
style={{
|
||
height: `${virtualizer.getTotalSize()}px`,
|
||
position: 'relative'
|
||
}}
|
||
>
|
||
{virtualizer.getVirtualItems().map(virtualRow => (
|
||
<div
|
||
key={virtualRow.index}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
width: '100%',
|
||
height: `${virtualRow.size}px`,
|
||
transform: `translateY(${virtualRow.start}px)`
|
||
}}
|
||
>
|
||
<MarketCard market={markets[virtualRow.index]} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
## 表單處理模式
|
||
|
||
### 帶驗證的受控表單 (Controlled Form)
|
||
|
||
```typescript
|
||
interface FormData {
|
||
name: string
|
||
description: string
|
||
endDate: string
|
||
}
|
||
|
||
interface FormErrors {
|
||
name?: string
|
||
description?: string
|
||
endDate?: string
|
||
}
|
||
|
||
export function CreateMarketForm() {
|
||
const [formData, setFormData] = useState<FormData>({
|
||
name: '',
|
||
description: '',
|
||
endDate: ''
|
||
})
|
||
|
||
const [errors, setErrors] = useState<FormErrors>({})
|
||
|
||
const validate = (): boolean => {
|
||
const newErrors: FormErrors = {}
|
||
|
||
if (!formData.name.trim()) {
|
||
newErrors.name = '名稱為必填項'
|
||
} else if (formData.name.length > 200) {
|
||
newErrors.name = '名稱必須在 200 字元以內'
|
||
}
|
||
|
||
if (!formData.description.trim()) {
|
||
newErrors.description = '描述為必填項'
|
||
}
|
||
|
||
if (!formData.endDate) {
|
||
newErrors.endDate = '結束日期為必填項'
|
||
}
|
||
|
||
setErrors(newErrors)
|
||
return Object.keys(newErrors).length === 0
|
||
}
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
|
||
if (!validate()) return
|
||
|
||
try {
|
||
await createMarket(formData)
|
||
// 成功處理
|
||
} catch (error) {
|
||
// 錯誤處理
|
||
}
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit}>
|
||
<input
|
||
value={formData.name}
|
||
onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||
placeholder="市場名稱"
|
||
/>
|
||
{errors.name && <span className="error">{errors.name}</span>}
|
||
|
||
{/* 其他欄位 */}
|
||
|
||
<button type="submit">建立市場</button>
|
||
</form>
|
||
)
|
||
}
|
||
```
|
||
|
||
## 錯誤邊界模式 (Error Boundary Pattern)
|
||
|
||
```typescript
|
||
interface ErrorBoundaryState {
|
||
hasError: boolean
|
||
error: Error | null
|
||
}
|
||
|
||
export class ErrorBoundary extends React.Component<
|
||
{ children: React.ReactNode },
|
||
ErrorBoundaryState
|
||
> {
|
||
state: ErrorBoundaryState = {
|
||
hasError: false,
|
||
error: null
|
||
}
|
||
|
||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||
return { hasError: true, error }
|
||
}
|
||
|
||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||
console.error('錯誤邊界擷取到錯誤:', error, errorInfo)
|
||
}
|
||
|
||
render() {
|
||
if (this.state.hasError) {
|
||
return (
|
||
<div className="error-fallback">
|
||
<h2>發生了一些錯誤</h2>
|
||
<p>{this.state.error?.message}</p>
|
||
<button onClick={() => this.setState({ hasError: false })}>
|
||
重試
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return this.props.children
|
||
}
|
||
}
|
||
|
||
// 用法
|
||
<ErrorBoundary>
|
||
<App />
|
||
</ErrorBoundary>
|
||
```
|
||
|
||
## 動畫模式
|
||
|
||
### Framer Motion 動畫
|
||
|
||
```typescript
|
||
import { motion, AnimatePresence } from 'framer-motion'
|
||
|
||
// ✅ 列表動畫
|
||
export function AnimatedMarketList({ markets }: { markets: Market[] }) {
|
||
return (
|
||
<AnimatePresence>
|
||
{markets.map(market => (
|
||
<motion.div
|
||
key={market.id}
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -20 }}
|
||
transition={{ duration: 0.3 }}
|
||
>
|
||
<MarketCard market={market} />
|
||
</motion.div>
|
||
))}
|
||
</AnimatePresence>
|
||
)
|
||
}
|
||
|
||
// ✅ 互動彈窗 (Modal) 動畫
|
||
export function Modal({ isOpen, onClose, children }: ModalProps) {
|
||
return (
|
||
<AnimatePresence>
|
||
{isOpen && (
|
||
<>
|
||
<motion.div
|
||
className="modal-overlay"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
onClick={onClose}
|
||
/>
|
||
<motion.div
|
||
className="modal-content"
|
||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||
>
|
||
{children}
|
||
</motion.div>
|
||
</>
|
||
)}
|
||
</AnimatePresence>
|
||
)
|
||
}
|
||
```
|
||
|
||
## 無障礙功能 (Accessibility) 模式
|
||
|
||
### 鍵盤導覽 (Keyboard Navigation)
|
||
|
||
```typescript
|
||
export function Dropdown({ options, onSelect }: DropdownProps) {
|
||
const [isOpen, setIsOpen] = useState(false)
|
||
const [activeIndex, setActiveIndex] = useState(0)
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
switch (e.key) {
|
||
case 'ArrowDown':
|
||
e.preventDefault()
|
||
setActiveIndex(i => Math.min(i + 1, options.length - 1))
|
||
break
|
||
case 'ArrowUp':
|
||
e.preventDefault()
|
||
setActiveIndex(i => Math.max(i - 1, 0))
|
||
break
|
||
case 'Enter':
|
||
e.preventDefault()
|
||
onSelect(options[activeIndex])
|
||
setIsOpen(false)
|
||
break
|
||
case 'Escape':
|
||
setIsOpen(false)
|
||
break
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
role="combobox"
|
||
aria-expanded={isOpen}
|
||
aria-haspopup="listbox"
|
||
onKeyDown={handleKeyDown}
|
||
>
|
||
{/* 下拉選單實作內容 */}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 焦點管理 (Focus Management)
|
||
|
||
```typescript
|
||
export function Modal({ isOpen, onClose, children }: ModalProps) {
|
||
const modalRef = useRef<HTMLDivElement>(null)
|
||
const previousFocusRef = useRef<HTMLElement | null>(null)
|
||
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
// 儲存目前獲取焦點的元素
|
||
previousFocusRef.current = document.activeElement as HTMLElement
|
||
|
||
// 將焦點移至彈窗
|
||
modalRef.current?.focus()
|
||
} else {
|
||
// 關閉時恢復焦點
|
||
previousFocusRef.current?.focus()
|
||
}
|
||
}, [isOpen])
|
||
|
||
return isOpen ? (
|
||
<div
|
||
ref={modalRef}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
tabIndex={-1}
|
||
onKeyDown={e => e.key === 'Escape' && onClose()}
|
||
>
|
||
{children}
|
||
</div>
|
||
) : null
|
||
}
|
||
```
|
||
|
||
**請記住**:現代前端模式能讓使用者介面更易於維護且更具效能。請根據專案的複雜度選擇適合的模式。
|