beio Logobeio

【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 / ScaffoldDashboard Layout, Settings Layout
Pages (頁面)填入資料的實體UIViewController / Screen首頁, 個人頁

我們的目標是:Page 層不應該出現任何 divspan,只應該出現語意化的 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: children prop

實作一個通用的 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 架構篇中,我們學到了:

  1. Atomic Design: 幫助我們組織元件目錄結構 (atoms, molecules, organisms)。
  2. Encapsulation (封裝): 隱藏 Tailwind 的複雜 class 字串,只暴露語意化的 Props。
  3. Slot Pattern: 利用 childrenReactNode props 實現高度靈活的佈局組合。

現在你的專案結構應該長這樣:

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 ModeRWD (響應式設計)?我們將揭開它被誤解的面紗。


Ken Huang

關於作者

Ken Huang

熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。

最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。

這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。

Android APP DevelopmentiOS APP DevelopmentBePTT CreatorFull Stack Learner