dramaling-vocab-learning/note/done/FRONTEND_ARCHITECTURE.md

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