Frontend 元件結構詳解#

本專案使用 React 函式元件搭配 Hooks。這章節說明元件組織方式和常見模式。

檔案結構#

frontend/src/components/
├── layout/                 # 版面元件
│   ├── Sidebar.tsx        # 側邊欄
│   ├── TopBar.tsx         # 頂部導航
│   ├── Footer.tsx         # 頁尾
│   └── Providers.tsx      # Context Providers
├── course/                 # 課程相關元件
│   ├── CourseCard.tsx     # 課程卡片
│   └── HomeCourseCard.tsx # 首頁課程卡片
├── mission/                # 任務相關元件
│   ├── VideoPlayer.tsx    # 影片播放器
│   └── RewardButton.tsx   # 領獎按鈕
├── profile/                # 個人資料元件
│   └── EditProfileModal.tsx
├── ui/                     # 通用 UI 元件
│   ├── AnnouncementBanner.tsx
│   └── JourneySwitcher.tsx
└── AuthProvider.tsx        # 認證 Provider

React vs Angular 元件對照#

基本元件結構#

Angular:

// course-card.component.ts
@Component({
  selector: 'app-course-card',
  templateUrl: './course-card.component.html',
  styleUrls: ['./course-card.component.scss']
})
export class CourseCardComponent implements OnInit {
  @Input() course!: Course;
  @Output() enroll = new EventEmitter<number>();

  ngOnInit(): void {
    console.log('Component initialized');
  }

  handleEnroll(): void {
    this.enroll.emit(this.course.id);
  }
}
<!-- course-card.component.html -->
<div class="card">
  <h2>{{ course.name }}</h2>
  <button (click)="handleEnroll()">報名</button>
</div>

React (Next.js):

// components/course/CourseCard.tsx
'use client';

import { useEffect } from 'react';

interface CourseCardProps {
  course: Course;
  onEnroll: (id: number) => void;
}

export function CourseCard({ course, onEnroll }: CourseCardProps) {
  useEffect(() => {
    console.log('Component initialized');
  }, []);

  return (
    <div className="card">
      <h2>{course.name}</h2>
      <button onClick={() => onEnroll(course.id)}>報名</button>
    </div>
  );
}

對照表#

AngularReact說明
@Input()props接收父元件資料
@Output()callback props發送事件給父元件
ngOnInit()useEffect(() => {}, [])初始化
ngOnDestroy()useEffect return清理
*ngIf{condition && ...}條件渲染
*ngFor.map()列表渲染
[(ngModel)]useState + onChange雙向綁定
{{ value }}{value}插值
[class.active]className={cn(...)}動態 class
(click)onClick事件綁定

Hooks 詳解#

useState - 狀態管理#

'use client';

import { useState } from 'react';

export function Counter() {
  // 宣告狀態
  const [count, setCount] = useState(0);
  const [user, setUser] = useState<User | null>(null);

  // 更新狀態
  const increment = () => setCount(count + 1);
  const resetUser = () => setUser(null);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+1</button>
    </div>
  );
}

Angular 對照:

export class CounterComponent {
  count = 0;
  user: User | null = null;

  increment() {
    this.count++;
  }
}

useEffect - 副作用處理#

'use client';

import { useEffect, useState } from 'react';

export function DataFetcher({ id }: { id: string }) {
  const [data, setData] = useState(null);

  // 元件掛載時執行(等同 ngOnInit)
  useEffect(() => {
    console.log('Component mounted');
  }, []);

  // 依賴變化時執行
  useEffect(() => {
    async function fetchData() {
      const response = await fetch(`/api/data/${id}`);
      const result = await response.json();
      setData(result);
    }
    fetchData();
  }, [id]);  // id 變化時重新執行

  // 清理函數(等同 ngOnDestroy)
  useEffect(() => {
    const timer = setInterval(() => console.log('tick'), 1000);

    return () => {
      clearInterval(timer);  // 元件卸載時清理
    };
  }, []);

  return <div>{data?.name}</div>;
}

Angular 對照:

export class DataFetcherComponent implements OnInit, OnDestroy {
  @Input() id!: string;
  data: any;
  private timer: any;

  ngOnInit() {
    this.fetchData();
    this.timer = setInterval(() => console.log('tick'), 1000);
  }

  ngOnChanges(changes: SimpleChanges) {
    if (changes['id']) {
      this.fetchData();
    }
  }

  ngOnDestroy() {
    clearInterval(this.timer);
  }

  fetchData() {
    this.http.get(`/api/data/${this.id}`).subscribe(data => {
      this.data = data;
    });
  }
}

useRef - 保存引用#

'use client';

import { useRef, useEffect } from 'react';

export function VideoPlayer() {
  const playerRef = useRef<HTMLVideoElement>(null);
  const watchedTimeRef = useRef(0);  // 不會觸發重新渲染

  useEffect(() => {
    // 直接操作 DOM
    if (playerRef.current) {
      playerRef.current.play();
    }
  }, []);

  const updateWatchedTime = () => {
    watchedTimeRef.current += 1;  // 修改不會觸發重新渲染
    console.log(`Watched: ${watchedTimeRef.current}s`);
  };

  return (
    <video ref={playerRef} onTimeUpdate={updateWatchedTime}>
      <source src="video.mp4" />
    </video>
  );
}

實際元件範例#

// components/layout/Sidebar.tsx
'use client';

import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useUserStore } from '@/store/useUserStore';
import { HomeIcon, TrophyIcon, UserIcon } from '@heroicons/react/24/outline';

interface NavItem {
  href: string;
  label: string;
  icon: React.ReactNode;
}

export function Sidebar() {
  const pathname = usePathname();
  const router = useRouter();
  const { user, logout } = useUserStore();

  const handleLogout = () => {
    if (confirm('確定要登出嗎?')) {
      localStorage.removeItem('token');
      logout();
      router.push('/sign-in');
    }
  };

  const navItems: NavItem[] = [
    { href: '/', label: '首頁', icon: <HomeIcon className="w-5 h-5" /> },
    { href: '/courses', label: '課程', icon: <Squares2X2Icon className="w-5 h-5" /> },
    { href: '/leaderboard', label: '排行榜', icon: <TrophyIcon className="w-5 h-5" /> },
    ...(user ? [{ href: '/profile', label: '個人檔案', icon: <UserIcon className="w-5 h-5" /> }] : []),
  ];

  return (
    <aside className="w-[280px] h-screen sticky top-0 bg-[#0d1117] border-r border-gray-700">
      {/* Logo */}
      <div className="p-6">
        <Link href="/">
          <img src="/logo.png" alt="Logo" className="h-12" />
        </Link>
      </div>

      {/* Navigation */}
      <nav className="px-4">
        <ul className="space-y-1">
          {navItems.map((item) => {
            const isActive = pathname === item.href;
            return (
              <li key={item.href}>
                <Link
                  href={item.href}
                  className={`flex items-center gap-3 px-3 py-2 rounded-lg ${
                    isActive
                      ? 'bg-sidebar-accent text-white'
                      : 'text-gray-400 hover:bg-gray-800'
                  }`}
                >
                  {item.icon}
                  <span>{item.label}</span>
                </Link>
              </li>
            );
          })}
        </ul>
      </nav>

      {/* User Info */}
      {user && (
        <div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-700">
          <div className="flex items-center gap-3 mb-3">
            <img
              src={user.avatarUrl}
              alt={user.name}
              className="w-10 h-10 rounded-full"
            />
            <div>
              <div className="text-white font-medium">{user.name}</div>
              <div className="text-gray-400 text-sm">Lv {user.level}</div>
            </div>
          </div>
          <button
            onClick={handleLogout}
            className="w-full bg-gray-700 hover:bg-gray-600 text-white rounded px-3 py-2"
          >
            登出
          </button>
        </div>
      )}
    </aside>
  );
}

條件渲染#

// Angular: *ngIf
// React: 三元運算子或 &&

export function UserProfile({ user }: { user: User | null }) {
  return (
    <div>
      {/* 條件顯示 */}
      {user ? (
        <div>
          <p>歡迎,{user.name}</p>
          <p>等級:{user.level}</p>
        </div>
      ) : (
        <p>請先登入</p>
      )}

      {/* 只顯示(無 else) */}
      {user && <p>你已登入</p>}

      {/* 多條件 */}
      {user?.level >= 10 && <Badge>高等級用戶</Badge>}
    </div>
  );
}

列表渲染#

// Angular: *ngFor
// React: .map()

interface Course {
  id: number;
  name: string;
}

export function CourseList({ courses }: { courses: Course[] }) {
  return (
    <ul>
      {courses.map((course) => (
        <li key={course.id}>  {/* key 是必須的 */}
          <CourseCard course={course} />
        </li>
      ))}

      {/* 空狀態 */}
      {courses.length === 0 && <p>沒有課程</p>}
    </ul>
  );
}

樣式處理#

Tailwind CSS#

// 直接在 className 使用 Tailwind 類別
export function Button({ children, variant }: { children: React.ReactNode; variant?: 'primary' | 'secondary' }) {
  return (
    <button
      className={`
        px-4 py-2 rounded-md font-medium transition-colors
        ${variant === 'primary'
          ? 'bg-blue-600 text-white hover:bg-blue-700'
          : 'bg-gray-200 text-gray-800 hover:bg-gray-300'
        }
      `}
    >
      {children}
    </button>
  );
}

cn 工具函數#

// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

// 使用
import { cn } from '@/lib/utils';

<button
  className={cn(
    'px-4 py-2 rounded-md',
    isActive && 'bg-blue-600 text-white',
    disabled && 'opacity-50 cursor-not-allowed'
  )}
>

常見問題#

Q: 什麼時候要加 ‘use client’?#

當元件使用以下功能時:

  • useState, useEffect, useRef 等 hooks
  • 瀏覽器 API(localStorage, window
  • 事件處理(onClick, onChange

Q: Props vs State?#

  • Props: 從父元件傳入,元件內不能修改
  • State: 元件內部管理,可以修改

Q: 為什麼 map 要加 key?#

React 用 key 來追蹤列表項目的變化,沒有 key 會導致效能問題和 bug。

// 好
{items.map(item => <Item key={item.id} {...item} />)}

// 不好(用 index 當 key)
{items.map((item, index) => <Item key={index} {...item} />)}

下一步#

理解元件後,接下來看 狀態管理 了解如何使用 Zustand 管理全域狀態。