🚀 資深工程師面試: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) 於表單驗證
場景: 撰寫一個可重用的
useValidatorComposable,用於不同的表單欄位。問題: 每個欄位(Email、密碼、手機號碼)有不同的驗證規則。如果直接在 Composable 內部寫
if/else判斷,會違反 OCP。策略模式實作:
策略介面 (IValidationStrategy): 定義一個通用的
validate(value: string): string | null函式。具體策略 (Concrete Strategy): 實作多個驗證類別,例如
EmailValidator、PasswordValidator、RequiredValidator。上下文 (Context):
useValidatorComposable 接收一個策略陣列。
// 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本身。
// 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),我們將關係繪製如下:
為了清晰展示依賴反轉 (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() |
+-------------------------+
說明:
高階元件 (LoginFormComponent) 透過 setAuthStrategy() 告訴 Context 應使用哪種策略。
元件隨後呼叫 Context 的 login() 方法。
Context 不關心細節,它只呼叫當前持有的 抽象策略 的 login() 方法。
實際的登入邏輯由特定的具體策略負責執行。這使得元件可以擴充新的登入方式,但無需修改 useAuthContext。
+------------------------------------+
| 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() |
+-------------------------+
說明:
高階元件 (
LoginFormComponent) 透過setAuthStrategy()告訴 Context 應使用哪種策略。元件隨後呼叫 Context 的
login()方法。Context 不關心細節,它只呼叫當前持有的 抽象策略 的
login()方法。實際的登入邏輯由特定的具體策略負責執行。這使得元件可以擴充新的登入方式,但無需修改
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 相關資訊
}
這是高階與低階模組共同依賴的抽象。
// 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;
}
}
這是實現特定細節的低階模組。它依賴並實作抽象介面。
// 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;
}
}
這是另一個可替換的低階模組。
// 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 };
}
這是元件(高階模組)與策略互動的橋樑。它負責持有和切換策略,並且不包含任何具體登入邏輯。
// 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>
元件(高階模組)現在只依賴於 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>
總結優勢
符合 OCP (Open/Closed Principle): 如果新增一種登入方式(如:Apple ID 登入),只需新增一個 AppleAuthStrategy 類別,並在元件中加入 setAuthStrategy(new AppleAuthStrategy()) 的邏輯,無需修改 useAuthContext 的核心程式碼。
符合 DIP (Dependency Inversion Principle): useAuthContext (高階) 不依賴於 PasswordAuthStrategy (低階),兩者都依賴於 IAuthStrategy (抽象)。
高可測試性: 測試 useAuthContext 時,只需 Mock IAuthStrategy 的 login 方法即可,完全不需要實際的 API 服務。
符合 OCP (Open/Closed Principle): 如果新增一種登入方式(如:Apple ID 登入),只需新增一個
AppleAuthStrategy類別,並在元件中加入setAuthStrategy(new AppleAuthStrategy())的邏輯,無需修改useAuthContext的核心程式碼。符合 DIP (Dependency Inversion Principle):
useAuthContext(高階) 不依賴於PasswordAuthStrategy(低階),兩者都依賴於IAuthStrategy(抽象)。高可測試性: 測試
useAuthContext時,只需 MockIAuthStrategy的login方法即可,完全不需要實際的 API 服務。
沒有留言:
張貼留言