# 🎨 前端架構設計 ## 🎯 設計原則 ### 核心理念 - **狀態驅動** - 單一真實來源 (Single Source of Truth) - **組件分離** - 邏輯、展示、容器分離 - **可預測性** - 明確的資料流向 - **可測試性** - 易於單元測試和整合測試 - **效能優化** - 避免不必要的重渲染 --- ## 🏗️ 架構概覽 ``` src/ ├── hooks/ │ ├── useFlashcardSearch.ts # 搜尋狀態管理 │ ├── useUrlSync.ts # URL狀態同步 │ ├── useDebounce.ts # 防抖 hook │ └── useApi.ts # API調用封裝 ├── components/ │ ├── Search/ │ │ ├── SearchControls.tsx # 搜尋控制組件 │ │ ├── FilterPanel.tsx # 篩選面板 │ │ ├── SortControls.tsx # 排序控制 │ │ └── SearchStats.tsx # 搜尋統計 │ ├── Pagination/ │ │ ├── PaginationControls.tsx │ │ └── PageSizeSelector.tsx │ └── FlashcardList/ │ ├── FlashcardGrid.tsx │ ├── FlashcardCard.tsx │ └── EmptyState.tsx ├── types/ │ ├── flashcard.ts │ ├── search.ts │ └── api.ts └── utils/ ├── apiCache.ts ├── searchHelpers.ts └── urlHelpers.ts ``` --- ## 🔄 狀態管理架構 ### 主要搜尋Hook設計 ```typescript // hooks/useFlashcardSearch.ts export interface SearchFilters { search: string; difficultyLevel: string; partOfSpeech: string; masteryLevel: string; favoritesOnly: boolean; createdAfter?: string; createdBefore?: string; reviewCountMin?: number; reviewCountMax?: number; } export interface SortOptions { sortBy: string; sortOrder: 'asc' | 'desc'; } export interface PaginationState { currentPage: number; pageSize: number; totalPages: number; totalCount: number; hasNext: boolean; hasPrev: boolean; } export interface SearchState { // 資料 flashcards: Flashcard[]; pagination: PaginationState; // UI 狀態 loading: boolean; error: string | null; isInitialLoad: boolean; // 搜尋條件 filters: SearchFilters; sorting: SortOptions; // 元數據 lastUpdated: Date | null; cacheHit: boolean; } export interface SearchActions { // 篩選操作 updateFilters: (filters: Partial) => void; clearFilters: () => void; resetFilters: () => void; // 排序操作 updateSorting: (sorting: Partial) => void; toggleSortOrder: () => void; // 分頁操作 goToPage: (page: number) => void; changePageSize: (size: number) => void; goToNextPage: () => void; goToPrevPage: () => void; // 資料操作 refresh: () => Promise; refetch: () => Promise; } export const useFlashcardSearch = (): [SearchState, SearchActions] => { // 狀態管理實作... }; ``` ### 實作細節 #### 1. 核心狀態管理 ```typescript export const useFlashcardSearch = () => { const [state, setState] = useState({ flashcards: [], pagination: { currentPage: 1, pageSize: 20, totalPages: 0, totalCount: 0, hasNext: false, hasPrev: false, }, loading: false, error: null, isInitialLoad: true, filters: { search: '', difficultyLevel: '', partOfSpeech: '', masteryLevel: '', favoritesOnly: false, }, sorting: { sortBy: 'createdAt', sortOrder: 'desc', }, lastUpdated: null, cacheHit: false, }); // 搜尋邏輯 const executeSearch = useCallback(async () => { setState(prev => ({ ...prev, loading: true, error: null })); try { const params = { ...state.filters, ...state.sorting, page: state.pagination.currentPage, limit: state.pagination.pageSize, }; const result = await flashcardsService.getFlashcards(params); if (result.success && result.data) { setState(prev => ({ ...prev, flashcards: result.data.flashcards, pagination: { ...prev.pagination, totalPages: result.data.pagination.total_pages, totalCount: result.data.pagination.total_count, hasNext: result.data.pagination.has_next, hasPrev: result.data.pagination.has_prev, }, loading: false, isInitialLoad: false, lastUpdated: new Date(), cacheHit: result.data.meta?.cache_hit || false, })); } else { setState(prev => ({ ...prev, loading: false, error: result.error || 'Failed to load flashcards', })); } } catch (error) { setState(prev => ({ ...prev, loading: false, error: error instanceof Error ? error.message : 'Unknown error', })); } }, [state.filters, state.sorting, state.pagination.currentPage, state.pagination.pageSize]); // 防抖搜尋 const debouncedSearch = useDebounce(executeSearch, 300); // Actions const updateFilters = useCallback((newFilters: Partial) => { setState(prev => ({ ...prev, filters: { ...prev.filters, ...newFilters }, pagination: { ...prev.pagination, currentPage: 1 }, // 重置頁碼 })); }, []); const updateSorting = useCallback((newSorting: Partial) => { setState(prev => ({ ...prev, sorting: { ...prev.sorting, ...newSorting }, pagination: { ...prev.pagination, currentPage: 1 }, })); }, []); const goToPage = useCallback((page: number) => { setState(prev => ({ ...prev, pagination: { ...prev.pagination, currentPage: page }, })); }, []); const changePageSize = useCallback((pageSize: number) => { setState(prev => ({ ...prev, pagination: { ...prev.pagination, pageSize, currentPage: 1 }, })); }, []); // 自動執行搜尋 useEffect(() => { if (state.filters.search) { debouncedSearch(); } else { executeSearch(); } }, [state.filters, state.sorting, state.pagination.currentPage, state.pagination.pageSize]); return [ state, { updateFilters, updateSorting, goToPage, changePageSize, clearFilters: () => updateFilters({ search: '', difficultyLevel: '', partOfSpeech: '', masteryLevel: '', favoritesOnly: false, }), toggleSortOrder: () => updateSorting({ sortOrder: state.sorting.sortOrder === 'asc' ? 'desc' : 'asc' }), goToNextPage: () => state.pagination.hasNext && goToPage(state.pagination.currentPage + 1), goToPrevPage: () => state.pagination.hasPrev && goToPage(state.pagination.currentPage - 1), refresh: executeSearch, refetch: executeSearch, }, ]; }; ``` #### 2. URL狀態同步 ```typescript // hooks/useUrlSync.ts export const useUrlSync = (searchState: SearchState, searchActions: SearchActions) => { const router = useRouter(); const searchParams = useSearchParams(); // 狀態 -> URL useEffect(() => { const params = new URLSearchParams(); // 只同步有值的參數 if (searchState.filters.search) { params.set('q', searchState.filters.search); } if (searchState.filters.difficultyLevel) { params.set('level', searchState.filters.difficultyLevel); } if (searchState.filters.partOfSpeech) { params.set('pos', searchState.filters.partOfSpeech); } if (searchState.filters.masteryLevel) { params.set('mastery', searchState.filters.masteryLevel); } if (searchState.filters.favoritesOnly) { params.set('favorites', 'true'); } if (searchState.sorting.sortBy !== 'createdAt') { params.set('sort', searchState.sorting.sortBy); } if (searchState.sorting.sortOrder !== 'desc') { params.set('order', searchState.sorting.sortOrder); } if (searchState.pagination.currentPage > 1) { params.set('page', searchState.pagination.currentPage.toString()); } if (searchState.pagination.pageSize !== 20) { params.set('size', searchState.pagination.pageSize.toString()); } const queryString = params.toString(); const newUrl = queryString ? `?${queryString}` : ''; // 避免無限循環 if (window.location.search !== newUrl) { router.push(newUrl, { scroll: false }); } }, [searchState, router]); // URL -> 狀態 (初始化) useEffect(() => { const initialState = { search: searchParams.get('q') || '', difficultyLevel: searchParams.get('level') || '', partOfSpeech: searchParams.get('pos') || '', masteryLevel: searchParams.get('mastery') || '', favoritesOnly: searchParams.get('favorites') === 'true', }; const initialSorting = { sortBy: searchParams.get('sort') || 'createdAt', sortOrder: (searchParams.get('order') as 'asc' | 'desc') || 'desc', }; const initialPage = parseInt(searchParams.get('page') || '1'); const initialPageSize = parseInt(searchParams.get('size') || '20'); // 只在初始化時設定 if (searchState.isInitialLoad) { searchActions.updateFilters(initialState); searchActions.updateSorting(initialSorting); if (initialPage > 1) { searchActions.goToPage(initialPage); } if (initialPageSize !== 20) { searchActions.changePageSize(initialPageSize); } } }, [searchParams, searchState.isInitialLoad]); }; ``` #### 3. 組件架構設計 ```typescript // components/Search/SearchControls.tsx interface SearchControlsProps { filters: SearchFilters; sorting: SortOptions; onFiltersChange: (filters: Partial) => void; onSortingChange: (sorting: Partial) => void; onClearFilters: () => void; loading?: boolean; } export const SearchControls: React.FC = ({ filters, sorting, onFiltersChange, onSortingChange, onClearFilters, loading = false, }) => { const [showAdvanced, setShowAdvanced] = useState(false); return (
{/* 基本搜尋 */}
onFiltersChange({ search })} placeholder="搜尋詞彙、翻譯或定義..." loading={loading} />
{/* 進階篩選 */} {showAdvanced && ( )} {/* 切換按鈕 */}
); }; ``` #### 4. API服務擴展 ```typescript // lib/services/flashcards.ts (擴展版本) export interface FlashcardQueryParams extends SearchFilters, SortOptions { page?: number; limit?: number; } export interface FlashcardQueryResponse { flashcards: Flashcard[]; pagination: { current_page: number; total_pages: number; total_count: number; page_size: number; has_next: boolean; has_prev: boolean; }; meta?: { query_time_ms: number; cache_hit: boolean; }; } class FlashcardsService { private cache = new Map(); async getFlashcards(params: FlashcardQueryParams): Promise> { try { // 建構查詢參數 const queryParams = new URLSearchParams(); // 只添加有值的參數 Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== '' && value !== false) { if (typeof value === 'boolean') { queryParams.append(key, 'true'); } else { queryParams.append(key, value.toString()); } } }); const queryString = queryParams.toString(); const endpoint = `/flashcards${queryString ? `?${queryString}` : ''}`; // 檢查快取 const cachedResult = this.getFromCache(endpoint); if (cachedResult) { return { success: true, data: { ...cachedResult, meta: { ...cachedResult.meta, cache_hit: true } } }; } // API調用 const response = await this.makeRequest>(endpoint); // 快取結果 if (response.success && response.data) { this.saveToCache(endpoint, response.data); } return response; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to fetch flashcards', }; } } private getFromCache(key: string): FlashcardQueryResponse | null { const entry = this.cache.get(key); if (entry && Date.now() - entry.timestamp < 300000) { // 5分鐘快取 return entry.data; } return null; } private saveToCache(key: string, data: FlashcardQueryResponse): void { this.cache.set(key, { data, timestamp: Date.now(), }); } } ``` --- ## 🧪 測試策略 ### 單元測試 ```typescript // __tests__/hooks/useFlashcardSearch.test.ts describe('useFlashcardSearch', () => { it('should initialize with default state', () => { const { result } = renderHook(() => useFlashcardSearch()); const [state] = result.current; expect(state.loading).toBe(false); expect(state.flashcards).toEqual([]); expect(state.filters.search).toBe(''); }); it('should update filters and reset page', () => { const { result } = renderHook(() => useFlashcardSearch()); const [, actions] = result.current; act(() => { actions.goToPage(3); actions.updateFilters({ search: 'test' }); }); const [newState] = result.current; expect(newState.filters.search).toBe('test'); expect(newState.pagination.currentPage).toBe(1); }); }); ``` ### 整合測試 ```typescript // __tests__/integration/FlashcardsPage.test.tsx describe('FlashcardsPage Integration', () => { it('should load and display flashcards', async () => { const mockFlashcards = [ { id: '1', word: 'test', translation: '測試' } ]; mockApiResponse(mockFlashcards); render(); await waitFor(() => { expect(screen.getByText('test')).toBeInTheDocument(); }); }); }); ``` --- ## 📈 效能優化 ### React優化策略 ```typescript // 使用 React.memo 避免不必要渲染 export const FlashcardCard = React.memo(({ flashcard, onEdit, onDelete }) => { // 組件實作... }); // 使用 useMemo 快取計算結果 const filteredOptions = useMemo(() => { return options.filter(option => option.available); }, [options]); // 使用 useCallback 穩定函數引用 const handleSearch = useCallback((term: string) => { onSearch(term); }, [onSearch]); ``` ### 虛擬滾動 (大量數據時) ```typescript import { FixedSizeList as List } from 'react-window'; export const VirtualFlashcardList: React.FC<{ flashcards: Flashcard[]; height: number; }> = ({ flashcards, height }) => { const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
); return ( {Row} ); }; ``` --- 這個前端架構確保: - 🎯 **清晰的狀態管理** - 單一真實來源 - ⚡ **高效能** - 防抖、快取、虛擬滾動 - 🔄 **URL同步** - 支援書籤和分享 - 🧪 **可測試** - 完整的測試覆蓋 - 🔧 **可維護** - 組件分離、類型安全 --- *文檔版本: 1.0* *最後更新: 2025-09-24*