最近Code Review的時候,我看到我們組一個很聰明的年輕同事,用觀察者模式,寫了一個極其複雜的全球狀態訂閱系統,就為了在一個元件裡,響應另一個不相關的元件的點擊事件。
比較常見的場景:點擊 Button 元件,讓 Panel 元件打印日誌或顯示提示,具體偽代碼👇:
// observer.js
class Observer {
constructor() {
this.subscribers = [];
}
subscribe(fn) {
this.subscribers.push(fn);
}
unsubscribe(fn) {
this.subscribers = this.subscribers.filter(sub => sub !== fn);
}
notify(data) {
this.subscribers.forEach(fn => fn(data));
}
}
// 全球狀態中心(相當於單例)
export const globalClickObserver = new Observer();
// Button.jsx
import React from "react";
import { globalClickObserver } from "./observer";
export default function Button() {
const handleClick = () => {
console.log("Button clicked");
globalClickObserver.notify({ source: "Button", payload: "Hello Panel" });
};
return <button onClick={handleClick}>Click</button>;
}
// Panel.jsx
import React, { useEffect } from "react";
import { globalClickObserver } from "./observer";
export default function Panel() {
useEffect(() => {
const subscriber = (data) => {
if (data.source === "Button") {
console.log("event:", data.payload);
}
};
globalClickObserver.subscribe(subscriber);
return () => globalClickObserver.unsubscribe(subscriber);
}, []);
return <div>I'm Panel</div>;
}
我把他叫過來,問他為什麼不直接用一個簡單的Event Bus(比如mitt
),或者乾脆用Zustand
這樣的狀態管理器。
他說:“我覺得用設計模式,代碼的擴展性會更好,也顯得更高級😂。”
這個瞬間,讓我下定決心,想聊聊這個話題:
在現代前端開發(尤其是React/Vue)中,我們掛在嘴邊的那些經典設計模式,90%都是在過度設計。
在我開噴之前,請允許我澄清:我反對的不是設計思想,比如 高內聚低耦合、單一職責。我反對的是,把那些20年前為Java/C++總結的、沉重的、面向對象的大招,生搬硬套到我們現代前端的開發範式裡。
曾幾何時,我也曾是設計模式的忠實信徒。熱衷於在代碼裡尋找應用工廠模式、策略模式的場景。
我們之所以會這樣,我覺得原因有二:
為了應對面試
設計模式是前端面試八股文裡的重災區。為了通過面試,我們不得不去背誦它們的定義和用法,這就導致了一種為了應考的慣性思維。
看起來牛皮🤷♂️
我們總覺得,能說出幾個設計模式的名字,能把它們用在代碼裡,就代表自己的水平更高。仿佛不說個單例、不聊個裝飾器,就體現不出自己的資深。
我們來看幾個在前端領域最常被濫用的經典模式。
經典寫法:搞一個class
,一個私有構造函數,再加一個getInstance
的靜態方法,防止被多次new
。
我的吐槽點:別鬧了,我們有ES6模組!!!
前端的原生模式:JavaScript的import/export
機制,天生就是單例的。你export
一個實例,在所有地方import
它,它從始至終就是同一個實例。
// a.js
class MyService { /* ... */ }
// 導出一個實例
export const myServiceInstance = new MyService();
// b.js
import { myServiceInstance } from './a.js';
// c.js
import { myServiceInstance } from './a.js';
// b.js和c.js裡的myServiceInstance,是同一個東西
為了實現單例,而去手寫一個Singleton
類,在現代前端裡,屬於(省略一萬字...)。
寫法:寫一個create
函數,根據傳入的type
,new
出不同的類的實例 ?。
在React/Vue裡,我們有比工廠更強大、更直觀的武器——元件。你根本不需要一個create
函數,你只需要一個元件,通過props
來決定它的形態和行為。
// 你不需要一個 createButton 的工廠
// 你只需要一個 Button 元件
function Button({ kind, ...props }) {
if (kind === 'icon') {
return <IconButton {...props} />;
}
if (kind === 'text') {
return <TextButton {...props} />;
}
return <PrimaryButton {...props} />;
}
用元件思維去思考,比工廠思維更符合現代前端的直覺。
寫法:維護一個訂閱者列表(subscribers
),提供subscribe
、unsubscribe
和notify
方法?
我的吐槽點是:你的框架自帶的響應式系統,比你手寫的強一百倍。
React的useState
/useEffect
,Vue的ref
/watch
,它們本身就是更高階、更強大的響應式系統,是觀察者模式的終極體現。狀態(被觀察者)變化,UI(觀察者)自動更新。你為什麼要去手寫一個簡陋版的偽響應式,而不用框架自帶的、經過千錘百鍛的完整經驗呢?
我噴了90%,那剩下10%依然有價值的是什麼?在我看來,是一些設計思想,而不是具體的什麼大招。
發佈/訂閱模式 (Pub/Sub):
它和觀察者模式很像,但更解耦。當兩個完全不相關的元件需要通信,而你又不想為此引入一個全球狀態庫時,一個輕量級的事件總線(Event Bus)或者 mitt
,就非常有用。
// pubsub.js
class PubSub {
constructor() {
this.events = {}; // 儲存事件和對應的訂閱者回調
}
// 訂閱
subscribe(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
return () => this.unsubscribe(event, callback); // 返回取消訂閱函數
}
// 取消訂閱
unsubscribe(event, callback) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
// 發佈
publish(event, data) {
if (!this.events[event]) return;
this.events[event].forEach(callback => callback(data));
}
}
// 導出一個全球單例
export const pubsub = new PubSub();
策略模式:
這個模式的核心思想——將不同的算法封裝起來,使它們可以互相替換——在前端依然非常閃光。它能幫助我們寫出更優雅、更易擴展的代碼,用來代替冗長的if/else
或switch
。
// 比如,處理不同類型的用戶折扣
const strategies = {
'normal': (price) => price,
'vip': (price) => price * 0.8,
'svip': (price) => price * 0.6,
};
function calculatePrice(userType, price) {
return strategies[userType](price);
}
你看,這裡沒有class
,沒有那麼複雜的邏輯,但它蘊含了策略模式的思想。
作為組長,當我在Code Review裡看到一個同事用了工廠模式時,我不會覺得他很牛逼。我反而會警惕:他是不是為了炫技?而選擇了一個更複雜的方案?我們能不能用一個簡單的React元件,就把這事兒給幹了?
現代前端框架,已經為我們內建了一套非常優秀、非常自洽的設計模式。元件是工廠,Hooks是裝飾器/策略,響應式系統是觀察者。
你的目標,不是寫出能套上某個設計模式名字的代碼,而是寫出簡單、清晰、易於維護的代碼。在前端,後者往往比前者重要得多🤷♂️。