前言
最近拜讀了一下修言大神的JavaScript 設計模式核⼼原理與應⽤實踐, 對於現階段的我,可以說受益匪淺,自己也學著總結下,分享下乾貨,力求共同進步!
在軟體工程中,設計模式(design pattern)是對軟體設計中普遍存在(反覆出現)的各種問題,所提出的解決方案。 ——維基百科
先提煉下,文章缺少小冊前兩章,概括來說:
- 技術寒冬,前端真的很缺人,經得起推敲的人太少;
- 前端技術面很大,想把每一面都涵蓋到很難,不建議大家死磕;
- 追求極致設計,講究道術結合;
- 掌握核心科技,以‘不變’應萬變;
- 前端工程師,首先是軟體工程師;
這裡強調一下以不變應萬變的中不變的是什麼,因為這關係到你的核心競爭力是什麼在哪裡。所謂‘不變的東西’說的駕馭技術的能力,具體來說分以下三個層次:
- 能用健壯的程式碼去解決具體問題;
- 能用抽象的思維去應對複雜的系統;
- 能用工程化的思想去規劃更大規模的業務;
這三種能力在你的成長過程中是層層遞進的關係,而後兩種能力可以說是對架構師的要求。能做到第一點,並且把它做到紮實、做到嫻熟的人,已經堪稱同輩楷模
很多人缺乏的並不是這種高瞻遠矚的激情,而是我們前面提到的“不變能力”中最基本的那一點——用健壯的程式碼去解決具體的問題的能力。這個能力在軟體工程領域所對標的經典知識體系,恰恰就是設計模式。所以說,想做靠譜開發,先掌握設計模式。
小冊的知識體系與格局,用思維導圖展示如下:
下面涉及到的是小冊中細講的設計模式;
目錄:
- 工廠模式
- 單例模式
- 原型模式
- 修飾器模式
- 介面卡模式
- 代理模式
- 策略模式
- 狀態模式
- 觀察者模式
- 迭代器模式
工廠模式
定義: 工廠模式其實就是將建立的物件的過程單獨封裝;
簡單工廠模式
結合定義我們來看一段需求,公司需要編寫一個員工資訊錄入系統,當系統裡面只建立自己的時候我們可以:
const lilei = {
name = 'lilei',
age: 18,
career: 'coder'
}
複製程式碼
當然員工肯定不會是一個,並且會不斷加入,所以使用建構函式寫成:
function User(name, age, career) {
this.name = name;
this.age = age;
this.career = career;
}
const lilei = new User('lilei', 18, 'coder')
const lilei = new User('hanmeimei', 20, 'product manager')
// ...
複製程式碼
上面的程式碼其實就是構造器,關於構造器模式後面會有具體介紹,我們採用ES5的建構函式來實現,ES6的class其本質還是函式,class只不過是語法糖,建構函式,才是它的這面目。
需求繼續增加,career欄位能攜帶的資訊有限,無法完整詮釋人員職能,要給每個工種的使用者新增上一個個性欄位,來描述相應的職能。
function Coder(name, age){
this.name = name;
this.age = age;
this.career = 'coder';
this.work = ['敲程式碼', '摸魚', '寫bug'];
}
function ProductManager(name, age) {
this.name = name;
this.age = age;
this.career = 'product manager';
this.work = ['訂會議室', '寫PRD', '催更']
}
function Factory(name, age, career) {
switch(career) {
case 'coder':
return new Coder(name, age);
break;
case 'product manager':
return new ProductManager(name, age);
break;
...
}
}
複製程式碼
現在看至少我們不用操心建構函式的分配問題了,那麼問題來了,大家都看到了省略號了吧,這就意味著每多一個工種就要手動新增一個類上去,假如有幾十個工種,那麼就會有幾十個類?相對來說,我們還是需要不停的宣告新的建構函式。
so:
function User(name, age, career, work) {
this.name = name;
this.age = age;
this.career = career;
this.work = work;
}
function Factory(name, age, career) {
let work;
switch() {
case'coder':
work = ['寫程式碼','摸魚', '寫bug'];
break;
case 'product manager':
work = ['訂會議室', '寫PRD', '催更']
break
case 'boss':
work = ['喝茶', '看報', '見客戶']
case 'xxx':
// 其它工種的職責分配
...
}
return new User(name, age, career)
}
複製程式碼
這樣一來我們需要做事情就簡單多了,只需要無腦傳參就可以了,不需要手寫無數個建構函式,剩下的Factory都幫我們處理了。
工廠模式的目的就是為了實現無腦傳參,就是為了爽。 -修言
乍一看沒什麼問題,但是經不起推敲呀。首先映入眼簾的 Bug,是我們把 Boss 這個角色和普通員工塞進了一個工廠。職能和許可權會有很大區別,因此我們需要對這個群體的物件進行單獨的邏輯處理。
怎麼辦?去修改 Factory的函式體、增加管理層相關的判斷和處理邏輯嗎?單從功能上來講是可行的,但是這樣操作到後期會導致Factory異常龐大,稍有不慎就有可能摧毀整個系統,這一切悲劇的根源只有一個——沒有遵守開放封閉原則;
開放封閉原則:對擴充開放,對修改封閉。說得更準確點,軟體實體(類、模組、函式)可以擴充套件,但是不可修改。
由此我們引出抽象工廠模式;
抽象工廠模式
抽象工廠這塊知識,對入行以來一直寫純 JavaScript 的同學可能不太友好——因為抽象工廠在很長一段時間裡,都被認為是 Java/C++ 這類語言的專利。
定義:抽象工廠模式是指當有多個抽象角色時,使用的一種工廠模式。抽象工廠模式可以向客戶端提供一個介面,使客戶端在不必指定產品的具體的情況下,建立多個產品族中的產品物件。
說白了抽象工廠模式,我認為就是工廠模式的擴充版,簡單工廠生產例項,抽象工廠生產的是工廠,其實是實現子類繼承父類的方法。
這裡比較繞,所以我可恥的把原文的例子搬過來了括弧笑,讓我們來看一下:
假如要做一個山寨手機,基本組成是作業系統(Operating System,我們下面縮寫作 OS)和硬體(HardWare)組成,我們需要開一個手機工廠才能量產,但是我們又不知道具體生產的是什麼手機,只知道有這兩部分組成,所以我先來一個抽象類來約定住這臺手機的基本組成:
class MobilePhoneFactory {
// 提供作業系統的介面
createOS (){
throw new Error('抽象工廠方法不允許直接呼叫,你需要將我重寫!');
}
// 提供硬體的介面
createHardWare(){
throw new Error('抽象工廠方法不允許直接呼叫,你需要將我重寫!');
}
}
複製程式碼
樓上這個類除了約定手機流水線的通用能力之外,啥也不幹,如果你嘗試new一個MobilePhoneFactory
實力並呼叫裡面的方法,它都會給你報錯。在抽象工廠模式裡,樓上這個類就是我們食物鏈頂端最大的Boss——AbstractFactory
(抽象工廠);
抽象工廠不幹活,具體工廠(ConcreteFactory)幹活!當我們明確了生產方案以後就可以化抽象為具體,比如現在需要生產Android系統 + 高通硬體手機的生產線,我們給手機型號起名叫FakeStar,那我就可以定製一個具體工廠:
//具體工廠繼承自抽象工廠
class FakeStarFactory entends MobilePhptoFactory {
cresteOS() {
// 提供安卓系統視力
return new AndroidOS();
}
createHardWare() {
// 提供高通硬體例項
return new QualcommHardeWare()
}
}
複製程式碼
這裡我們在提供按安卓系統的時候,呼叫了兩個建構函式:AndroidOS和QualcommHardWare,它們分別用於生成具體的作業系統和硬體例項。像這種被我們拿來用於 new 出具體物件的類,叫做具體產品類(ConcreteProduct)。具體產品類往往不會孤立存在,不同的具體產品類往往有著共同的功能,比如安卓系統類和蘋果系統類,它們都是作業系統,都有著可以操控手機硬體系統這樣一個最基本的功能。因此我們可以用一個抽象產品(AbstractProduct)類來宣告這一類產品應該具有的基本功能。
// 定義作業系統這類產品的抽象產品類
class OS {
controlHardWare() {
throw new Error('抽象產品方法不允許直接呼叫,你需要將我重寫!');
}
}
// 定義具體作業系統的具體產品類
class AndroidOS extends OS {
controlHardWare() {
console.log('我會用安卓的方式去操作硬體')
}
}
class AppleOS extends OS {
controlHardWare() {
console.log('我會用?的方式去操作硬體')
}
}
...
複製程式碼
硬體產品同理這裡就不重複了。如此一來,當我們需要生產一臺FakeStar手機時,我們只需要:
// 這是我的手機
const myPhone = new FakeStarFactory()
// 讓它擁有作業系統
const myOS = myPhone.createOS()
// 讓它擁有硬體
const myHardWare = myPhone.createHardWare()
// 啟動作業系統(輸出‘我會用安卓的方式去操作硬體’)
myOS.controlHardWare()
// 喚醒硬體(輸出‘我會用高通的方式去運轉’)
myHardWare.operateByOrder()
複製程式碼
當有一天需要產出一款新機投入市場的時候,我們是不是不需要對抽象工廠MobilePhoneFactory做任何修改,只需要擴充它的種類:
class newStarFactory extends MobilePhoneFactory {
createOS() {
// 作業系統實現程式碼
}
createHardWare() {
// 硬體實現程式碼
}
}
複製程式碼
這麼個操作,對原有的系統不會造成任何潛在影響所謂的“對擴充開放,對修改封閉”就這麼圓滿實現了。
總結
抽象工廠模式的四個角色:
- 抽象工廠(不能用於生成具體例項):用於成名最終目標產品的共性。
- 具體工廠:繼承抽象工廠、實現抽象工廠裡宣告的方法,用於建立具體的產品類。
- 抽象產品(不能用於生成具體例項):用於具體產品中共性的抽離。
- 具體產品:比如上面我們提到的具體的硬體等;
單例模式
定義: 保證一個類只有一個例項,並提供一個訪問他的全域性訪問點。
一般情況下我們建立一個類(本質是建構函式)後,可以通過new關鍵字呼叫建構函式進而生成任意多的例項物件:
class SingleDog {
show() {
console.log('我是一隻單身狗');
}
}
const s1 = new SingleDog();
const s2 = new SingleDog();
// false
s1 === s2
複製程式碼
很明顯s1與s2沒有任何瓜葛,因為每次new出來的例項都會給我們開闢一塊新的記憶體空間。那麼我們怎麼才能讓對此new出來都是那唯一的一個例項呢?那就需要我們的建構函式具備判斷自己是否被建立過一個例項的能力。
核心程式碼:
// 定義Storage
class SingleDog {
show() {
console.log('我是一隻單身狗');
}
getInstace() {
// 判斷是否已經new過一個例項
if(!SingleDog.instance){
// 若這個唯一例項不存在,則建立它
SingleDog.instance = new SingleDog();
}
// 如果有則直接返回
return SingleDog.instance;
}
}
const s1 = SingleDog.getInstance()
const s2 = SingleDog.getInstance()
// true
s1 === s2
複製程式碼
生產實踐:redux、vuex中的Store,或者我們經常使用的Storage都是單例模式。
我們來實現一下Storage:
class Storage{
static getInstance() {
if(!Storage.instance) {
Storage.instance = new Storage();
}
return Storage.instance;
}
getItem(key) {
return localStorage.getItem(key);
}
setItem(key, value){
return localStorage.setItem(key, value);
}
}
const storage1 = Storage.getInstance()
const storage2 = Storage.getInstance()
storage1.setItem('name', '李雷')
// 李雷
storage1.getItem('name')
// 也是李雷
storage2.getItem('name')
// 返回true
storage1 === storage2
複製程式碼
思考一下如何實現一個全域性唯一的模態框呢?
原型模式
原型模式不僅是一種設計模式,它還是一種程式設計正規化(programming paradigm),是 JavaScript 物件導向系統實現的根基。
原型模式這一章節小冊並沒有講述什麼稀奇的知識點主要是關於Prototype
相關的需要強調的是javascript是以原型為中心的語言,ES6中的類其實是原型繼承的語法糖。
ECMAScript 2015 中引入的JavaScript類實質上是JavaScript現有的基於原型的繼承的語法糖。類語法不會為 JavaScript 引入新的物件導向的繼承模型。 ——MDN
在原型模式下當我們想要建立一個物件時會先找到一個物件作為原型,然後在通過克隆原型的方式來建立出一個與原型一樣(共享一套資料/方法)的物件。
其實談原型模式就是在談原型正規化,原型程式設計正規化的核心思想就是利用例項來描述物件,用例項作為定義物件和繼承的基礎。在JavaScript中,原型程式設計正規化的體現就是基於原型鏈的繼承。這其中,對原型、原型鏈的理解是關鍵。
這裡應當注意,在一些面試中,面試官可能會可以混淆javascript中的原型正規化和強型別語言中的原型模式,當他們這麼做的時候很有可能是為了考察你對物件深拷貝的理解。
在JavaScript中實現深拷貝,有一種取巧的方式——JSON.stringify:
注意這方法是自己的侷限性的,比如無法處理function、無法處理正則等等,我們在面試中不應該侷限於這種方法,應該擴充出更多的可實施方案,比如遞迴等其他方法,回答遞迴的時候應該注意遞迴函式中值的型別的判斷以及遞迴爆棧的問題。
深拷貝是沒有完美方案的,每一種方案都有他自己的case。
關於深拷貝,有想深入研究的,小冊作者在這裡推薦了個比較好的地址可以關注下:
裝飾器模式
裝飾器模式(DecoratorPattern)允許向一個現有的物件新增新的功能,同時又不改變其結構。這種型別的設計模式屬於結構型模式,它是作為現有的類的一個包裝。
優點:裝飾類和被裝飾類可以獨立發展,不會相互耦合,裝飾模式是繼承的一個替代模式,裝飾模式可以動態擴充套件一個實現類的功能。
缺點:多層裝飾比較複雜。
類裝飾器的引數
當我們給一個類新增裝飾器時:
function classDecorator(target) {
target.hasDecorator = true
return target
}
// 將裝飾器“安裝”到Button類上
@classDecorator
class Button {
// Button類的相關邏輯
}
複製程式碼
此處的 target 就是被裝飾的類本身。看著眼熟不?react中的高階元件(HOC)就是使用這個實現的。
方法裝飾器的引數
而當我們給一個方法新增裝飾器時:
function funcDecorator(target, name, descriptor) {
let originalMethod = descriptor.value
descriptor.value = function() {
console.log('我是Func的裝飾器邏輯');
... // 你需要擴充的操作
return originalMethod.apply(this, arguments);
}
return descriptor
}
class Button {
@funcDecorator
onClick () {
console.log('我是Func的原有邏輯')
}
}
複製程式碼
-
第一個引數target 變成了
Button.prototype
,即類的原型物件。這是因為 onClick 方法總是要依附其例項存在的,修飾onClik其實是修飾它的例項。但我們的裝飾器函式執行的時候,Button 例項還並不存在。為了確保例項生成後可以順利呼叫被裝飾好的方法,裝飾器只能去修飾 Button 類的原型物件 -
第二個參 數name,是我們修飾的目標屬性屬性名。
-
第三個引數descriptor,它的真面目就是“屬性描述物件”(attributes object),它由各種各樣的屬性描述符組成,這些描述符又分為資料描述符和存取描述符,很明顯,拿到了 descriptor,就相當於拿到了目標方法的控制權。:
這裡需要注意:
-
當我們在react中給方法新增裝飾器的時候,方法樣使用上邊寫法,不能使用
()=>{}
箭頭函式的寫法,原因是箭頭函式寫法如果class類沒有例項出來是獲取不到的 -
接著上一條說,使用上述寫法的時候應該在元件的
constructor
中使用bind
修改onClick
方法的this
指向。
高階元件(HOC)的應用
高階元件(HOC)的主要有兩個型別:
- 屬性代理
新元件類繼承子React.component類,對傳入的元件進行一系列操作,從而產生一個新的元件,達到增強元件的作用。
1、 操作props
2、 訪問ref
3、 抽取state
4、 封裝元件
class WrappedComponent extends Component {
render() {
return <input name="name" {...this.props.name} />;
}
}
const HOC = (WrappedComponent) =>
class extends Component {
constructor(props) {
super(props);
this.state = {
name: '',
};
this.onNameChange = this.onNameChange.bind(this);
}
onNameChange(event) {
this.setState({
name: event.target.value,
})
}
render() {
const newProps = {
name: {
value: this.state.name,
onChange: this.onNameChange,
},
}
return <WrappedComponent {...this.props} {...newProps} />;
}
}
複製程式碼
- 反向繼承
新元件類繼承子原元件類,攔截生命週期、渲染劫持和控制state。
export default function ConsoleLog(WrappedComponent, params = []) {
return class extends WrappedComponent {
consoleLog() {
if (params && params.length > 0) {
params.forEach((info) => {
console.log(`${info}==` + JSON.stringify(this.props[info]));
})
} else {
console.log("this.props", JSON.stringify(this.props))
}
}
render() {
this.consoleLog()
return super.render();
}
}
}
複製程式碼
反向繼承不能保證完整的子元件樹被解析。React Components, Elements, and Instances這篇文章主要明確了一下幾個點:
-
元素(element)是一個是用DOM節點或者元件來描述螢幕顯示的純物件,元素可以在屬性(props.children)中包含其他的元素,一旦建立就不會改變。我們通過JSX和React.createClass建立的都是元素。
-
元件(component)可以接受屬性(props)作為輸入,然後返回一個元素樹(element tree)作為輸出。有多種實現方式:Class或者函式(Function)。
所以, 反向繼承不能保證完整的子元件樹被解析的意思的解析的元素樹中包含了元件(函式型別或者Class型別),就不能再操作元件的子元件了,這就是所謂的不能完全解析。
小結(未完待續)
關於react的高階元件過後我會在整理出一份詳細的部落格,因為可操作性很強,一段兩段也說不清。
由於後半部分作者還在更新中,所以沒有加進去,有興趣的可以關注下,之後就可以愉快的閱讀了。
關注我然後帶走它!