【App 工程師的前端轉生術 05】UI 架構篇:原子化元件設計與 Slot Pattern
01. 前言:別再寫 <div className="..."> 了
經過前四篇的洗禮,你的 Next.js 專案已經有了穩固的資料層 (Repository) 和邏輯層 (ViewModel)。但在 UI 層,很多初轉職的 App 工程師還是習慣把所有 HTML 全部寫在 page.tsx 裡。
這就像你在 iOS 的 ViewController 裡直接用程式碼 new UILabel, new UIButton 然後手動設 frame 和 style,完全沒有封裝成 Custom View。
結果就是:你的 Page 充滿了重複的 Tailwind Class 字串,改一個按鈕樣式要改十個地方。
今天我們要來做 UI 的重構 (Refactoring)。我們要讓你的 React 代碼看起來像 SwiftUI 或 Jetpack Compose 一樣語意化。
02. 原子設計 (Atomic Design) 的 App 視角
Atomic Design 是前端界很流行的設計方法,其實這跟 Mobile App 的元件階層概念完全互通。
| 層級 | 概念 | App 對應 (iOS / Android) | 例子 |
|---|---|---|---|
| Atoms (原子) | 不可再分的最小元件 | UIButton / Button | 按鈕、輸入框、標籤 (Badge) |
| Molecules (分子) | 簡單的組合元件 | UITableViewCell / ListItem | 搜尋列 (Input + Button)、表單欄位 (Label + Input + Error) |
| Organisms (組織) | 複雜的業務區塊 | UIView (Custom) / Composable | 導航列 (NavBar)、登入表單 (LoginForm)、商品卡片 |
| Templates (模板) | 頁面骨架 | BaseViewController / Scaffold | Dashboard Layout, Settings Layout |
| Pages (頁面) | 填入資料的實體 | UIViewController / Screen | 首頁, 個人頁 |
我們的目標是:Page 層不應該出現任何 div 或 span,只應該出現語意化的 Component。
03. 實戰:封裝一個 PrimaryButton (Atom)
初學者最常犯的錯是到處複製 Tailwind Class。
❌ 壞味道 (Bad Smell):
// 在 Page A
<button className="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600">
送出
</button>
// 在 Page B (如果不小心少複製一個 class,樣式就不統一了)
<button className="bg-blue-500 text-white px-4 py-2 rounded-lg">
儲存
</button>
✅ 封裝後 (Atomic Component):
// src/components/atoms/PrimaryButton.tsx
import { ReactNode, ButtonHTMLAttributes } from 'react';
// 繼承原生 Button 的屬性 (onClick, disabled, type...)
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
isLoading?: boolean;
}
export function PrimaryButton({ children, isLoading, className, ...props }: Props) {
return (
<button
// 統一管理樣式,並允許外部透過 className 傳入額外微調 (雖然不建議過度覆寫)
className={`
bg-blue-600 text-white px-4 py-2 rounded-lg font-medium transition-colors
hover:bg-blue-700 active:bg-blue-800
disabled:bg-gray-300 disabled:cursor-not-allowed
flex items-center justify-center gap-2
${className}
`}
disabled={props.disabled || isLoading}
{...props} // 把剩餘的原生屬性展開
>
{isLoading && <span className="animate-spin">↻</span>}
{children}
</button>
);
}
現在,你在 Page 裡只需要寫:
<PrimaryButton onClick={vm.login} isLoading={vm.isLoading}>
登入
</PrimaryButton>
這是不是很有 Swift/Kotlin 的感覺了?
04. Slot Pattern:React 的 ViewBuilder
在 App 開發中,我們常建立「容器型」元件,例如卡片 (Card) 或 對話框 (Dialog),內容是可以替換的。
- iOS (SwiftUI):
@ViewBuilder - Android (Compose):
content: @Composable () -> Unit - React:
childrenprop
實作一個通用的 Card 元件 (Molecule/Organism)
// src/components/molecules/Card.tsx
import { ReactNode } from 'react';
interface CardProps {
title: string;
// 這是 Slot 1: 主要內容
children: ReactNode;
// 這是 Slot 2: 右上角的動作按鈕 (Optional)
action?: ReactNode;
}
export function Card({ title, children, action }: CardProps) {
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center">
<h3 className="font-semibold text-gray-800 text-lg">{title}</h3>
{/* 如果有傳入 action Slot,就渲染它 */}
{action && <div>{action}</div>}
</div>
{/* Body */}
<div className="p-6">
{children}
</div>
</div>
);
}
使用方式對照
SwiftUI:
Card(title: "個人檔案") {
Text("User Details...")
} action: {
Button("Edit") { ... }
}
React:
<Card
title="個人檔案"
action={
<PrimaryButton onClick={vm.edit}>編輯</PrimaryButton>
}
>
<div className="flex gap-4">
<Avatar src={vm.user.avatar} />
<UserInfo user={vm.user} />
</div>
</Card>
App 工程師視角:
發現了嗎?React 的 children 就是 SwiftUI 的 Trailing Closure,而其他的 Prop 如果型別是 ReactNode,就等於是多個 ViewBuilder 參數。這種 Composition (組合) 的能力,是現代 UI 框架的核心。
05. 列表渲染:RecyclerView / LazyColumn 的替代品
在 App 裡,渲染列表通常很麻煩(需要 Adapter, ViewHolder)。在 React 裡,這是最簡單的事。
不需要 Adapter,只需要 Array.map()。
// src/components/organisms/UserList.tsx
import { User } from '@/domain/models/user';
import { Card } from '../molecules/Card';
interface Props {
users: User[];
onUserClick: (id: string) => void;
}
export function UserList({ users, onUserClick }: Props) {
if (users.length === 0) {
return <div className="text-center text-gray-500 py-10">暫無資料</div>;
}
return (
<div className="grid gap-4 md:grid-cols-2">
{/* 相當於 onBindViewHolder */}
{users.map((user) => (
<div
key={user.id} // ⚠️ 重要:相當於 DiffUtil 的 item identity
onClick={() => onUserClick(user.id)}
className="cursor-pointer hover:shadow-md transition-shadow"
>
<Card title={user.name}>
<p className="text-gray-600">{user.email}</p>
</Card>
</div>
))}
</div>
);
}
06. 小結:UI 也是一種 API
在這篇 UI 架構篇中,我們學到了:
- Atomic Design: 幫助我們組織元件目錄結構 (
atoms,molecules,organisms)。 - Encapsulation (封裝): 隱藏 Tailwind 的複雜 class 字串,只暴露語意化的 Props。
- Slot Pattern: 利用
children和ReactNodeprops 實現高度靈活的佈局組合。
現在你的專案結構應該長這樣:
src/
├── app/ (Pages & Routing)
├── components/
│ ├── atoms/ (Button, Input, Avatar)
│ ├── molecules/ (Card, FormField)
│ └── organisms/ (UserList, LoginForm)
├── domain/ (Models)
├── features/ (ViewModels)
└── infrastructure/ (Repository, API)
這看起來是不是跟一個架構嚴謹的 iOS/Android 專案非常像了?
在下一篇 【Part 6】,我們將深入探討 Tailwind CSS 的設計哲學。為什麼我說它是 Web 界的 Modifier?如何處理 Dark Mode 和 RWD (響應式設計)?我們將揭開它被誤解的面紗。

關於作者
Ken Huang
熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。
最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。
這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。