beio Logobeio

【App 工程師的前端轉生術 04】邏輯層的核心:Custom Hooks 就是你的 ViewModel

發布於

01. 前言:尋找失落的 ViewModel

在 Part 3,我們用 Repository Pattern 搞定了資料層。現在,資料已經流進來了,但要放在哪裡?

作為 App 工程師,你的直覺告訴你:

  • Android: 我需要一個繼承 androidx.lifecycle.ViewModel 的 Class,裡面放 StateFlow
  • iOS: 我需要一個繼承 ObservableObject 的 Class,裡面放 @Published 屬性。

然而,你在 React 的文檔裡找不到 ViewModel 這個關鍵字。你只看到一堆 useStateuseEffect 散落在 Component 裡。

這導致了很多「義大利麵代碼」:UI 渲染邏輯跟業務邏輯(表單驗證、按鈕狀態切換、錯誤處理)全部混在一起,就像你當年寫的 Massive View Controller。

今天這篇的核心觀念只有一個:在 React 中,Custom Hook (自定義 Hook) 就是你的 ViewModel。

02. 觀念對齊:Class vs. Function

在 Mobile World,ViewModel 是一個 物件 (Object),它活得比 View 久(Survives configuration changes)。 在 React World,ViewModel 是一個 函數 (Function),它隨著 View 的每一次渲染而執行。

雖然實作不同,但職責是完全一樣的:Input (Events) -> Process (Logic) -> Output (State)

狀態管理的對照

概念Android (Kotlin)iOS (Swift)React (Hooks)
保存狀態MutableStateFlow<T>@Published varuseState<T>
計算屬性val isValid: StateFlow<Boolean>var isValid: Bool { ... }const isValid = ...
副作用/生命週期init { } / viewModelScopeinit { } / TaskuseEffect
邏輯封裝單位class LoginViewModelclass LoginViewModelfunction useLoginViewModel

03. 反面教材:Massive Component

讓我們看一個典型的「髒」代碼。這是一個登入頁面,邏輯與 UI 糾纏不清。

// ❌ 典型的 Spaghetti Code
'use client'; // 代表這是 Client Component

export default function LoginPage() {
  // 1. 狀態散落一地 (State Pollution)
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // 2. 業務邏輯混在 View 裡 (Logic Leakage)
  const handleLogin = async () => {
    if (!email.includes('@')) {
        setError('Email 格式錯誤');
        return;
    }
    setIsLoading(true);
    try {
        await authRepository.login(email, password);
        // 跳轉邏輯...
    } catch (e) {
        setError(e.message);
    } finally {
        setIsLoading(false);
    }
  };

  // 3. UI 渲染 (View)
  return (
    <form onSubmit={(e) => { e.preventDefault(); handleLogin(); }}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      {error && <span className="text-red-500">{error}</span>}
      <button disabled={isLoading}>Login</button>
    </form>
  );
}

App 工程師視角: 這就是把所有 IBAction 和變數都寫在 ViewControllerActivity 裡面的樣子。難以測試、難以閱讀、難以維護。

04. 解法:打造 useLoginViewModel

我們要將上述邏輯抽離。在 React 中,只要函數名稱以 use 開頭,它就是一個 Hook。

Step 1: 實作 ViewModel

// src/features/auth/view-models/useLoginViewModel.ts
import { useState } from 'react';
import { useRouter } from 'next/navigation'; // 類似 NavigationController
import { authRepository } from '@/infrastructure/repositories/authRepository';

export const useLoginViewModel = () => {
  // --- State (如同 @Published / StateFlow) ---
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [uiState, setUiState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  
  // Next.js 的 Router Hook
  const router = useRouter();

  // --- Computed Properties (計算屬性) ---
  // 這會在每次 render 時自動計算,不需額外 state
  const isFormValid = email.includes('@') && password.length >= 6;
  const isLoading = uiState === 'loading';

  // --- Actions (如同 fun login() / func login()) ---
  const login = async () => {
    if (!isFormValid) return;

    setUiState('loading');
    setErrorMessage(null);

    try {
      // 呼叫我們在 Part 3 寫好的 Repository
      await authRepository.login(email, password);
      
      setUiState('success');
      router.push('/dashboard'); // 導航跳轉
    } catch (e: any) {
      setUiState('error');
      setErrorMessage(e.message || '登入失敗');
    } finally {
      if (uiState !== 'success') setUiState('idle');
    }
  };

  // --- Return Interface (公開給 View 的介面) ---
  // 這就像是 ViewModel 的 public properties
  return {
    // Inputs (Two-way binding helpers)
    email,
    setEmail,
    password,
    setPassword,
    
    // Outputs (Read-only State)
    isLoading,
    errorMessage,
    isFormValid,
    
    // Actions
    login,
  };
};

Step 2: 重構 View Component

現在,我們的 View 變得極度乾淨,就像 SwiftUI 或 Jetpack Compose 的寫法:

// app/login/page.tsx
'use client';

import { useLoginViewModel } from '@/features/auth/view-models/useLoginViewModel';

export default function LoginPage() {
  // ✨ Dependency Injection (Sort of)
  // View 只需要知道 "有這個 ViewModel",不需要知道實作細節
  const vm = useLoginViewModel();

  return (
    <div className="p-4">
      <h1 className="text-2xl font-bold">登入</h1>
      
      <div className="flex flex-col gap-4 mt-4">
        {/* Input 不需要知道 state 怎麼存,只管綁定 */}
        <input 
          placeholder="Email"
          value={vm.email}
          onChange={(e) => vm.setEmail(e.target.value)}
          className="border p-2 rounded"
        />
        
        <input 
          type="password"
          placeholder="Password"
          value={vm.password}
          onChange={(e) => vm.setPassword(e.target.value)}
          className="border p-2 rounded"
        />

        {/* Error State */}
        {vm.errorMessage && (
          <div className="text-red-500 bg-red-50 p-2 rounded">
            {vm.errorMessage}
          </div>
        )}

        <button 
          onClick={vm.login}
          disabled={!vm.isFormValid || vm.isLoading}
          className="bg-blue-500 text-white p-2 rounded disabled:bg-gray-300"
        >
          {vm.isLoading ? '登入中...' : '登入'}
        </button>
      </div>
    </div>
  );
}

05. 關於依賴注入 (DI) 的預告

敏銳的 App 工程師可能發現了:useLoginViewModel 裡面直接 importauthRepository。這在單元測試時會很麻煩(很難 Mock)。

在 Android/iOS,我們習慣用 Hilt 或 Swinject 來注入。在 React Hook 中,我們可以用 Default ParametersContext 來解決這個問題。這部分我們將在 Part 10: 測試與 DI 中詳細探討。

06. 小結:MVVM 完成體

到目前為止,我們已經建立了一個完整的 MVVM 架構:

  1. Model (Part 3): Zod Schemas & TypeScript Types。
  2. Repository (Part 3): 負責資料獲取與驗證。
  3. ViewModel (Part 4): Custom Hooks,負責狀態管理與業務邏輯。
  4. View (Part 1 & 4): React Components,只負責渲染 vm.state

現在,你的前端程式碼已經具備了與 Native App 同等級的結構化程度。

但是,一個好的 App 不只有邏輯,還要有漂亮的 UI。iOS 有 SwiftUI Modifier,Android 有 Compose Modifier。React 呢?

在下一篇 【Part 5】,我們將探討 原子化元件設計Tailwind CSS,讓你像堆積木一樣構建 UI,告別混亂的 CSS 檔案。


Ken Huang

關於作者

Ken Huang

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

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

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

Android APP DevelopmentiOS APP DevelopmentBePTT CreatorFull Stack Learner