本篇要介紹 SOLID 的第三個原則- Liskov Substitution Principle,里氏替換原則(LSP)
,老實說相關參考討論的資源,主要是針對類別(class) 繼承時覆寫父類的行為
有關,但是在前端或函式開發的案例少之又少一樣幾乎沒有(我覺得是最難理解且案例最少的準則之一),不過慢慢思索 Vue 開發中有那些時候可以用到,還是可以當作開發準則認識一下,避免程式在擴充時行為不一。
子類別應該繼承並擴展父類別的功能,而不能做出違反父類別預期行為的改動。我們在程式中如果原本用的是父類別的物件,換成子類別物件後,程式仍然能夠正常工作,而不會破壞原來的邏輯。
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中到底哪裡會有違反繼承擴充行為
的現象,因為一般引用子元件到父元件時,在UI顯示邏輯上會自動繼承呈現。比較常發生的應該是 Composables 組合函式組合過程
,在函式功能擴展或替換原本傳入的函式功能,改變了原本的行為,使得呼叫某段功能時回傳結果不一致。
嘗試用一些具體的違反 LSP 案例,看看在 Vue Composables 中這類的錯誤到底會發生什麼事。
這是一個之前提到過簡單的計數器 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
添加了新的功能(如 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 的組合式函式擴展功能時,我們應該避免替換或修改原本的行為,而應在此基礎上進行擴展,保持回傳值與原本接口的一致性,確保使用者對該函式的行為預期不會被打破。
如果大家有實務上看到的建議或想法~~也可以提出回饋喔,設計理論真的好吃經驗和自我摸索,如果後續有找到更相關的案例也會補充上來~大家一起加油!