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 # 認證 ProviderReact 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>
);
}對照表#
| Angular | React | 說明 |
|---|---|---|
@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>
);
}實際元件範例#
Sidebar - 側邊欄#
// 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 管理全域狀態。