前端的設計模式是什么
設計模式一個比較宏觀的概念,通俗來講,它是軟件開發人員在軟件開發過程中面臨的一些具有代表性問題的解決方案。當然,在實際開發中不用設計模式同樣也是可以實現需求的,只是在業務邏輯比較復雜的情況下,代碼可讀性及可維護性變差。所以隨着業務邏輯的擴展,了解常用設計模式解決問題是非常有必要的。
前端的設計模式的基本准則
- 單一職責原則:每個類只需要負責自己的那部分,類的復雜度降低。
- 開閉原則:一個實體,如類、模塊和函數應該對擴展開放,對修改關閉,讓程序更穩定更靈活。
- 里式替換原則:所有引用基類的地方必須能透明地使用其子類的對象,也就是說子類對象可以替換其父類對象,而程序執行效果不變。便於構建擴展性更好的系統。
- 依賴倒置原則:上層模塊不應該依賴底層模塊,它們都應該依賴於抽象;抽象不應該依賴於細節,細節應該依賴於抽象。這可以讓項目擁有變化的能力。
- 接口隔離原則:多個特定的客戶端接口要好於一個通用性的總接口,系統有更高的靈活性。
- 迪米特原則(最少知識原則):一個類對於其他類知道的越少越好,也就是說一個對象應當對其他對象有盡可能少的了解。
設計模式的種類
1. 創建型模式
一般用於創建對象。
包括:單例模式,工廠方法模式,抽象工廠模式,建造者模式,原型模式。
2. 結構型模式
重點為“繼承”關系,有着一層繼承關系,且一般都有“代理”。
包括:適配器模式,橋接模式,組合模式,裝飾器模式,外觀模式,享元模式,代理模式,過濾器模式。
3. 行為型模式
職責的划分,各自為政,減少外部的干擾。
包括:命令模式,解釋器模式,迭代器模式,中介者模式,備忘錄模式,觀察者模式,狀態模式,策略模式,模板方法模式,訪問者模式,責任鏈模式。
http://www.bijianshuo.com 軟文發稿平台
前端常用的計模式應用實例
1、單例模式
單例模式又稱為單體模式,保證一個類只有一個實例,並提供一個訪問它的全局訪問點。一個極有可能重復出現的“實例”, 如果重復創建,將會產生性能消耗。如果借助第一次的實例,后續只是對該實例的重復使用,這樣就達到了我們節省性能的目的。
全局彈窗是前端開發中一個比較常規的需求,一般情況下,同一時間只會存在一個全局彈窗,我們可以實現單例模式,保證每次實例化時返回的實際上是同一個方法。
class MessageBox { show() { console.log("show"); } hide() {} static getInstance() { if (!MessageBox.instance) { MessageBox.instance = new MessageBox(); } return MessageBox.instance; } } let box3 = MessageBox.getInstance(); let box4 = MessageBox.getInstance(); console.log(box3 === box4); // true
上面這種是比較常見的單例模式實現,但是這種方式存在一些弊端。因為它需要讓調用方了解到通過Message.getInstance來獲取單例。又或者假設需求變更,可以通過存在二次彈窗,則需要改動不少地方,因為MessageBox除了實現常規的彈窗邏輯之外,還需要負責維護單例的邏輯。因此,可以將初始化單例的邏輯單獨維護,實現一個通用的、返回某個類對應單例的方法。
function getSingleton(ClassName) { let instance; return () => { if (!instance) { instance = new ClassName(); } return instance; }; } const createMessageBox = getSingleton(MessageBox); let box5 = createMessageBox(); let box6 = createMessageBox(); console.log(box5 === box6);
這樣,通過createMessageBox返回的始終是同一個實例。如果在某些場景下需要生成另外的實例,則可以重新生成一個createMessageBox方法,或者直接調用new MessageBox(),這樣就對之前的邏輯不會有任何影響。
2、工廠模式
工廠模式提供了一種創建對象的方法,對使用方隱藏了對象的具體實現細節,並使用一個公共的接口來創建對象。
前端本地存儲目前最常見的方案就是使用localStorage,為了避免在業務代碼中各種getItem和setItem,我們可以做一下最簡單的封裝。
let themeModel = { name: "local_theme", get() { let val = localStorage.getItem(this.name); return val && jsON.parse(val); }, set(val) { localStorage.setItem(this.name, jsON.stringify(val)); }, remove() { localStorage.removeItem(this.name); }, }; themeModel.get(); themeModel.set({ darkMode: true });
這樣,通過themeModel暴露的get、set接口,我們無需再維護local_theme。但上面的封裝也存在一些可見的問題,如果需要新增多個 name,那么上面的模板代碼需要重新寫多遍嗎?為了解決這個問題,我們可以創建Model對象的邏輯進行封裝。
const storageMap = new Map() function createStorageModel(key, storage = localStorage) { // 相同key返回單例 if (storageMap.has(key)) { return storageMap.get(key); } const model = { key, set(val) { storage.setItem(this.key, JSON.stringify(val);); }, get() { let val = storage.getItem(this.key); return val && JSON.parse(val); }, remove() { storage.removeItem(this.key); }, }; storageMap.set(key, model); return model; } const themeModel = createStorageModel('local_theme', localStorage) const utmSourceModel = createStorageModel('utm_source', sessionStorage)
這樣,我們就可以通過createStorageModel這個公共的接口來創建各種不同本地存儲的對象,而無需關注創建對象的具體細節。
3、策略模式
策略模式,可以針對不同的狀態,給出不同的算法或者結果。將層級相同的邏輯封裝成可以組合和替換的策略方法,減少if...else代碼,方便擴展后續功能。
表單校驗是我們最常見的場景了,我們一般都會想到用if...else來判斷。
function onFormSubmit(params) { if (!params.name) { return showError("請填寫昵稱"); } if (params.name.length > 6) { return showError("昵稱最多6位字符"); } if (!/^1\d{10}$/.test(params.phone)) return showError("請填寫正確的手機號"); } // ... sendSubmit(params) }
將所有字段的校驗規則都堆疊在一起,代碼量大,排查問題也是一個大麻煩。在遇見錯誤時,直接通過 return 跳過了后面的判斷;如果我們希望直接展示每個字段的錯誤呢,那么改動的工作量又不少。不過,在antd、ELementUI等框架盛行的年代,我們已經不再需要寫這些復雜的表單校驗,但是對於他們的實現原理,我們可以簡單模擬一下。
// 定義一個校驗的類,主要暴露了構造參數和validate兩個接口 class Schema { constructor(descriptor) { this.descriptor = descriptor; // 傳入定義的校驗規則 } // 拆分出一些更通用的規則,比如required(必填)、len(長度)、min/max(最值)等,可以盡可能地復用 handleRule(val, rule) { const { key, params, message } = rule; let ruleMap = { required() { return !val; }, max() { return val > params; }, validator() { return params(val); }, }; let handler = ruleMap[key]; if (handler && handler()) { throw message; } } validate(data) { return new Promise((resolve, reject) => { let keys = Object.keys(data); let errors = []; for (let key of keys) { const ruleList = this.descriptor[key]; if (!Array.isArray(ruleList) || !ruleList.length) continue; const val = data[key]; for (let rule of ruleList) { try { this.handleRule(val, rule); } catch (e) { errors.push(e.toString()); } } } if (errors.length) { reject(errors); } else { resolve(); } }); } } // 聲明每個字段的校驗邏輯 const descriptor = { nickname: [ { key: "required", message: "請填寫昵稱" }, { key: "max", params: 6, message: "昵稱最多6位字符" }, ], phone: [ { key: "required", message: "請填寫電話號碼" }, { key: "validator", params(val) { return !/^1\d{10}$/.test(val); }, message: "請填寫正確的電話號碼", }, ], }; // 開始對數據進行校驗 const validator = new Schema(descriptor); const params = { nickname: "", phone: "123000" }; validator.validate(params).then(() => { console.log("success"); }).catch((e) => { console.log(e); });
Schema主要暴露了構造參數和validate兩個接口,是一個通用的工具類,而params是表單提交的數據源,因此主要的校驗邏輯實際上是在descriptor中聲明的。將常見的校驗規則都放在ruleMap中,比之前各種不可復用的if..else判斷更容易維護和迭代。
4、狀態模式
狀態模式允許一個對象在其內部狀態改變的時候改變它的行為。狀態模式的思路是:首先創建一個狀態對象保存狀態變量,然后封裝好每種動作對應的狀態,然后狀態對象返回一個接口對象,它可以對內部的狀態修改或者調用。
常見的使用場景,比如滾動加載,包含了初始化加載、加載成功、加載失敗、滾動加載等狀態,任意時間它只會處於一種狀態。
// 定義一個狀態機 class rollingLoad { constructor() { this._currentState = 'init' this.states = { init: { failed: 'error' }, init: { complete: 'normal' }, normal: { rolling: 'loading' }, loading: { complete: 'normal' }, loading: { failed: 'error' }, } this.actions = { init() { console.log('初始化加載,大loading') }, normal() { console.log('加載成功,正常展示') }, error() { console.log('加載失敗') }, loading() { console.log('滾動加載') } // ..... } } change(state) { // 更改當前狀態 let to = this.states[this._currentState][state] if(to){ this._currentState = to this.go() return true } return false } go() { this.actions[this._currentState]() return this } } // 狀態更改的操作 const rollingLoad = new rollingLoad() rollingLoad.go() rollingLoad.change('complete') rollingLoad.change('loading')
這樣,我們就可以通過狀態變更,運行相應的函數,且狀態之間存在聯系。那么,看起來是不是和策略模式很像呢?其實不然,策略類的各個屬性之間是平等平行的,它們之間沒有任何聯系。而狀態機中的各個狀態之間存在相互切換,且是被規定好了的。
5、發布-訂閱模式
發布—訂閱模式又叫觀察者模式,它定義對象間的一種一對多的依賴關系,當一個對象的狀態發生改變時,所有依賴於它的對象都將得到通知。
發布訂閱模式大概是前端同學最熟悉的設計模式之一了,常見的事件監聽addEventListener,各種屬性方法onload、onchange,vue響應式數據,組件通信redux、eventBus等。
常見的獲取登錄信息,假設我們開發一個商城網站,網站里有 header 頭部、nav 導航、消息列表、購物車等模塊。這幾個模塊的渲染有一個共同的前提條件,就是必須先用 ajax 異步請求獲取用戶的登錄信息。比如用戶的名字和頭像要顯示在 header 模塊里,而這兩個字段都來自用戶登錄后返回的信息。異步的問題通常也可以用回調函數來解決:
login.succ(function(data){ header.setAvatar( data.avatar); // 設置 header 模塊的頭像 nav.setAvatar( data.avatar ); // 設置導航模塊的頭像 message.refresh(); // 刷新消息列表 cart.refresh(); // 刷新購物車列表 });
我們還必須了解 header 模塊里設置頭像的方法叫setAvatar、購物車模塊里刷新的方法叫refresh,這種強耦合性會使程序變得不易拓展。那么回頭看看我們的發布—訂閱模式,這種模式下,對用戶信息感興趣的業務模塊可以自行訂閱登錄成功的消息事件。當登錄成功時,登錄模塊只需要發布登錄成功的消息,而業務方接受到消息之后,就會開始進行各自的業務處理,登錄模塊並不關心業務方究竟要做什么。
// 發布登錄成功的消息 $.ajax( 'http://xxx.com?login', function(data){ // 登錄成功 login.trigger( 'loginSucc', data); // 發布登錄成功的消息 }); // 各模塊監聽登錄成功的消息 var header = (function(){ // header 模塊 login.listen( 'loginSucc', function(data){ header.setAvatar( data.avatar ); }); return { setAvatar: function( data ){ console.log( '設置 header 模塊的頭像' ); } } })(); var nav = (function(){ // nav 模塊 login.listen( 'loginSucc', function( data ){ nav.setAvatar( data.avatar ); }); return { setAvatar: function( avatar ){ console.log( '設置 nav 模塊的頭像' ); } } })();
發布—訂閱模式可以廣泛應用於異步編程中,這是一種替代傳遞回調函數的方案。比如,我們可以訂閱ajax請求的error、succ等事件。或者如果想在動畫的每一幀完成之后做一些事情,那我們可以訂閱一個事件,然后在動畫的每一幀完成之后發布這個事件。在異步編程中使用發布—訂閱模式,我們就無需過多關注對象在異步運行期間的內部狀態,而只需要訂閱感興趣的事件發生點。
6、迭代器模式
迭代器模式是指提供一種方法順序訪問一個聚合對象中的各個元素,而又不需要暴露該對象的內部表示。迭代器模式可以把迭代的過程從業務邏輯中分離出來,在使用迭代器模式之后,即使不關心對象的內部構造,也可以按順序訪問其中的每個元素。
JS 也內置了多種遍歷數組的方法如forEach、reduce等。對於數組的循環大家都輕車熟路了,在實際開發中,也可以通過循環來優化代碼。 一個常見的開發場景是:通過 ua 判斷當前頁面的運行平台,方便執行不同的業務邏輯,最基本的寫法當然是if...else。
const PAGE_TYPE = { app: "app", // app wx: "wx", // 微信 tiktok: "tiktok", // 抖音 bili: "bili", // B站 kwai: "kwai", // 快手 }; function getPageType() { const ua = navigator.userAgent; let pageType; // 移動端、桌面端微信瀏覽器 if (/xxx_app/i.test(ua)) { pageType = app; } else if (/MicroMessenger/i.test(ua)) { pageType = wx; } else if (/aweme/i.test(ua)) { pageType = tiktok; } else if (/BiliApp/i.test(ua)) { pageType = bili; } else if (/Kwai/i.test(ua)) { pageType = kwai; } else { // ... } return pageType; }
參考策略模式的思路,我們可以減少分支判斷的出現,將每個平台的判斷拆分成單獨的策略:
function isApp(ua) { return /xxx_app/i.test(ua); } function isWx(ua) { return /MicroMessenger/i.test(ua); } function isTiktok(ua) { return /aweme/i.test(ua); } function isBili(ua) { return /BiliApp/i.test(ua); } function isKwai(ua) { return /Kwai/i.test(ua); } let platformList = [ { name: "app", validator: isApp }, { name: "wx", validator: isWx }, { name: "tiktok", validator: isTiktok }, { name: "bili", validator: isBili }, { name: "kwai", validator: isKwai }, ]; function getPageType() { // 每個平台的名稱與檢測方法 const ua = navigator.userAgent; // 遍歷 for (let { name, validator } in platformList) { if (validator(ua)) { return name; } } }
這樣,整個getPageType方法就變得非常簡潔:按順序遍歷platformList,返回第一個匹配上的平台名稱作為pageType。這樣即使后面需要增加或移除平台判斷,需要修改的僅僅也只是platformList這個地方而已。迭代器模式是一種相對簡單的模式,簡單到很多時候我們都不認為它是一種設計模式。目前的絕大部分語言都內置了迭代器。