設(shè)計(jì)模式在前端項(xiàng)目中的應(yīng)用

來源 | https://juejin.cn/post/6956825832073461768
前端的設(shè)計(jì)模式是什么
前端的設(shè)計(jì)模式的基本準(zhǔn)則
單一職責(zé)原則:每個(gè)類只需要負(fù)責(zé)自己的那部分,類的復(fù)雜度降低。 開閉原則:一個(gè)實(shí)體,如類、模塊和函數(shù)應(yīng)該對(duì)擴(kuò)展開放,對(duì)修改關(guān)閉,讓程序更穩(wěn)定更靈活。 里式替換原則:所有引用基類的地方必須能透明地使用其子類的對(duì)象,也就是說子類對(duì)象可以替換其父類對(duì)象,而程序執(zhí)行效果不變。便于構(gòu)建擴(kuò)展性更好的系統(tǒng)。 依賴倒置原則:上層模塊不應(yīng)該依賴底層模塊,它們都應(yīng)該依賴于抽象;抽象不應(yīng)該依賴于細(xì)節(jié),細(xì)節(jié)應(yīng)該依賴于抽象。這可以讓項(xiàng)目擁有變化的能力。 接口隔離原則:多個(gè)特定的客戶端接口要好于一個(gè)通用性的總接口,系統(tǒng)有更高的靈活性。 迪米特原則(最少知識(shí)原則):一個(gè)類對(duì)于其他類知道的越少越好,也就是說一個(gè)對(duì)象應(yīng)當(dāng)對(duì)其他對(duì)象有盡可能少的了解。
設(shè)計(jì)模式的種類
1、 創(chuàng)建型模式
包括:?jiǎn)卫J?工廠方法模式,抽象工廠模式,建造者模式,原型模式。
2、結(jié)構(gòu)型模式
包括:適配器模式,橋接模式,組合模式,裝飾器模式,外觀模式,享元模式,代理模式,過濾器模式。
3、行為型模式
包括:命令模式,解釋器模式,迭代器模式,中介者模式,備忘錄模式,觀察者模式,狀態(tài)模式,策略模式,模板方法模式,訪問者模式,責(zé)任鏈模式。
前端常用的計(jì)模式應(yīng)用實(shí)例
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
上面這種是比較常見的單例模式實(shí)現(xiàn),但是這種方式存在一些弊端。因?yàn)樗枰屨{(diào)用方了解到通過Message.getInstance來獲取單例。
又或者假設(shè)需求變更,可以通過存在二次彈窗,則需要改動(dòng)不少地方,因?yàn)镸essageBox除了實(shí)現(xiàn)常規(guī)的彈窗邏輯之外,還需要負(fù)責(zé)維護(hù)單例的邏輯。
因此,可以將初始化單例的邏輯單獨(dú)維護(hù),實(shí)現(xiàn)一個(gè)通用的、返回某個(gè)類對(duì)應(yīng)單例的方法。
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返回的始終是同一個(gè)實(shí)例。如果在某些場(chǎng)景下需要生成另外的實(shí)例,則可以重新生成一個(gè)createMessageBox方法,或者直接調(diào)用new MessageBox(),這樣就對(duì)之前的邏輯不會(huì)有任何影響。
2、工廠模式
工廠模式提供了一種創(chuàng)建對(duì)象的方法,對(duì)使用方隱藏了對(duì)象的具體實(shí)現(xiàn)細(xì)節(jié),并使用一個(gè)公共的接口來創(chuàng)建對(duì)象。
前端本地存儲(chǔ)目前最常見的方案就是使用localStorage,為了避免在業(yè)務(wù)代碼中各種getItem和setItem,我們可以做一下最簡(jiǎn)單的封裝。
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接口,我們無需再維護(hù)local_theme。但上面的封裝也存在一些可見的問題,如果需要新增多個(gè) name,那么上面的模板代碼需要重新寫多遍嗎?為了解決這個(gè)問題,我們可以創(chuàng)建Model對(duì)象的邏輯進(jìn)行封裝。
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這個(gè)公共的接口來創(chuàng)建各種不同本地存儲(chǔ)的對(duì)象,而無需關(guān)注創(chuàng)建對(duì)象的具體細(xì)節(jié)。
3、策略模式
策略模式,可以針對(duì)不同的狀態(tài),給出不同的算法或者結(jié)果。將層級(jí)相同的邏輯封裝成可以組合和替換的策略方法,減少if...else代碼,方便擴(kuò)展后續(xù)功能。
表單校驗(yàn)是我們最常見的場(chǎng)景了,我們一般都會(huì)想到用if...else來判斷。
function onFormSubmit(params) {if (!params.name) {return showError("請(qǐng)?zhí)顚戧欠Q");}if (params.name.length > 6) {return showError("昵稱最多6位字符");}if (!/^1\d{10}$/.test(params.phone))return showError("請(qǐng)?zhí)顚懻_的手機(jī)號(hào)");}// ...sendSubmit(params)}
將所有字段的校驗(yàn)規(guī)則都堆疊在一起,代碼量大,排查問題也是一個(gè)大麻煩。在遇見錯(cuò)誤時(shí),直接通過 return 跳過了后面的判斷;如果我們希望直接展示每個(gè)字段的錯(cuò)誤呢,那么改動(dòng)的工作量又不少。
不過,在antd、ELementUI等框架盛行的年代,我們已經(jīng)不再需要寫這些復(fù)雜的表單校驗(yàn),但是對(duì)于他們的實(shí)現(xiàn)原理,我們可以簡(jiǎn)單模擬一下。
// 定義一個(gè)校驗(yàn)的類,主要暴露了構(gòu)造參數(shù)和validate兩個(gè)接口class Schema {constructor(descriptor) {this.descriptor = descriptor; // 傳入定義的校驗(yàn)規(guī)則}// 拆分出一些更通用的規(guī)則,比如required(必填)、len(長(zhǎng)度)、min/max(最值)等,可以盡可能地復(fù)用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();}});}}// 聲明每個(gè)字段的校驗(yàn)邏輯const descriptor = {nickname: [{ key: "required", message: "請(qǐng)?zhí)顚戧欠Q" },{ key: "max", params: 6, message: "昵稱最多6位字符" },],phone: [{ key: "required", message: "請(qǐng)?zhí)顚戨娫捥?hào)碼" },{key: "validator",params(val) {return !/^1\d{10}$/.test(val);},message: "請(qǐng)?zhí)顚懻_的電話號(hào)碼",},],};// 開始對(duì)數(shù)據(jù)進(jìn)行校驗(yàn)const validator = new Schema(descriptor);const params = { nickname: "", phone: "123000" };validator.validate(params).then(() => {console.log("success");}).catch((e) => {console.log(e);});
Schema主要暴露了構(gòu)造參數(shù)和validate兩個(gè)接口,是一個(gè)通用的工具類,而params是表單提交的數(shù)據(jù)源,因此主要的校驗(yàn)邏輯實(shí)際上是在descriptor中聲明的。將常見的校驗(yàn)規(guī)則都放在ruleMap中,比之前各種不可復(fù)用的if..else判斷更容易維護(hù)和迭代。
4、狀態(tài)模式
狀態(tài)模式允許一個(gè)對(duì)象在其內(nèi)部狀態(tài)改變的時(shí)候改變它的行為。狀態(tài)模式的思路是:首先創(chuàng)建一個(gè)狀態(tài)對(duì)象保存狀態(tài)變量,然后封裝好每種動(dòng)作對(duì)應(yīng)的狀態(tài),然后狀態(tài)對(duì)象返回一個(gè)接口對(duì)象,它可以對(duì)內(nèi)部的狀態(tài)修改或者調(diào)用。
常見的使用場(chǎng)景,比如滾動(dòng)加載,包含了初始化加載、加載成功、加載失敗、滾動(dòng)加載等狀態(tài),任意時(shí)間它只會(huì)處于一種狀態(tài)。
// 定義一個(gè)狀態(tài)機(jī)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('滾動(dòng)加載')}// .....}}change(state) {// 更改當(dāng)前狀態(tài)let to = this.states[this._currentState][state]if(to){this._currentState = tothis.go()return true}return false}go() {this.actions[this._currentState]()return this}}// 狀態(tài)更改的操作const rollingLoad = new rollingLoad()rollingLoad.go()rollingLoad.change('complete')rollingLoad.change('loading')
這樣,我們就可以通過狀態(tài)變更,運(yùn)行相應(yīng)的函數(shù),且狀態(tài)之間存在聯(lián)系。那么,看起來是不是和策略模式很像呢?其實(shí)不然,策略類的各個(gè)屬性之間是平等平行的,它們之間沒有任何聯(lián)系。而狀態(tài)機(jī)中的各個(gè)狀態(tài)之間存在相互切換,且是被規(guī)定好了的。
5、發(fā)布-訂閱模式
發(fā)布—訂閱模式又叫觀察者模式,它定義對(duì)象間的一種一對(duì)多的依賴關(guān)系,當(dāng)一個(gè)對(duì)象的狀態(tài)發(fā)生改變時(shí),所有依賴于它的對(duì)象都將得到通知。
發(fā)布訂閱模式大概是前端同學(xué)最熟悉的設(shè)計(jì)模式之一了,常見的事件監(jiān)聽addEventListener,各種屬性方法onload、onchange,vue響應(yīng)式數(shù)據(jù),組件通信redux、eventBus等。
常見的獲取登錄信息,假設(shè)我們開發(fā)一個(gè)商城網(wǎng)站,網(wǎng)站里有 header 頭部、nav 導(dǎo)航、消息列表、購物車等模塊。
這幾個(gè)模塊的渲染有一個(gè)共同的前提條件,就是必須先用 ajax 異步請(qǐng)求獲取用戶的登錄信息。
比如用戶的名字和頭像要顯示在 header 模塊里,而這兩個(gè)字段都來自用戶登錄后返回的信息。異步的問題通常也可以用回調(diào)函數(shù)來解決:
login.succ(function(data){header.setAvatar( data.avatar); // 設(shè)置 header 模塊的頭像nav.setAvatar( data.avatar ); // 設(shè)置導(dǎo)航模塊的頭像message.refresh(); // 刷新消息列表cart.refresh(); // 刷新購物車列表});
我們還必須了解 header 模塊里設(shè)置頭像的方法叫setAvatar、購物車模塊里刷新的方法叫refresh,這種強(qiáng)耦合性會(huì)使程序變得不易拓展。
那么回頭看看我們的發(fā)布—訂閱模式,這種模式下,對(duì)用戶信息感興趣的業(yè)務(wù)模塊可以自行訂閱登錄成功的消息事件。
當(dāng)?shù)卿洺晒r(shí),登錄模塊只需要發(fā)布登錄成功的消息,而業(yè)務(wù)方接受到消息之后,就會(huì)開始進(jìn)行各自的業(yè)務(wù)處理,登錄模塊并不關(guān)心業(yè)務(wù)方究竟要做什么。
// 發(fā)布登錄成功的消息$.ajax( 'http://xxx.com?login', function(data){ // 登錄成功login.trigger( 'loginSucc', data); // 發(fā)布登錄成功的消息});// 各模塊監(jiān)聽登錄成功的消息var header = (function(){ // header 模塊login.listen( 'loginSucc', function(data){header.setAvatar( data.avatar );});return {setAvatar: function( data ){console.log( '設(shè)置 header 模塊的頭像' );}}})();var nav = (function(){ // nav 模塊login.listen( 'loginSucc', function( data ){nav.setAvatar( data.avatar );});return {setAvatar: function( avatar ){console.log( '設(shè)置 nav 模塊的頭像' );}}})();
發(fā)布—訂閱模式可以廣泛應(yīng)用于異步編程中,這是一種替代傳遞回調(diào)函數(shù)的方案。比如,我們可以訂閱ajax請(qǐng)求的error、succ等事件。
或者如果想在動(dòng)畫的每一幀完成之后做一些事情,那我們可以訂閱一個(gè)事件,然后在動(dòng)畫的每一幀完成之后發(fā)布這個(gè)事件。
在異步編程中使用發(fā)布—訂閱模式,我們就無需過多關(guān)注對(duì)象在異步運(yùn)行期間的內(nèi)部狀態(tài),而只需要訂閱感興趣的事件發(fā)生點(diǎn)。
6、迭代器模式
迭代器模式是指提供一種方法順序訪問一個(gè)聚合對(duì)象中的各個(gè)元素,而又不需要暴露該對(duì)象的內(nèi)部表示。
迭代器模式可以把迭代的過程從業(yè)務(wù)邏輯中分離出來,在使用迭代器模式之后,即使不關(guān)心對(duì)象的內(nèi)部構(gòu)造,也可以按順序訪問其中的每個(gè)元素。
JS 也內(nèi)置了多種遍歷數(shù)組的方法如forEach、reduce等。對(duì)于數(shù)組的循環(huán)大家都輕車熟路了,在實(shí)際開發(fā)中,也可以通過循環(huán)來優(yōu)化代碼。
一個(gè)常見的開發(fā)場(chǎng)景是:通過 ua 判斷當(dāng)前頁面的運(yùn)行平臺(tái),方便執(zhí)行不同的業(yè)務(wù)邏輯,最基本的寫法當(dāng)然是if...else。
const PAGE_TYPE = {app: "app", // appwx: "wx", // 微信tiktok: "tiktok", // 抖音bili: "bili", // B站kwai: "kwai", // 快手};function getPageType() {const ua = navigator.userAgent;let pageType;// 移動(dòng)端、桌面端微信瀏覽器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;}
參考策略模式的思路,我們可以減少分支判斷的出現(xiàn),將每個(gè)平臺(tái)的判斷拆分成單獨(dú)的策略:
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() {// 每個(gè)平臺(tái)的名稱與檢測(cè)方法const ua = navigator.userAgent;// 遍歷for (let { name, validator } in platformList) {if (validator(ua)) {return name;}}}
這樣,整個(gè)getPageType方法就變得非常簡(jiǎn)潔:按順序遍歷platformList,返回第一個(gè)匹配上的平臺(tái)名稱作為pageType。
這樣即使后面需要增加或移除平臺(tái)判斷,需要修改的僅僅也只是platformList這個(gè)地方而已。
迭代器模式是一種相對(duì)簡(jiǎn)單的模式,簡(jiǎn)單到很多時(shí)候我們都不認(rèn)為它是一種設(shè)計(jì)模式。目前的絕大部分語言都內(nèi)置了迭代器。
總結(jié)
在將函數(shù)作為一等對(duì)象的語言中,有許多需要利用對(duì)象多態(tài)性的設(shè)計(jì)模式,這些模式的結(jié)構(gòu)與傳統(tǒng)面向?qū)ο笳Z言的結(jié)構(gòu)大相徑庭,實(shí)際上已經(jīng)融入到了語言之中,我們可能經(jīng)常使用它們,只是不知道它們的名字而已。
深入理解他們,并有意識(shí)地去使用設(shè)計(jì)模式來優(yōu)化代碼,提升效率,使我們的系統(tǒng)有更好的拓展性才是我們追求的。
學(xué)習(xí)更多技能
請(qǐng)點(diǎn)擊下方公眾號(hào)
![]()

