跳至內容

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. 持續重構,設計模式不是一次到位,而是持續改進的過程

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


相關資源:

💬 留言討論