前言
最近拜讀了一下修言大神的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的高階組件過後我會在整理出一份詳細完整的博客,因爲可操作性很強,一段兩段也說不清。
由於後半部分作者還在更新中,所以沒有加進去,有興趣的可以關注下,之後就可以愉快的閱讀了。
關注我然後帶走它!