1. 程式人生 > >不知道怎麼封裝程式碼?看看這幾種設計模式吧!

不知道怎麼封裝程式碼?看看這幾種設計模式吧!

## 為什麼要封裝程式碼? 我們經常聽說:“寫程式碼要有良好的封裝,要高內聚,低耦合”。那怎樣才算良好的封裝,我們為什麼要封裝呢?其實封裝有這樣幾個好處: > 1. 封裝好的程式碼,內部變數不會汙染外部。 > 2. 可以作為一個模組給外部呼叫。外部呼叫者不需要知道實現的細節,只需要按照約定的規範使用就行了。 > 3. 對擴充套件開放,對修改關閉,即開閉原則。外部不能修改模組,既保證了模組內部的正確性,又可以留出擴充套件介面,使用靈活。 ## 怎麼封裝程式碼? JS生態已經有很多模組了,有些模組封裝得非常好,我們使用起來很方便,比如jQuery,Vue等。如果我們仔細去看這些模組的原始碼,我們會發現他們的封裝都是有規律可循的。這些規律總結起來就是設計模式,用於程式碼封裝的設計模式主要有`工廠模式`,`建立者模式`,`單例模式`,`原型模式`四種。下面我們結合一些框架原始碼來看看這四種設計模式: ## 工廠模式 工廠模式的名字就很直白,封裝的模組就像一個工廠一樣批量的產出需要的物件。常見工廠模式的一個特徵就是呼叫的時候不需要使用`new`,而且傳入的引數比較簡單。但是呼叫次數可能比較頻繁,經常需要產出不同的物件,頻繁呼叫時不用`new`也方便很多。一個工廠模式的程式碼結構如下所示: ```javascript function factory(type) { switch(type) { case 'type1': return new Type1(); case 'type2': return new Type2(); case 'type3': return new Type3(); } } ``` 上述程式碼中,我們傳入了`type`,然後工廠根據不同的`type`來建立不同的物件。 ### 例項: 彈窗元件 下面來看看用工廠模式的例子,假如我們有如下需求: > 我們專案需要一個彈窗,彈窗有幾種:訊息型彈窗,確認型彈窗,取消型彈窗,他們的顏色和內容可能是不一樣的。 針對這幾種彈窗,我們先來分別建一個類: ```javascript function infoPopup(content, color) {} function confirmPopup(content, color) {} function cancelPopup(content, color) {} ``` 如果我們直接使用這幾個類,就是這樣的: ```javascript let infoPopup1 = new infoPopup(content, color); let infoPopup2 = new infoPopup(content, color); let confirmPopup1 = new confirmPopup(content, color); ... ``` 每次用的時候都要去`new`對應的彈窗類,我們用工廠模式改造下,就是這樣: ```javascript // 新加一個方法popup把這幾個類都包裝起來 function popup(type, content, color) { switch(type) { case 'infoPopup': return new infoPopup(content, color); case 'confirmPopup': return new confirmPopup(content, color); case 'cancelPopup': return new cancelPopup(content, color); } } ``` 然後我們使用`popup`就不用`new`了,直接呼叫函式就行: ```javascript let infoPopup1 = popup('infoPopup', content, color); ``` ### 改造成面向物件 上述程式碼雖然實現了工廠模式,但是`switch`始終感覺不是很優雅。我們使用面向物件改造下`popup`,將它改為一個類,將不同型別的彈窗掛載在這個類上成為工廠方法: ```javascript function popup(type, content, color) { // 如果是通過new呼叫的,返回對應型別的彈窗 if(this instanceof popup) { return new this[type](content, color); } else { // 如果不是new呼叫的,使用new呼叫,會走到上面那行程式碼 return new popup(type, content, color); } } // 各種型別的彈窗全部掛載在原型上成為例項方法 popup.prototype.infoPopup = function(content, color) {} popup.prototype.confirmPopup = function(content, color) {} popup.prototype.cancelPopup = function(content, color) {} ``` ### 封裝成模組 這個`popup`不僅僅讓我們呼叫的時候少了一個`new`,他其實還把相關的各種彈窗都封裝在了裡面,這個`popup`可以直接作為模組`export`出去給別人呼叫,也可以掛載在`window`上作為一個模組給別人呼叫。因為`popup`封裝了彈窗的各種細節,即使以後`popup`內部改了,或者新增了彈窗型別,或者彈窗類的名字變了,只要保證對外的介面引數不變,對外面都沒有影響。掛載在`window`上作為模組可以使用自執行函式: ```javascript (function(){ function popup(type, content, color) { if(this instanceof popup) { return new this[type](content, color); } else { return new popup(type, content, color); } } popup.prototype.infoPopup = function(content, color) {} popup.prototype.confirmPopup = function(content, color) {} popup.prototype.cancelPopup = function(content, color) {} window.popup = popup; })() // 外面就直接可以使用popup模組了 let infoPopup1 = popup('infoPopup', content, color); ``` ### jQuery的工廠模式 jQuery也是一個典型的工廠模式,你給他一個引數,他就給你返回符合引數DOM物件。那jQuery這種不用`new`的工廠模式是怎麼實現的呢?其實就是jQuery內部幫你呼叫了`new`而已,jQuery的呼叫流程簡化了就是這樣: ```javascript (function(){ var jQuery = function(selector) { return new jQuery.fn.init(selector); // new一下init, init才是真正的建構函式 } jQuery.fn = jQuery.prototype; // jQuery.fn就是jQuery.prototype的簡寫 jQuery.fn.init = function(selector) { // 這裡面實現真正的建構函式 } // 讓init和jQuery的原型指向同一個物件,便於掛載例項方法 jQuery.fn.init.prototype = jQuery.fn; // 最後將jQuery掛載到window上 window.$ = window.jQuery = jQuery; })(); ``` 上述程式碼結構來自於jQuery原始碼,從中可以看出,你呼叫時省略的`new`在jQuery裡面幫你呼叫了,目的是為了使大量呼叫更方便。但是這種結構需要藉助一個`init`方法,最後還要將`jQuery`和`init`的原型綁在一起,其實還有一種更加簡便的方法可以實現這個需求: ```javascript var jQuery = function(selector) { if(!(this instanceof jQuery)) { return new jQuery(selector); } // 下面進行真正建構函式的執行 } ``` 上述程式碼就簡潔多了,也可以實現不用`new`直接呼叫,這裡利用的特性是`this`在函式被`new`呼叫時,指向的是`new`出來的物件,`new`出來的物件自然是類的`instance`,這裡的`this instanceof jQuery`就是`true`。如果是普通呼叫,他就是`false`,我們就幫他`new`一下。 ## 建造者模式 建造者模式是用於比較複雜的大物件的構建,比如`Vue`,`Vue`內部包含一個功能強大,邏輯複雜的物件,在構建的時候也需要傳很多引數進去。像這種需要建立的情況不多,建立的物件本身又很複雜的時候就適用建造者模式。建造者模式的一般結構如下: ```javascript function Model1() {} // 模組1 function Model2() {} // 模組2 // 最終使用的類 function Final() { this.model1 = new Model1(); this.model2 = new Model2(); } // 使用時 var obj = new Final(); ``` 上述程式碼中我們最終使用的是`Final`,但是`Final`裡面的結構比較複雜,有很多個子模組,`Final`就是將這些子模組組合起來完成功能,這種需要精細化構造的就適用於建造者模式。 ### 例項:編輯器外掛 假設我們有這樣一個需求: > 寫一個編輯器外掛,初始化的時候需要配置大量引數,而且內部的功能很多很複雜,可以改變字型顏色和大小,也可以前進後退。 一般一個頁面就只有一個編輯器,而且裡面的功能可能很複雜,可能需要調整顏色,字型等。也就是說這個外掛內部可能還會呼叫其他類,然後將他們組合起來實現功能,這就適合建造者模式。我們來分析下做這樣一個編輯器需要哪些模組: > 1. 編輯器本身肯定需要一個類,是給外部呼叫的介面 > 2. 需要一個控制引數初始化和頁面渲染的類 > 3. 需要一個控制字型的類 > 4. 需要一個狀態管理的類 ```javascript // 編輯器本身,對外暴露 function Editor() { // 編輯器裡面就是將各個模組組合起來實現功能 this.initer = new HtmlInit(); this.fontController = new FontController(); this.stateController = new StateController(this.fontController); } // 初始化引數,渲染頁面 function HtmlInit() { } HtmlInit.prototype.initStyle = function() {} // 初始化樣式 HtmlInit.prototype.renderDom = function() {} // 渲染DOM // 字型控制器 function FontController() { } FontController.prototype.changeFontColor = function() {} // 改變字型顏色 FontController.prototype.changeFontSize = function() {} // 改變字型大小 // 狀態控制器 function StateController(fontController) { this.states = []; // 一個數組,儲存所有狀態 this.currentState = 0; // 一個指標,指向當前狀態 this.fontController = fontController; // 將字型管理器注入,便於改變狀態的時候改變字型 } StateController.prototype.saveState = function() {} // 儲存狀態 StateController.prototype.backState = function() {} // 後退狀態 StateController.prototype.forwardState = function() {} // 前進狀態 ``` 上面的程式碼其實就將一個編輯器外掛的架子搭起來了,具體實現功能就是往這些方法裡面填入具體的內容就行了,其實就是各個模組的相互呼叫,比如我們要實現後退狀態的功能就可以這樣寫: ```javascript StateController.prototype.backState = function() { var state = this.states[this.currentState - 1]; // 取出上一個狀態 this.fontController.changeFontColor(state.color); // 改回上次顏色 this.fontController.changeFontSize(state.size); // 改回上次大小 } ``` ## 單例模式 單例模式適用於全域性只能有一個例項物件的場景,單例模式的一般結構如下: ```javascript function Singleton() {} Singleton.getInstance = function() { if(this.instance) { return this.instance; } this.instance = new Singleton(); return this.instance; } ``` 上述程式碼中,`Singleton`類掛載了一個靜態方法`getInstance`,如果要獲取例項物件只能通過這個方法拿,這個方法會檢測是不是有現存的例項物件,如果有就返回,沒有就新建一個。 ### 例項:全域性資料儲存物件 假如我們現在有這樣一個需求: > 我們需要對一個全域性的資料物件進行管理,這個物件只能有一個,如果有多個會導致資料不同步。 這個需求要求全域性只有一個數據儲存物件,是典型的適合單例模式的場景,我們可以直接套用上面的程式碼模板,但是上面的程式碼模板獲取`instance`必須要調`getInstance`才行,要是某個使用者直接調了`Singleton()`或者`new Singleton()`就會出問題,這次我們換一種寫法,讓他能夠相容`Singleton()`和`new Singleton()`,使用起來更加傻瓜化: ```javascript function store() { if(store.instance) { return store.instance; } store.instance = this; } ``` 上述程式碼支援使用`new store()`的方式呼叫,我們使用了一個靜態變數`instance`來記錄是否有進行過例項化,如果例項化了就返回這個例項,如果沒有例項化說明是第一次呼叫,那就把`this`賦給這個這個靜態變數,因為是使用`new`呼叫,這時候的`this`指向的就是例項化出來的物件,並且最後會隱式的返回`this`。 如果我們還想支援`store()`直接呼叫,我們可以用前面工廠模式用過的方法,檢測`this`是不是當前類的例項,如果不是就幫他用`new`呼叫就行了: ```javascript function store() { // 加一個instanceof檢測 if(!(this instanceof store)) { return new store(); } // 下面跟前面一樣的 if(store.instance) { return store.instance; } store.instance = this; } ``` 然後我們用兩種方式呼叫來檢測下: ![image-20200521154322364](https://user-gold-cdn.xitu.io/2020/5/22/1723a69dec1897e8?w=254&h=121&f=png&s=8600) ### 例項:vue-router `vue-router`其實也用到了單例模式,因為如果一個頁面有多個路由物件,可能造成狀態的衝突,`vue-router`的單例實現方式又有點不一樣,[下列程式碼來自`vue-router`原始碼](https://github.com/vuejs/vue-router/blob/dev/src/install.js): ```javascript let _Vue; function install(Vue) { if (install.installed && _Vue === Vue) return; install.installed = true _Vue = Vue } ``` 每次我們呼叫`vue.use(vueRouter)`的時候其實都會去執行`vue-router`模組的`install`方法,如果使用者不小心多次呼叫了`vue.use(vueRouter)`就會造成`install`的多次執行,從而產生不對的結果。`vue-router`的`install`在第一次執行時,將`installed`屬性寫成了`true`,並且記錄了當前的`Vue`,這樣後面在同一個`Vue`裡面再次執行`install`就會直接`return`了,這也是一種單例模式。 可以看到我們這裡三種程式碼都是單例模式,他們雖然形式不一樣,但是核心思想都是一樣的,都是用一個變數來標記程式碼是否已經執行過了,如果執行過了就返回上次的執行結果,這樣就保證了多次呼叫也會拿到一樣的結果。 ## 原型模式 原型模式最典型的應用就是JS本身啊,JS的原型鏈就是原型模式。JS中可以使用`Object.create`指定一個物件作為原型來建立物件: ```javascript const obj = { x: 1, func: () => {} } // 以obj為原型建立一個新物件 const newObj = Object.create(obj); console.log(newObj.__proto__ === obj); // true console.log(newObj.x); // 1 ``` 上述程式碼我們將`obj`作為原型,然後用`Object.create`建立的新物件都會擁有這個物件上的屬性和方法,這其實就算是一種原型模式。還有JS的面向物件其實更加是這種模式的體現,比如JS的繼承可以這樣寫: ```javascript function Parent() { this.parentAge = 50; } function Child() {} Child.prototype = new Parent(); Child.prototype.constructor = Child; // 注意重置constructor const obj = new Child(); console.log(obj.parentAge); // 50 ``` 這裡的繼承其實就是讓子類`Child.prototype.__proto__`的指向父類的`prototype`,從而獲取父類的方法和屬性。[JS中面向物件的內容較多,我這裡不展開了,有一篇文章專門講這個問題](https://juejin.im/post/5e50e5b16fb9a07c9a1959af)。 ## 總結 1. 很多用起來順手的開源庫都有良好的封裝,封裝可以將內部環境和外部環境隔離,外部用起來更順手。 2. 針對不同的場景可以有不同的封裝方案。 3. 需要大量產生類似例項的元件可以考慮用工廠模式來封裝。 4. 內部邏輯較複雜,外部使用時需要的例項也不多,可以考慮用建造者模式來封裝。 5. 全域性只能有一個例項的需要用單例模式來封裝。 6. 新老物件之間可能有繼承關係的可以考慮用原型模式來封裝,JS本身就是一個典型的原型模式。 7. 使用設計模式時不要生搬硬套程式碼模板,更重要的是掌握思想,同一個模式在不同的場景可以有不同的實現方案。 **文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。** **作者博文GitHub專案地址: [https://github.com/dennis-jiang/Front-End-Knowledges](https://github.com/dennis-jiang/Front-End-Knowledges)** **作者掘金文章彙總:[https://juejin.im/post/5e3ffc85518825494e2772fd](https://juejin.im/post/5e3ffc85518825494e2772fd)**