16 KiB
16 KiB
🎨 前端架構設計
🎯 設計原則
核心理念
- 狀態驅動 - 單一真實來源 (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設計
// 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<SearchFilters>) => void;
clearFilters: () => void;
resetFilters: () => void;
// 排序操作
updateSorting: (sorting: Partial<SortOptions>) => void;
toggleSortOrder: () => void;
// 分頁操作
goToPage: (page: number) => void;
changePageSize: (size: number) => void;
goToNextPage: () => void;
goToPrevPage: () => void;
// 資料操作
refresh: () => Promise<void>;
refetch: () => Promise<void>;
}
export const useFlashcardSearch = (): [SearchState, SearchActions] => {
// 狀態管理實作...
};
實作細節
1. 核心狀態管理
export const useFlashcardSearch = () => {
const [state, setState] = useState<SearchState>({
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<SearchFilters>) => {
setState(prev => ({
...prev,
filters: { ...prev.filters, ...newFilters },
pagination: { ...prev.pagination, currentPage: 1 }, // 重置頁碼
}));
}, []);
const updateSorting = useCallback((newSorting: Partial<SortOptions>) => {
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狀態同步
// 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. 組件架構設計
// components/Search/SearchControls.tsx
interface SearchControlsProps {
filters: SearchFilters;
sorting: SortOptions;
onFiltersChange: (filters: Partial<SearchFilters>) => void;
onSortingChange: (sorting: Partial<SortOptions>) => void;
onClearFilters: () => void;
loading?: boolean;
}
export const SearchControls: React.FC<SearchControlsProps> = ({
filters,
sorting,
onFiltersChange,
onSortingChange,
onClearFilters,
loading = false,
}) => {
const [showAdvanced, setShowAdvanced] = useState(false);
return (
<div className="bg-white rounded-xl shadow-sm p-6">
{/* 基本搜尋 */}
<div className="flex items-center justify-between mb-4">
<SearchInput
value={filters.search}
onChange={(search) => onFiltersChange({ search })}
placeholder="搜尋詞彙、翻譯或定義..."
loading={loading}
/>
<SortControls
sortBy={sorting.sortBy}
sortOrder={sorting.sortOrder}
onChange={onSortingChange}
/>
</div>
{/* 進階篩選 */}
{showAdvanced && (
<FilterPanel
filters={filters}
onChange={onFiltersChange}
onClear={onClearFilters}
/>
)}
{/* 切換按鈕 */}
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-sm text-blue-600 hover:text-blue-700"
>
{showAdvanced ? '收起篩選' : '進階篩選'}
</button>
</div>
);
};
4. API服務擴展
// 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<string, CacheEntry>();
async getFlashcards(params: FlashcardQueryParams): Promise<ApiResponse<FlashcardQueryResponse>> {
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<ApiResponse<FlashcardQueryResponse>>(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(),
});
}
}
🧪 測試策略
單元測試
// __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);
});
});
整合測試
// __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(<FlashcardsPage />);
await waitFor(() => {
expect(screen.getByText('test')).toBeInTheDocument();
});
});
});
📈 效能優化
React優化策略
// 使用 React.memo 避免不必要渲染
export const FlashcardCard = React.memo<FlashcardCardProps>(({ flashcard, onEdit, onDelete }) => {
// 組件實作...
});
// 使用 useMemo 快取計算結果
const filteredOptions = useMemo(() => {
return options.filter(option => option.available);
}, [options]);
// 使用 useCallback 穩定函數引用
const handleSearch = useCallback((term: string) => {
onSearch(term);
}, [onSearch]);
虛擬滾動 (大量數據時)
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 }) => (
<div style={style}>
<FlashcardCard flashcard={flashcards[index]} />
</div>
);
return (
<List
height={height}
itemCount={flashcards.length}
itemSize={120}
width="100%"
>
{Row}
</List>
);
};
這個前端架構確保:
- 🎯 清晰的狀態管理 - 單一真實來源
- ⚡ 高效能 - 防抖、快取、虛擬滾動
- 🔄 URL同步 - 支援書籤和分享
- 🧪 可測試 - 完整的測試覆蓋
- 🔧 可維護 - 組件分離、類型安全
文檔版本: 1.0 最後更新: 2025-09-24