天天看點

設計模式在前端項目中的應用

設計模式在前端項目中的應用

前端的設計模式是什麼

設計模式一個比較宏觀的概念,通俗來講,它是軟體開發人員在軟體開發過程中面臨的一些具有代表性問題的解決方案。

當然,在實際開發中不用設計模式同樣也是可以實作需求的,隻是在業務邏輯比較複雜的情況下,代碼可讀性及可維護性變差。

是以随着業務邏輯的擴充,了解常用設計模式解決問題是非常有必要的。

前端的設計模式的基本準則
  • 單一職責原則:每個類隻需要負責自己的那部分,類的複雜度降低。
  • 開閉原則:一個實體,如類、子產品和函數應該對擴充開放,對修改關閉,讓程式更穩定更靈活。
  • 裡式替換原則:所有引用基類的地方必須能透明地使用其子類的對象,也就是說子類對象可以替換其父類對象,而程式執行效果不變。便于建構擴充性更好的系統。
  • 依賴倒置原則:上層子產品不應該依賴底層子產品,它們都應該依賴于抽象;抽象不應該依賴于細節,細節應該依賴于抽象。這可以讓項目擁有變化的能力。
  • 接口隔離原則:多個特定的用戶端接口要好于一個通用性的總接口,系統有更高的靈活性。
  • 迪米特原則(最少知識原則):一個類對于其他類知道的越少越好,也就是說一個對象應當對其他對象有盡可能少的了解。
設計模式的種類

1、 建立型模式

一般用于建立對象。

包括:單例模式,工廠方法模式,抽象工廠模式,建造者模式,原型模式。

2、結構型模式

重點為“繼承”關系,有着一層繼承關系,且一般都有“代理”。

包括:擴充卡模式,橋接模式,組合模式,裝飾器模式,外觀模式,享元模式,代理模式,過濾器模式。

3、行為型模式

職責的劃分,各自為政,減少外部的幹擾。

包括:指令模式,解釋器模式,疊代器模式,中介者模式,備忘錄模式,觀察者模式,狀态模式,政策模式,模闆方法模式,通路者模式,責任鍊模式。

前端常用的計模式應用執行個體

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這個地方而已。

疊代器模式是一種相對簡單的模式,簡單到很多時候我們都不認為它是一種設計模式。目前的絕大部分語言都内置了疊代器。

總結

繼續閱讀