2025年11月11日 星期二

🚀 資深工程師面試:SOLID 原則與設計模式在模組化元件中的實戰應用

🚀 資深工程師面試:SOLID 原則與設計模式在模組化元件中的實戰應用

這是一份針對資深軟體工程師(特別是具備 Laravel / 全端經驗)的進階面試題綱。它不只考驗技術概念,更著重於如何將這些設計原則(如 SOLID)與設計模式落地到可維護、可擴充前端/後端模組化元件設計中。

文章將涵蓋:概念理解實作範例架構設計,以及測試與演進


🎯 第一部分:核心概念理解 (SOLID)

題目 1:請逐一解釋 SOLID 五原則,並說明在前端元件設計中的對應意義。

原則完整名稱核心精神前端元件設計的對應意義(以 Vue/React 為例)
S單一職責原則 (SRP)一個類別/模組只有一個理由需要被改變元件的職責單一化。一個元件只負責一個業務或視覺職責。例如:顯示資料的元件不應負責處理 API 請求;按鈕元件只負責外觀和點擊事件,不應包含複雜的路由邏輯。避免創建「巨石元件 (God Component)」。
O開放封閉原則 (OCP)模組應對擴充開放 (Open for extension),但對修改封閉 (Closed for modification)透過參數 (Props)、插槽 (Slots)、事件 (Emits)組件組合 (Composition) 來擴展功能,而不是直接修改元件的原始碼。例如:基礎 Button 元件應支援通過 Props 傳入不同主題,而非修改其內部邏輯來適應新主題。
L里氏替換原則 (LSP)子型別(Subtypes)必須能夠替換掉它們的基礎型別(Base types)。在前端組件系統中,意味著如果子元件(例如:FancyButton)繼承或實作了父元件(例如:BaseButton)的介面(Props/Emits/Methods),那麼將父元件替換為子元件時,不應破壞系統的行為和預期。在 TypeScript/介面設計中尤其重要。
I介面隔離原則 (ISP)客戶端不應該被強迫依賴於它們不使用的方法/介面。Props 或 Hook 介面應該小而精確。避免設計一個龐大的父組件,強迫子組件接收一大堆它根本不需要的 Props。在 TypeScript 中,這要求我們為不同的使用場景定義多個精確的介面,而非一個包山包海的介面。
D依賴反轉原則 (DIP)高階模組不應依賴低階模組,兩者都應依賴於抽象 (Abstraction)抽象不應依賴於細節,細節應依賴於抽象。在大型專案中,元件(高階)不應直接呼叫 API 服務(低階)的實作。應該依賴於一個抽象的資料介面 (Interface/Type)依賴注入 (Dependency Injection) 機制,讓外部(如 Vuex/Pinia/Context/Container)提供服務實作。這使得測試時可以輕鬆替換 Mock 服務。

追問:哪一條最常被誤用?請結合 SRP、OCP 在元件責任劃分與擴充上的實例。

誤用與迷思:單一職責原則 (SRP)

最常被誤用的是:單一職責原則 (SRP)。

誤用情境:

許多人誤以為 SRP 只是單純地讓函式或類別**「做很少的事情」,導致過度細節化,將簡單的邏輯分散到太多檔案中,造成「微服務地獄」**般的程式碼庫,反而降低了可讀性和開發效率。

正確理解與前端實例:

SRP 的核心是**「一個改變的理由 (One reason to change)」**,而非「只做一件事」。

原則實作重點範例(Vue Composition API)
SRP責任劃分:業務邏輯 (Business Logic)狀態管理 (State Management)視覺呈現 (Presentation) 三種不同「改變的理由」分開。資料獲取/處理的邏輯抽離到一個 useFetchUsers()Composable 中(職責一:業務邏輯)。而 UserTable.vue 元件只負責接收 users 陣列並以表格呈現(職責二:視覺呈現)。這樣當 API 變動時,只改 Composable;當 UI 樣式變動時,只改 Vue 檔案。
OCP擴充機制: 透過開放的介面來允許外部注入或擴充行為。BaseTable.vue 元件(對修改封閉)利用 <slot>對擴充開放)來讓使用者自定義表格的單元格 (Cell) 內容。如果未來需要一個特殊的「狀態標籤」欄位,開發者只需使用 Slot 傳入自定義的標籤元件,而不需要修改 BaseTable.vue 的核心程式碼。

🏗️ 第二部分:設計模式在 UI 元件中的應用

題目 2:常見的設計模式(Factory, Strategy, Observer, Decorator)在 UI 元件中各自適合的場景為何?

設計模式核心目的前端 UI 元件適合場景
工廠模式 (Factory)物件的創建邏輯使用邏輯解耦,依據不同參數回傳不同實例。動態元件渲染與配置: 根據資料類型(例如:type: 'text', type: 'image', type: 'video')來創建並渲染對應的 Form Field 元件。這使得新增一種新的欄位類型時,只需要新增一個工廠分支和一個新元件,而不需要修改表單容器。
策略模式 (Strategy)定義一組演算法 (行為),將每個演算法封裝起來,並使它們可以互相替換。多種行為切換: 一個 UserList 元件需要支援多種排序策略(按名稱、按時間、按權重)或資料處理策略(分頁、無限捲動)。元件本身不實作這些邏輯,而是接收一個策略物件 (Strategy Object) 來執行。
觀察者模式 (Observer)定義對象之間的一對多依賴關係,當一個對象狀態改變時,所有依賴它的對象都會得到通知並自動更新。跨元件的狀態管理: 這是 Vue 響應式系統、Vuex/Pinia、React Context/Redux 的核心基礎。例如:當使用者點擊 DarkModeToggle 元件時(主題狀態改變),所有依賴該主題狀態的元件(觀察者)都會被通知並更新樣式。
裝飾器模式 (Decorator)動態地將新職責附加到物件上,相較於繼承更具彈性。高階元件 (HOC) 或 Composable 擴充: 用於不修改原始元件的情況下,增加額外的屬性或行為。例如:withLoading(Component) 可以在元件外層動態添加一個載入中的遮罩,或者 useLogClick(useButton) 在基礎按鈕邏輯上添加日誌記錄功能。

追問:舉一個你實作過的範例。

實作範例:策略模式 (Strategy) 於表單驗證

  1. 場景: 撰寫一個可重用的 useValidator Composable,用於不同的表單欄位。

  2. 問題: 每個欄位(Email、密碼、手機號碼)有不同的驗證規則。如果直接在 Composable 內部寫 if/else 判斷,會違反 OCP。

  3. 策略模式實作:

    • 策略介面 (IValidationStrategy): 定義一個通用的 validate(value: string): string | null 函式。

    • 具體策略 (Concrete Strategy): 實作多個驗證類別,例如 EmailValidatorPasswordValidatorRequiredValidator

    • 上下文 (Context): useValidator Composable 接收一個策略陣列

TypeScript
// IValidationStrategy
interface IValidationStrategy {
  validate(value: string): string | null; // 回傳錯誤訊息或 null
}

// 具體策略 - RequiredValidator
class RequiredValidator implements IValidationStrategy {
  validate(value: string): string | null {
    return value.trim() ? null : '此欄位為必填。';
  }
}

// Context - useValidator Composable
function useValidator(strategies: IValidationStrategy[]) {
  const errorMessage = ref<string | null>(null);

  const executeValidation = (value: string) => {
    errorMessage.value = null; // 清除舊錯誤

    for (const strategy of strategies) {
      const error = strategy.validate(value);
      if (error) {
        errorMessage.value = error;
        break; // 找到第一個錯誤即停止
      }
    }
    return !errorMessage.value; // 回傳是否驗證通過
  };

  return { errorMessage, executeValidation };
}

優勢:

  • OCP: 新增一個 TaiwanPhoneValidator 無需修改 useValidator 的程式碼。

  • 高內聚、低耦合: 驗證邏輯與表單邏輯完全分離。


🛠️ 第三部分:實作與範例

題目 3:展示一個你用 Composition API + TypeScript 實作的可重用元件,如何應用 SRP 與 OCP。

期待回答重點: props/slots/emit 的責任劃分、抽象介面。

範例:可自定義的 BaseCard 元件

應用 SRP:

  • BaseCard.vue:只負責佈局 (Layout)陰影 (Shadow)圓角 (Border Radius)視覺呈現職責。它不處理任何業務資料。

  • 外層元件 (如 UserProfileCard.vue):負責資料獲取商業邏輯(例如:點擊編輯按鈕的邏輯),並將資料透過 Props 或 Slot 傳入。

應用 OCP:

  • 透過 Header, Body, Footer具名插槽 (Named Slots),允許外部完全自定義卡片的內容和行為,而無需修改 BaseCard.vue 本身。

TypeScript
// BaseCard.vue (應用 OCP 與 SRP)

<script setup lang="ts">
// 1. 介面抽象 (ISP):只要求最必要的 Props
interface Props {
  isLoading?: boolean;
  isDraggable?: boolean; // 新增功能
  padding?: 'sm' | 'md' | 'lg';
}

const props = withDefaults(defineProps<Props>(), {
  isLoading: false,
  isDraggable: false,
  padding: 'md',
});

// 2. SRP: 只處理視覺/佈局相關的 computed 屬性
const paddingClass = computed(() => `p-${props.padding}`);

// 3. OCP: 允許外部擴充行為 (如拖曳)
const { handleDragStart } = useDraggable(props.isDraggable); // Composable 封裝行為
</script>

<template>
  <div
    :class="['base-card', paddingClass, { 'is-loading': isLoading }]"
    @dragstart="handleDragStart"
  >
    <header class="card-header">
      <slot name="header">
        <h3 v-if="$slots.title"><slot name="title" /></h3>
      </slot>
    </header>

    <div class="card-body">
      <slot />
    </div>

    <footer v-if="$slots.footer" class="card-footer">
      <slot name="footer" />
    </footer>
  </div>
</template>

總結責任劃分:

  • Props: 負責設定元件外觀通用行為(如 padding, isLoading)。

  • Slots: 負責擴充內容(OCP 的體現)。

  • Emits: 負責通知父元件發生了什麼事件(將職責回歸給父元件)。


題目 4:當元件需要多種行為切換時,你會如何用 Strategy 或 Decorator 實作?請畫出模組關係或貼出程式片段說明。

題目 2 的策略模式範例所示,策略模式 (Strategy) 是處理多種行為切換的首選。

為什麼選擇 Strategy 而非 Decorator?

  • Strategy (策略模式): 適用於完全替換元件的核心演算法/行為(例如:從「排序演算法 A」切換到「排序演算法 B」)。

  • Decorator (裝飾器模式): 適用於疊加附加額外功能(例如:在「基礎按鈕」上疊加「日誌功能」和「載入狀態」)。

策略模式實作接續:登入模式切換

模組關係圖(策略模式 - 登入模式切換)

為了清晰展示依賴反轉 (DIP),我們將關係繪製如下:

+------------------------------------+
| 1. LoginFormComponent (高階元件)   |
+-----------+------------------------+
            |
            | (A) 選擇並設定策略
            V
+-----------+------------------------+
| 2. useAuthContext (Context)        |
|  - currentStrategy: IAuthStrategy  |
|  - setAuthStrategy()               |
+-----------+------------------------+
            |
            | (B) 執行抽象方法 login()
            V
+-----------+------------------------+
| 3. IAuthStrategy (抽象介面)        |
|  - login(...args)                  |
+-----------+------------------------+
            |
            | (C) 由 Context 動態決定呼叫
            V
+-----------+-------------+
| 4. PasswordAuthStrategy |
|  - 實作 login()         |
+-------------------------+
            |
            +-------------------------+
            | 4. GoogleAuthStrategy   |
            |  - 實作 login()         |
            +-------------------------+

說明:

  1. 高階元件 (LoginFormComponent) 透過 setAuthStrategy() 告訴 Context 應使用哪種策略。

  2. 元件隨後呼叫 Contextlogin() 方法。

  3. Context 不關心細節,它只呼叫當前持有的 抽象策略login() 方法。

  4. 實際的登入邏輯由特定的具體策略負責執行。這使得元件可以擴充新的登入方式,但無需修改 useAuthContext

程式碼與模組細節 (TypeScript/Vue Composable)

1. 抽象介面:策略契約 (IAuthStrategy.ts)

這是高階與低階模組共同依賴的抽象

TypeScript
// src/services/auth/IAuthStrategy.ts

/**
 * 登入策略的抽象介面 (Strategy Interface)
 * 定義了所有登入方式都必須遵守的「契約」。
 */
export interface IAuthStrategy {
  /**
   * 執行登入邏輯。由於不同登入方式的參數不同,使用 rest arguments。
   * @param args - 登入所需的參數 (如: email, password, token, socialCode等)
   * @returns 包含使用者資訊的 Session 物件
   */
  login(...args: any[]): Promise<UserSession>;
}

// 假設的 UserSession 介面
export interface UserSession {
  userId: string;
  token: string;
  // ... 其他 session 相關資訊
}

2. 具體策略:低階模組 (PasswordAuthStrategy.ts)

這是實現特定細節的低階模組。它依賴並實作抽象介面。

TypeScript
// src/services/auth/strategies/PasswordAuthStrategy.ts
import { IAuthStrategy, UserSession } from '../IAuthStrategy';
import { apiService } from '@/services/Api'; // 假設的低階 API 服務

/**
 * 具體策略 A: 帳號密碼登入
 */
export class PasswordAuthStrategy implements IAuthStrategy {
  async login(email: string, password: string): Promise<UserSession> {
    if (!email || !password) {
      throw new Error("Email 或密碼不能為空。");
    }
    
    // 真正的 API 呼叫細節
    const response = await apiService.post('/login/password', { email, password });
    
    // 處理響應並返回 Session
    return response.data as UserSession; 
  }
}

3. 具體策略:低階模組 (GoogleAuthStrategy.ts)

這是另一個可替換的低階模組。

TypeScript
// src/services/auth/strategies/GoogleAuthStrategy.ts
import { IAuthStrategy, UserSession } from '../IAuthStrategy';
import { googleOAuthService } from '@/services/GoogleOAuth'; // 假設的 Google 服務

/**
 * 具體策略 B: Google 社交登入
 */
export class GoogleAuthStrategy implements IAuthStrategy {
  async login(authCode: string): Promise<UserSession> {
    if (!authCode) {
      throw new Error("缺少授權碼。");
    }
    
    // 呼叫 Google 服務,然後可能是後端 API 換取 Token
    const response = await googleOAuthService.exchangeToken(authCode);
    
    // 處理響應
    return response.data as UserSession;
  }
}

4. 上下文 (Context):高階邏輯 (useAuthContext.ts)

這是元件(高階模組)與策略互動的橋樑。它負責持有切換策略,並且不包含任何具體登入邏輯

TypeScript
// src/composables/useAuthContext.ts

// 預設使用密碼登入,但這也可以通過 DI 容器來注入初始值
const currentStrategy = ref<IAuthStrategy>(new PasswordAuthStrategy());

/**
 * 設置當前的登入策略
 * @param strategy - 任何實作 IAuthStrategy 的實例
 */
export function setAuthStrategy(strategy: IAuthStrategy) {
  currentStrategy.value = strategy;
}

/**
 * Auth Context - 供高階元件使用
 */
export function useAuthContext() {
  const isLoading = ref(false);
  
  const login = async (...args: any[]) => {
    isLoading.value = true;
    try {
      // 依賴於抽象介面 IAuthStrategy.login()
      const session = await currentStrategy.value.login(...args); 
      // 成功後的通用處理 (例如:存儲到 Pinia/Vuex)
      console.log('Login successful, session:', session);
      return session;
    } catch (e) {
      console.error('Login failed:', e);
      throw e; 
    } finally {
      isLoading.value = false;
    }
  };

  // 暴露給元件:切換策略、執行登入
  return { login, setAuthStrategy, isLoading };
}

5. 應用:元件層 (LoginFormComponent.vue)

元件(高階模組)現在只依賴於 useAuthContext,與具體登入實現解耦。

程式碼片段
<script setup lang="ts">
import { useAuthContext, setAuthStrategy } from '@/composables/useAuthContext';
import { PasswordAuthStrategy } from '@/services/auth/strategies/PasswordAuthStrategy';
import { GoogleAuthStrategy } from '@/services/auth/strategies/GoogleAuthStrategy';

const { login, isLoading } = useAuthContext();

// --- 密碼登入邏輯 ---
const email = ref('');
const password = ref('');
const handlePasswordLogin = async () => {
  setAuthStrategy(new PasswordAuthStrategy()); // 確保使用密碼策略
  await login(email.value, password.value); // 傳遞策略所需的參數
};

// --- Google 登入邏輯 ---
const handleGoogleLogin = async (authCode: string) => {
  setAuthStrategy(new GoogleAuthStrategy()); // 切換到 Google 策略
  await login(authCode); // 傳遞策略所需的參數
};

</script>

<template>
  <div>
    <form @submit.prevent="handlePasswordLogin">
      <button type="submit" :disabled="isLoading">登入</button>
    </form>
    
    <hr>
    
    <button @click="handleGoogleLogin('some-google-code')" :disabled="isLoading">
      使用 Google 登入
    </button>
  </div>
</template>

總結優勢

  1. 符合 OCP (Open/Closed Principle): 如果新增一種登入方式(如:Apple ID 登入),只需新增一個 AppleAuthStrategy 類別,並在元件中加入 setAuthStrategy(new AppleAuthStrategy()) 的邏輯,無需修改 useAuthContext 的核心程式碼

  2. 符合 DIP (Dependency Inversion Principle): useAuthContext (高階) 不依賴於 PasswordAuthStrategy (低階),兩者都依賴於 IAuthStrategy (抽象)。

  3. 高可測試性: 測試 useAuthContext 時,只需 Mock IAuthStrategylogin 方法即可,完全不需要實際的 API 服務。

沒有留言:

張貼留言

📦 LogiFlow WMS:打造 SaaS 多租戶倉儲管理系統的技術實踐

📦 LogiFlow WMS:打造 SaaS 多租戶倉儲管理系統的技術實踐 在企業數位化的浪潮下,倉儲管理系統 (WMS) 不再只是單一公司的內部工具,而是需要支援 多租戶 (Multi-Tenant) 的 SaaS 架構。這意味著系統必須在共享基礎設施的同時,保有嚴格的資...