iT邦幫忙

2024 iThome 鐵人賽

DAY 23
0
JavaScript

Vue.js學習中的細節陷阱:30天自我學習指南系列 第 23

Day 23: SOLID - 里式替換原則(LSP) 和 Vue的組合式函式擴充

  • 分享至 

  • xImage
  •  

本篇要介紹 SOLID 的第三個原則- Liskov Substitution Principle,里氏替換原則(LSP),老實說相關參考討論的資源,主要是針對類別(class) 繼承時覆寫父類的行為有關,但是在前端或函式開發的案例少之又少一樣幾乎沒有(我覺得是最難理解且案例最少的準則之一),不過慢慢思索 Vue 開發中有那些時候可以用到,還是可以當作開發準則認識一下,避免程式在擴充時行為不一。

今日學習目標

  1. 理解里氏替換原則 (Liskov Substitution Principle, LSP)
  2. Vue 組合式函式(composable)彼此組裝時注意事項

里氏替換原則 (LSP)

子類別應該繼承並擴展父類別的功能,而不能做出違反父類別預期行為的改動。我們在程式中如果原本用的是父類別的物件,換成子類別物件後,程式仍然能夠正常工作,而不會破壞原來的邏輯。

LSP 要求子類型在替代父類型時,應該保持相同行為的預期,或者在不改變功能預期的前提下進行擴展。

因為 類別的繼承(inheritance) 的特性會有一些耦合關係,如果在設計思維上有繼承關係,子類別對於方法修改(Override, Overload) 必須依照父類別行為方向,否則會對整體的繼承體系照成破壞,會有產生不可預測的行為與不好察覺的Bug。

像是以漫畫圖機器人來說,如果設計上咖啡機器人衍生第二代的小機器人,如果整個外觀都是承襲上一代設計,我們會認為同樣指令點餐時,應該是送來咖啡吧,可惜改造過程中製作餐點的行為變成製作水了。

// 第一代機器人
class CoffeeMachine {
 makeCoffee() {
 return 'Here's your coffee';
 }
}
// 第二代咖啡機器人,製作咖啡功能被覆寫了
class WrongCoffeeMachine extends CoffeeMachine {
 makeCoffee() {
 return 'Here's your water'; // 改變了行為,與原本咖啡無關變成水了
}

比較正確做法

class AdvancedCoffeeMachine extends CoffeeMachine {
  makeCoffee() {
    return 'Here’s your cappuccino'; // 即便要覆寫修改,擴展原有功能但返回行為一致
  }

  makeLatte() {
    return 'Here’s your latte'; // 新增功能
  }

  makeWater() {
    return 'Here’s your water'; // 另一個新增功能
  }


Vue Composable 與里式替換(LSP)原則

想了好久在Vue中到底哪裡會有違反繼承擴充行為的現象,因為一般引用子元件到父元件時,在UI顯示邏輯上會自動繼承呈現。比較常發生的應該是 Composables 組合函式組合過程,在函式功能擴展或替換原本傳入的函式功能,改變了原本的行為,使得呼叫某段功能時回傳結果不一致。

嘗試用一些具體的違反 LSP 案例,看看在 Vue Composables 中這類的錯誤到底會發生什麼事。

改變原本 Composable function 的核心行為

  • useCounter

這是一個之前提到過簡單的計數器 Composable,可以遞增或重置計數,並且重複地在多個元件中使用共同的計數邏輯。

// useCounter.js
import { ref } from 'vue';

export function useCounter() {
  const count = ref(0);

  function increment() {
    count.value++;
  }

  function reset() {
    count.value = 0;
  }

  return { count, increment, reset };
}

製作另一個 useAdvancedCounter ,是將原本 useCounter 做一些新功能的擴充。

這個 Composable 嘗試擴展 useCounter,但改變了 reset 方法的行為,讓原本重制的設定變為 10,而不是重置為 0。 ,如果引入元件調用 reset 方法時,會讓從原本useCounter擴展來的開發者覺得困惑,如果未來組合式函式組裝過程變得比較複雜時,中途這一段覆寫設計有可能造成程式碼行為比較難追蹤。

也跟LSP上所定義功能的應該保持返回行為或執行結果一致、也不應破壞使用者對於該 Composable 行為的預期行為。

// useAdvancedCounter.js
import { useCounter } from './useCounter';

export function useAdvancedCounter() {
  const { count, increment, reset } = useCounter();

  function resetToTen() {
    count.value = 10;  // 這裡改變了重置行為
  }

  // 替代原本的 reset,違反了LSP保持行為一致
  return { count, increment, reset: resetToTen };
}

當擴充 Composable 的功能時,應當保持原有功能的完整性穩定性,而不是更改命名替換原本的回傳值。在進行擴展時,應保留原有的接口和方法,並將新的功能添加進去。這樣可以確保擴展後的 Composable 能夠向後相容,並且忠實反映所有功能。

import { useToggle } from './useToggle';

export function useToggleWithLog() {
  const { isOn, toggle } = useToggle();

  // 這樣可以對原本功能流程 進行額外封裝 但必須對原本toggle行為保持不變
  function performToggle() {
    console.log('Toggling state');
    // 其他計算和處理流程等 和 isOn 資料計算
    toggle();
  }

  // 返回時避免直接重新命名新的 toggle,明確回傳在這裡定義重新封裝的行為
  return { toggle, performToggle };


VueUse useRefHistory 封裝

可以用昨天提到的 VueUse 套件來觀察如何擴展組合式函式的行為:

useRefHistory 添加了新的功能(如 commit、batch 和 dispose),這些都是在原有的功能上擴展出來的,內部設計上並不是重新命名原本的功能,而是透過流程封裝保存為原有功能,這樣可以確保原有功能和新的功能可以共存,並且不會影響原有行為的預期。

export function useRefHistory<Raw, Serialized = Raw>(
  source: Ref<Raw>,
  options: UseRefHistoryOptions<Raw, Serialized> = {},
): UseRefHistoryReturn<Raw, Serialized> {
 // 略

  const manualHistory = useManualRefHistory(source, { ...options, clone: options.clone || deep, setSource })

  const { clear, commit: manualCommit } = manualHistory
  
  // 利用另一個組合式函式提供的功能,進行功能衍伸組裝

  function commit() {
    // This guard only applies for flush 'pre' and 'post'
    // If the user triggers a commit manually, then reset the watcher
    // so we do not trigger an extra commit in the async watcher
    ignorePrevAsyncUpdates()

    manualCommit()
  }
  
  // 返回值
  return {
    ...manualHistory, // 原本的composable
    isTracking, 
    pause,
    resume,
    commit, // 新加入的功能
    batch,
    dispose,
  }


總結

里氏替換原則 (Liskov Substitution Principle, LSP) 是要求在物件導向設計(OOP),子類別應該能夠替換父類別,且不會破壞系統的正確性和預期行為。代表著子類別應該擴展父類別的功能,而不能違反父類別行為的預期。

如果對比應用在前端開發中,在 Vue 的組合式函式擴展功能時,我們應該避免替換或修改原本的行為,而應在此基礎上進行擴展,保持回傳值與原本接口的一致性,確保使用者對該函式的行為預期不會被打破。

如果大家有實務上看到的建議或想法~~也可以提出回饋喔,設計理論真的好吃經驗和自我摸索,如果後續有找到更相關的案例也會補充上來~大家一起加油!


學習資源

  1. https://medium.com/@nwyyy/liskov-substitution-principle-85594a75b3d1
  2. https://ithelp.ithome.com.tw/m/articles/10326231
  3. https://howardphung.hashnode.dev/liskov-substitution-principle-solid-in-javascript
  4. https://medium.com/%E7%A8%8B%E5%BC%8F%E6%84%9B%E5%A5%BD%E8%80%85/%E4%BD%BF%E4%BA%BA%E7%98%8B%E7%8B%82%E7%9A%84-solid-%E5%8E%9F%E5%89%87-%E9%87%8C%E6%B0%8F%E6%9B%BF%E6%8F%9B%E5%8E%9F%E5%89%87-liskov-substitution-principle-e66659344aed
  5. https://www.explainthis.io/zh-hant/swe/what-is-hof (JavaScript HOFs)
  6. https://www.freecodecamp.org/news/higher-order-functions-explained/

上一篇
Day 22: SOLID - 開放封閉原則(OCP) 和 Vue的元件開發
下一篇
Day 24: SOLID - 介面分離原則(ISP) 和 Vue 的動態元件切換
系列文
Vue.js學習中的細節陷阱:30天自我學習指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言