跳至內容

JavaScript 設計模式筆記 - SOLID 原則與 Vue.js 應用

2024 年初參加了一場 JavaScript 設計模式的讀書會,主題是討論 Addy Osmani 的《JavaScript 設計模式學習手冊》。 最近在重建部落格時,想起這場讀書會還沒留下點什麼紀錄有些可惜, 而且這種主題沒有時效問題,就決定趁這個機會整理一下當時的內容,順便 水一篇 分享給大家。

我負責的是最初的導讀,所以說雖然 Addy Osmani 書中是以 React 為例,但由於我的私心 (笑) 就盡量用我最熟悉的 Vue.js 的觀點來說明設計模式的概念與應用。

這篇文章整理了當時讀書會的重點內容,包含設計模式的基本概念、SOLID 原則,以及一些常見設計模式在 Vue.js 中的應用。

對於開發者來說,設計模式一直是個既熟悉又陌生的主題,說熟悉,是因為我們可能每天都在用;陌生,是因為用的時候你可能不一定知道自己正在用。


為什麼要學設計模式?

Good code is like a love letter to the next developer who will maintain it! 好的程式碼就像寫給下一個維護它的開發人員的情書! — Addy Osmani

這句話真的很經典。每次接手別人的專案,都希望看到的是「祖產豪宅」,結果往往是「危樓廢墟」。 設計模式就是幫助我們寫出好維護、好擴充程式碼的工具。

設計模式的好處:

  • 容易增減功能
  • 容易維護
  • 容易重複使用程式碼
  • 團隊合作時更容易溝通與分工

但也要小心過度使用:

  • 過度複雜,讓其他開發者難以理解
  • 過度設計(Over-engineering)
  • 選擇了不適合的設計模式

設計模式是什麼?

根據 Wikipedia 的定義:

在軟體工程中,軟體設計模式是在軟體設計的給定上下文中,針對普遍問題的一種通用且可重用的解決方案。

簡單來說:「有些人已經解決你的問題了!」

設計模式是一套解決程式設計問題的通用解決方案集合。它們:

  • 是一種抽象的模板,可以用於不同的情況和程式語言
  • 跟演算法有點像,但更偏向解決架構層面的問題
  • 一再重複出現的東西、事件、現象,就稱為「模式」
  • 你也可以建立自己的設計模式,只要它能解決問題並且能夠被重複使用

經典的 GoF

1995 年由四位作者(Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides)出版的《Design Patterns》被譽為「軟體工程師的聖經」。 書中提出 23 種經典設計模式,這四位作者也因此被稱為 GoF (Gang of Four)。

設計模式的三大類別:

  1. 建立型模式(Creational Patterns) - 用於物件的創建

    • 工廠模式、抽象工廠模式、單例模式、建造者模式、原型模式
  2. 結構型模式(Structural Patterns) - 用於表示物件組合

    • 適配器模式、橋接模式、組合模式、裝飾者模式、外觀模式、享元模式、代理模式
  3. 行為型模式(Behavioral Patterns) - 用於物件之間的通訊

    • 責任鏈模式、命令模式、解釋器模式、迭代器模式、中介者模式、備忘錄模式、觀察者模式、狀態模式、策略模式、模板方法模式、訪問者模式

反模式(Anti-Pattern)

反模式就像是一種模式,只不過它提供的不是解決方案,而是表面上看起來像解決方案但實際上不是解決方案的東西。

為什麼會產生反模式?

  • 經驗不足:不了解更好的做法
  • 時間壓力:趕著上線,先求有再求好
  • 誤用設計模式:把設計模式用在不適合的地方
  • 過度設計:為了用而用,反而把簡單問題複雜化

反模式比不用設計模式更危險,因為它會讓程式碼品質下降,增加維護成本。有時候反模式會被誤認為是設計模式,這是最危險的情況。

JavaScript 常見的反模式

1. 污染全域命名空間

javascript
// ❌ 反模式:所有變數都在全域
var name = 'John'
var age = 30
var email = 'john@example.com'

// ✅ 好的做法:使用模組或 IIFE
const user = {
  name: 'John',
  age: 30,
  email: 'john@example.com'
}

2. 將字串傳給 setTimeout/setInterval

javascript
// ❌ 反模式:會觸發 eval(),不安全且效能差
setTimeout("console.log('這是不安全的!')", 1000)

// ✅ 好的做法:傳遞函數
setTimeout(() => console.log('這是安全的!'), 1000)

3. 修改內建原型

javascript
// ❌ 反模式:修改 Array.prototype
Array.prototype.first = function() {
  return this[0]
}

// ✅ 好的做法:使用工具函數
function getFirst(arr) {
  return arr[0]
}

4. 不使用嚴格模式

javascript
// ❌ 反模式:沒有 'use strict',容易出錯
function calculateTotal() {
  totle = 100  // 拼錯了,但不會報錯,變成全域變數
}

// ✅ 好的做法:啟用嚴格模式
'use strict'
function calculateTotal() {
  const total = 100  // 拼錯會立即報錯
}

5. Callback Hell

javascript
// ❌ 反模式:多層嵌套的回呼
getData(function(a) {
  getMoreData(a, function(b) {
    getMoreData(b, function(c) {
      console.log(c)
    })
  })
})

// ✅ 好的做法:使用 Promise 或 async/await
async function fetchData() {
  const a = await getData()
  const b = await getMoreData(a)
  const c = await getMoreData(b)
  console.log(c)
}

如何避免反模式?

  1. 持續學習:了解最新的 Best Practices
  2. 程式碼審查:讓團隊成員互相 Review 程式碼
  3. 使用 Linter:透過工具自動檢查常見問題
  4. 寫測試:好的測試能暴露設計問題,提高程式碼品質
  5. 重構:定期重構程式碼,改善設計,避免累積技術債

JavaScript 的模組化

早期的 JavaScript 是沒有模組化概念的,歷經多年演進:

  1. IIFE(即時函數) - 利用閉包封裝模組,形成私有空間
  2. CommonJS - Node.js 出現後的模組規範
  3. AMD(Asynchronous Module Definition) - 非同步模組定義
  4. UMD(Universal Module Definition) - 通用模組定義
  5. ES6 Modules - 現代 JavaScript 的模組化標準(import / export

如今結合 Vite、Webpack、Rollup 等打包工具,可以進行靜態分析和 Tree Shaking,讓 JavaScript 的模組化更加完善。


SOLID 原則

遵守這些原則,就算不懂設計模式,也能寫出好的程式碼。

1. SRP - 單一職責原則(Single Responsibility Principle)

一個 Class 只負責一項職責

在 Vue.js 中:

  • 將不同的功能分割到不同的元件中
  • 確保每個元件只處理一個任務
  • 提高元件的重用性

範例:

假設有個購物車頁面,不應該把所有功能塞在同一個元件:

vue
<!-- ❌ 違反 SRP:一個元件做太多事 -->
<template>
  <div>
    <div>商品列表、價格計算、結帳按鈕、優惠券輸入...</div>
  </div>
</template>

<!-- ✅ 符合 SRP:拆分成多個元件 -->
<CartItemList />      <!-- 只負責顯示商品列表 -->
<CartSummary />       <!-- 只負責計算總價 -->
<CouponInput />       <!-- 只負責優惠券處理 -->
<CheckoutButton />    <!-- 只負責結帳按鈕 -->

2. OCP - 開放封閉原則(Open/Closed Principle)

對於擴展是開放的,對於修改是封閉的

能夠在不改變現有程式碼的情況下擴展元件的行為。

在 Vue.js 中:

  • 使用 slots 擴展元件功能
  • 使用 composables(推薦)來封裝可重用的邏輯
  • 避免使用 mixins(已不推薦)

範例:

使用 slot 讓元件可以擴展,而不需要修改原始元件:

vue
<!-- ❌ 違反 OCP:每次新增需求都要改元件 -->
<Button v-if="type === 'primary'" class="btn-primary">送出</Button>
<Button v-else-if="type === 'danger'" class="btn-danger">刪除</Button>
<!-- 每次新增按鈕類型都要改這裡 -->

<!-- ✅ 符合 OCP:用 slot 擴展 -->
<Button>
  <template #icon><IconPlus /></template>
  新增項目
</Button>
<!-- 可以自由擴展內容,不用改 Button 元件 -->

3. LSP - 里氏替換原則(Liskov Substitution Principle)

子類應該能夠替換它們的基類而不影響程式的正確性

若使用 TypeScript,可定義共同的 PaymentMethod 介面,以確保所有支付策略都遵守相同結構。 (這裡的共同介面相當於 TypeScript 的 interface)

在 Vue.js 中:

  • 子類別物件(具體策略)可以在程式中取代其父類別物件(策略介面)
  • 新增商品處理邏輯(新的策略)變得更加簡單,無需修改現有的元件邏輯

範例:

不同的支付方式應該可以互換使用:

javascript
// 所有支付方式都有相同的介面
const paymentMethods = {
  creditCard: { pay: (amount) => { /* 信用卡支付 */ } },
  cash: { pay: (amount) => { /* 現金支付 */ } },
  linePay: { pay: (amount) => { /* LINE Pay 支付 */ } }
}

// 可以自由切換,不影響程式運作
function checkout(method, amount) {
  method.pay(amount)  // 不管哪種支付方式,都能正常運作
}

4. ISP - 介面隔離原則(Interface Segregation Principle)

一個 Class(元件)不應該被強迫用到它不需要的方法

在 Vue.js 中:

  • 設計細粒度的 props 和事件
  • 確保元件不會被迫接收它們不需要的屬性或方法
  • 提高元件的獨立性和重用性

範例:

不要傳遞整個巨大的物件,只傳需要的屬性:

vue
<!-- ❌ 違反 ISP:傳整個 user 物件 -->
<UserCard :user="user" />
<!-- UserCard 可能只需要 name 和 avatar,卻被迫接收整個 user -->

<!-- ✅ 符合 ISP:只傳需要的屬性 -->
<UserCard
  :name="user.name"
  :avatar="user.avatar"
/>
<!-- 元件只接收它真正需要的資料 -->

5. DIP - 依賴反轉原則(Dependency Inversion Principle)

高階模組不該相依於低階模組,兩者都應該相依於抽象

白話:不要把程式碼寫死在某種實作上

在 Vue.js 中:

  • 元件不直接管理狀態,而是透過 Pinia store 來管理
  • 互動的部份交給抽象類別或介面,會改變的實作放到子類別裡面
  • 未來需求改變,只需要修改 store 的實作,而不需要修改使用的元件

範例:

元件不直接處理資料,而是相依於抽象的 store:

vue
<!-- ❌ 違反 DIP:元件直接處理 API 和資料 -->
<script setup>
const products = ref([])
const fetchProducts = async () => {
  const res = await fetch('/api/products')  // 寫死實作
  products.value = await res.json()
}
</script>

<!-- ✅ 符合 DIP:相依於抽象的 store -->
<script setup>
import { useProductStore } from '@/stores/product'
const productStore = useProductStore()
// 元件不知道資料怎麼來,只知道透過 store 取得
// 未來改用 GraphQL 或其他方式,元件不用改
</script>

其他常見的設計模式

以下是幾個在 Vue.js 中常見的設計模式:

1. 觀察者模式(Observer Pattern)

當一個狀態改變時,所有相依於它的目標都會得到通知並自動更新。

Vue.js 的響應式系統本質上就是一種觀察者模式的實現。當狀態變化時,Vue.js 會自動更新 DOM。

2. 代理模式(Proxy Pattern)

為其他物件提供一種代理以控制對這個物件的存取。

Vue 3 的 reactive 就是一種代理模式的實現,使用 JavaScript 的 Proxy API 來追蹤狀態變化。

3. Module 模式

用來模擬類別的私有成員,並且可以將公開的成員封裝在閉包中。

Vuex 或 Pinia 結合了 Module、Observer 與 Singleton 模式,用以實作集中式的狀態管理。

4. 工廠方法模式(Factory Method Pattern)

在不直接調用構造函數的情況下建立物件。

在 Vue.js 中,可透過 Props 或 Attr 來決定要渲染的元件、樣式等:

vue
<component :is="currentView" />

<component :is> 屬於 Vue 的動態元件機制,本質是透過工廠方法的概念實現動態建立與切換元件。 根據不同條件動態切換元件,這就是工廠模式在 Vue.js 的應用。

5. 單例模式(Singleton Pattern)

確保一個類別只有一個實例,並且讓全域都能存取。

全域狀態管理(如 Pinia store)就是單例模式的典型應用,確保整個應用程式共享同一個狀態實例。

6. 策略模式(Strategy Pattern)

定義一系列的算法,把它們一個個封裝起來,並且使它們可以互相替換。

例如表單驗證時,可以定義多種驗證策略(email 驗證、長度驗證、必填驗證等),根據需求組合使用。

7. 命令模式(Command Pattern)

將請求封裝為物件,藉由不同的請求來參數化其他物件。

常見於實作 Undo/Redo 功能,將每個操作封裝成命令物件,就能輕鬆實現這類功能。

8. 責任鏈模式(Chain of Responsibility Pattern)

使多個物件都有機會處理請求,從而避免請求的發送者和接收者之間的耦合關係。

例如一連串的驗證規則,按順序對輸入進行驗證,直到某規則回傳驗證失敗,或者所有條件都通過。


複合模式

實務上分析需求時,常常會發現要同時結合多種模式才能解決問題。

  • 不見得一個問題只能用一種模式
  • 分析需求時,通常會發現要結合多個模式才能解決問題
  • 有些模式是多個基本模式所結合出來的,就被稱為複合模式
  • 例如傳統 MVC 就會用到 Strategy 模式、Observer 模式、Composite 模式等

總結

整理完這篇筆記,回想當時讀書會的討論,最大的收穫不是記住了多少種設計模式,而是理解了「為什麼要用」比「怎麼用」更重要。

設計模式不是銀彈,也不是炫技的工具,它最大的價值在於:

  1. 提供共通的語言 - 跟同事說「這裡用觀察者模式」,大家立刻知道你在講什麼
  2. 前人的經驗總結 - 避免重蹈覆轍,站在巨人的肩膀上
  3. 幫助思考架構 - 面對複雜問題時,有個思考的框架

但要記住幾個重點:

  • 先求簡單: 不要為了用設計模式而用,先解決問題再說
  • 遵守 SOLID:就算不懂設計模式,遵守 SOLID 原則也能寫出好程式碼
  • 避免過度設計: 過早最佳化 (Premature Optimization) 是萬惡的根源
  • 別被名詞困住:看到自己寫的程式碼符合某個模式的精神,恭喜你已經在用了!

給想學設計模式的朋友:

  1. 不用急著全部記起來,先從常用的開始(觀察者、工廠、單例)
  2. 在實際專案中練習,看到問題自然會想到對應的模式
  3. 多看別人的程式碼,尤其是開源專案(Vue、React 等框架的原始碼都是設計模式的寶庫)
  4. 持續重構,設計模式不是一次到位,而是持續改進的過程

最後,寫程式跟寫文章一樣,重點是讓讀者(包含未來的自己)容易理解。設計模式是幫助我們達成這個目標的工具,而不是目標本身。


相關資源:

💬 留言討論

Released under the MIT License.