讀書整理 - 理解JavaScript設計模式(一)

夏天Summer發表於2019-08-13

最近在翻閱國內兩位大佬撰寫的《JavaScript設計模式》《JavaScript設計模式與開發實踐》, 受益匪淺。 這篇文章, 就將通過這兩本書介紹的加上自己的理解, 整合起來.也算是當成學習的筆記, 如果有錯誤的地方歡迎各位小夥伴指正。

此係列計劃寫三篇,共計 36 個設計模式。

理解

設計模式其實與我們的工作息息相關,可能隨意寫的程式碼封裝就涉及到設計模式,就像鳴人一樣有時候會放出九尾的力量但不會自由使用,希望讀完這篇後可以對常用的一些設計模式有個概念的理解,為了贏取白富美當上村長而努力吧,少年!!

這裡不會贅述基本的概念, 不理解原型, 閉包, 繼承等基礎概念的希望能先把基礎打牢再學習

設計模式的主題是把不變的事物和變化的分離開來, 但切記生搬硬套, 導致程式碼反而更加複雜, 得不償失。

工廠模式

工廠模式:建立一個物件,並且暴露, 裡面新增邏輯用來滿足多種例項操作

舉例: 老闆要招人, 分別是 php, java, ios, 要釋出不一樣的JD, 現在我要用工廠模式寫一個招聘物件, 暴露出來, 可以實現所有職位的操作。

Code:

function JoBFactory(type, content) {
    // 安全模式類, 下面有介紹
    if (this instanceof JoBFactory) {
        return new this[type](content)
    }
    else {
        return new JoBFactory(type, content)
    }
}
// 把需要的類加入到原型裡, 這樣只會暴露一個JoBFactory, 整合了所有分類
JoBFactory.prototype = {
    PHP: function (content) {
        this.content = content
        ...
    },
    Java: function () {
        ...
    }
    ...
}
複製程式碼

解析: 把所有的分類例項加入到一個物件的原型裡,要使用的時候,只需取一個 JoBFactory操作即可

安全模式類:為了防止忽略new 字元, 做了判斷, 呼叫時只需要 JoBFactory() 或者 new JoBFactory() 都可以返回例項。如JoBFactory('PHP', '世界上最好的語言')

工廠模式不僅僅用來new出例項,它的作用就是隱藏了例項的複雜度,只需要提供一個介面,簡單明瞭。這在工作中是非常常見的。

建造者模式

  • 工廠模式:不管你想幹啥,只給你一個你想要的的物件
  • 建造者模式:主要針對複雜業務的解耦,算是工廠的一種拆解拼接。我可以將你的需求分解多個物件建立,更關心的是建立物件的過程。

舉例: 我們公司是賣車的,使用者下單要買車,這個車呢:

品牌:邁巴赫、林肯、賓利、特斯拉[如果不選品牌,預設特斯拉] 
顏色:赤橙黃綠青藍紫...[如果不選顏色,預設黃色]
動力:燃油、電力、混合動力[如果不選動力,預設電力]

購買人的一些基本資訊[包括姓名電話, 而且可以修改]
針對購買人選擇的車型返回對車型的簡單描述[可以修改]
...
複製程式碼

最後會生成一個訂單, 內容包括購買人跟想買車的所有資訊, 如果使用工廠模式,是不是會產生很多個工廠方法,而且不能做到靈活的運用及複用。這時候建造者模式就出來了,我們可以把, 購買人, 反饋結果 拆離, 最後拼接在一起。

Code:

// 建立一個車
var Car = function (params) {
    this.color = params && params.color || 'yellow'; // 顏色
    this.brand = params && params.brand || 'Tesla'; // 品牌
    this.power = params && params.power || 'electric'; // 動力
}
// 提供原型方法
Car.prototype = {
    getColor : function () {
        return this.color;
    },
    getBrand : function () {
        return this.brand;
    },
    getPower : function () {
        return this.power;
    }
}

// 建立購買人
var Client = function (name, phone) {
    this.name = name
    this.phone = phone || '110'
}

Client.prototype.changePhone = function (phone) {
    this.phone = phone;
}
 
// 建立反饋
var FeedBack = function(brand){
    var that = this;
    (function(brand,that){
        switch (brand){
            case 'Tesla':
                // that.brand = brand;
                that.information = '特斯拉是好車'
                break
            case 'Rolls' :
                that.information = '勞斯來時是好車'
        }
    })(brand,that)
}

// 這裡進行拼接
var CarOrder = function (name) {
    var object = new Car();
    object.client  = new Client(name);
    object.feedBack = new FeedBack(object.brand);
    return object;
}

var orderCar = new CarOrder('xiatian');

console.log(orderCar.color) // yellow
console.log(orderCar.client.name) // xiatian

orderCar.client.changePhone('119')
console.log(orderCar.client.phone) // 119

複製程式碼

原型模式

原型模式: 使用原型繼承物件,建立新的物件,實現方法和屬性的共享及擴充套件

舉例: 寫個輪播元件,支援上下滑動, 漸隱滑動切換。 我們可以發現,有很多屬性方法都是可以共用的,包括圖片陣列, 圖片容器,切換方法, 建立方法... 所以這時候我們就需要繼承來實現, 對屬性共享, 並且重寫切換方法

Code:

// 圖片輪播類
var LoopImages = function (imgArr, container) {
    this.imgArr = imgArr;
    this.container = container;
}
// 將耗時操作放入原型中
LoopImages.prototype = {
    // 建立輪播圖片
    createImage: function () {
        console.log('LoopImage create')
    },
    // 切換圖片
    changeImage: function () {
        console.log('LoopImage change')
    }
}

// 上下滑動切換類
var SlideLoopImages = function(imgArr, container) {
    // 使用call的特性,繼承基類的建構函式, 相當於 ES6 中的 super(params)
    // 注意, 這裡無法繼承基類的原型方法
    LoopImages.call(this, imgArr, container)
}

// 類式繼承, 與建構函式一起就叫組合繼承
// ps: 組合繼承的缺點就是呼叫了兩次父類建構函式,並且會有多餘的資料在原型裡 (imgArr, container)
SlideLoopImages.prototype = new LoopImages()

SlideLoopImages.prototype.changeImage = function() {
    console.log('SlideLoopImage change')
}


// 漸隱切換類
var FadeLoopImages = function(imgArr, container) {
    LoopImages.call(this, imgArr, container)
}

FadeLoopImages.prototype = new LoopImages()

FadeLoopImages.prototype.changeImage = function() {
    console.log('FadeLoopImage change')
}

// 擴充原型方法
FadeLoopImages.prototype.getImageLength = function() {
   return this.imgArr.length
}


// 測試
var slide = new SlideLoopImages([1, 2, 3], 'slide')
console.log(slide.container) // slide

var fade = new FadeLoopImages([1, 2, 3], 'fade')
fade.getImageLength() // 3
複製程式碼

單例模式

單例模式: 保證一個類僅有一個例項, 而且可以全域性訪問。

通常的單例模式實現:

const singleton = function(name) {
  this.name = name
  this.instance = null
}

singleton.prototype.getName = function() {
  console.log(this.name)
}

singleton.getInstance = function(name) {
  if (!this.instance) { // 關鍵語句
    this.instance = new singleton(name)
  }
  return this.instance
}

// test
const a = singleton.getInstance('a') // 通過 getInstance 來獲取例項
const b = singleton.getInstance('b')
console.log(a === b)
複製程式碼

應用場景:JQuery中的$、Vuex中的Store、Redux中的Store等

JavaScript 是無類的語言, 而且 JS 中的全域性物件符合單例模式兩個條件。很多時候我們把全域性物件當成單例模式來使用, 所以這樣其實就很簡單

單例模式既可以管理名稱空間也可以實現模組的區分

舉例:我們需要編寫一個程式碼庫A, 其中還分很多的模組, 模組中也有許多方法

Code:

var A = {
    Util: {
        util_method1: function () {}
        util_method1: function () {}
    },
    Tool: {
        tool_method1: function () {}
        tool_method1: function () {}
    }
    ....
}

// 測試
A.Util.util_method1();
A.Tool.tool_method1();
複製程式碼

外觀模式

外觀模式: 大白話就是把很多複雜判斷的東西整合在一起, 暴露一個統一的方法或者函式,最常用於相容問題

舉例: 每個前端都頭疼的IE相容問題,建立DOM事件監聽, IE9以下需要用attachEvent, 不可能每個監聽都寫一遍if判斷吧

Code:

function addEvent(dom, type, fn) {
    if (dom.addEventListener) {
        dom.addEventListener(type, fn, false);
    } else if (dom.attachEvent) {
        dom.attachEvent('on', type, fn)
    } else {
        // 唯獨支援 on + '事件名' 的瀏覽器
        dom['on' + type] = fn
    }
}

// 測試
var input = document.getElementById('myinput');
addEvent(input, 'click', function () {
    console.log('繫結事件')
})
複製程式碼

ps: 《JavaScript設計模式》書中又提到外觀模式可以封裝多個功能,統一暴露出來, 其實我覺得那個例子跟單例模式沒啥區別,就不再贅述。

介面卡模式

介面卡模式: 大白話就是送 給你個轉接頭,能讓你插遍世界上所有的插座。比如Vuecomputed

舉例: 前端工作開發中最常見的就是封裝了ajax請求方法,可是突然來了個新後端,由於比較菜,返回的資料格式發生了變化,難道我們還要在原來封裝的方法裡再修改嘛?那要再來一個新的後端呢?(前端可以讓步,但絕對不能當舔狗!!先懟回去啊!... 然後再改... )

Code:

// 原來的結構 { name: 'me', type: 'boy', title: 'bug' }
// 現在的結構 [ 'me', 'boy', 'bug'  ]

// 介面卡
function ajaxAdapter(data) {
    return {
        name: data[0],
        type: data[1],
        title: data[2]
    }
}

$.ajax({
    url: 'xxx.php',
    success: function (data, success) {
        if (data) {
            doSomething(ajaxAdapter(data))
        }
    }
})
複製程式碼

代理模式

代理模式: 幹啥子中間都要插一箇中間商,賺差價, 也類似明星的經紀人,他跟商家談價錢,一切 ok 後再給個合同給明星就行了

代理模式在日常開發中有很多應用場景, 它有很多小分類, 在JavaScript中最常用的是虛擬代理和快取代理,而且在是否需要代理模式前要先確定是否真的不方便訪問某個物件,不能硬來,不然也只是徒增程式碼複雜度而已

虛擬代理

虛擬代理: 把一些需要開銷很多大的物件,延遲到真正需要它的時候才去建立。 比如: 圖片預載入。

舉例: 圖片預載入,未載入前先用loading佔位, 然後非同步的方式載入圖片,載入完畢後填充到節點裡面

Code:

// 圖片物件
var MyImage = (function (){
   var imgNode = document.createElement('img')
   document.body.appendChild(imgNode)
   
   return {
       setSrc: function (src) {
           imgNode.src = src
       }
   }
})()

// 代理物件,加入 loading 佔位, onload 之後用圖片填充
var proxyImage = (function () {
   var img = new Image;
   img.onload = function () {
       myImage.setSrc(this.src)
   }
    return {
      setSrc: function(src) {
        myImage.setSrc('loading.jpg') // 本地 loading 圖片
        img.src = src
      }
    }
})()
複製程式碼

很多小夥伴會說這樣的為何不整在一起,其實書中也說了物件導向的設計的原則--單一職責原則,如果職責過多,內部發生變化,就會被破壞。比如這個例子, 如果在未來如果不需要預載入, 只要改成請求本體代替請求代理物件就行。

快取代理

快取代理:可以對開銷大的運算結果停供暫時的快取,在下次運算時,如果傳遞進來的引數跟之前一致,則可以直接返回前面儲存的運算結果,比如 React 優化中提到的記憶化技術( memoize-one )

舉例: 計算乘積

Code:

// 求乘積
var mult = function() {
    var a = 1;
    for (var i = 0, l = arguments.length; i < l; i++) {
        a = a * arguments[i]
    }
    return a
}

// 快取代理函式
var proxyMult = (function () {
   // 閉包特性, cache 一直存在
   var cache = {}
   return function () {
       var args = Array.prototype.join.call(arguments, ',')
       
       // 快取裡存在 key 為 args, 返回之前儲存的 value 值 
       if (args in cache) {
           return cache[args];
       }
       // 否則重新計算並且儲存引數和結果
       return cache[args] = mult.apply(this, arguments)
   }
})()
複製程式碼

裝飾者模式

裝飾者模式:在不改變原物件的基礎上,給物件進行包裝, 新增新的功能。用來滿足更加複雜的需求

舉例: 我們使用ES7的裝飾器語法實現物件欄位的只讀功能

Code:

function readonly(target, key, descriptor) {
    descriptor.writable = false
    return descriptor
}

class Test {
    @readonly
    name: 'xiatian'
}

let test = new Test()
t.name = '111' // 不可修改
複製程式碼

橋接模式

橋接模式:將抽象部分與實現部分分離開來,使兩者都可以獨立的變化,並且可以一起和諧地工作。抽象部分和實現部分都可以獨立的變化而不會互相影響,降低了程式碼的耦合性,提高了程式碼的擴充套件性。

其實橋接模式在我們的工作程式碼裡隨處可見,說的通俗點就是拆分程式碼,使得程式碼可以複用和擴充套件。而且拆分的程式碼之間有橋樑可以互相連通

舉例:實現彈窗元件:普通訊息提醒,錯誤提醒。每一種提醒的展示方式還都不一樣。這是一個典型的多維度變化的場景。

Code:

// 一. 建立彈窗類
// 這兩個類就是前面提到的抽象部分,也就是擴充抽象類,它們都包含一個成員animation。
function MessageDialog(animation) {
    this.animation = animation;
}
MessageDialog.prototype.show = function () {
    this.animation.show();
}
function ErrorDialog(animation) {
    this.animation = animation;
}
ErrorDialog.prototype.show = function () {
    this.animation.show();
}

// 二. 建立動畫類
// 兩種彈窗通過show方法進行顯示,但是顯示的動畫效果不同。我們定義兩種顯示的效果類如下:
function LinerAnimation() {
}
LinerAnimation.prototype.show = function () {
    console.log("it is liner");
}
function EaseAnimation() {
}
EaseAnimation.prototype.show = function () {
    console.log("it is ease");
}
<! -- 上面兩個類都是抽象類 -->

// 三. 具體實現
// MessageDialog 與 LinerAnimation 連結在了一起,一起工作,互不影響
var message = new MessageDialog(new LinerAnimation());
message.show();
var error = new ErrorDialog(new EaseAnimation());
error.show();
複製程式碼

命令模式

命令模式:將執行的命令封裝,解決命令發起者與命令執行者之間的耦合,每一條命令實質上是一個操作。命令的是使用者不必瞭解命令執行者的命令介面是如何實現的,只需要知道如何呼叫。

個人理解其實就像是JQuery外掛的用法,只需執行某個命令,傳入特定引數就可以, 無需關心命令執行者是誰,做了什麼操作

舉例: 大專案分工,選單模組,一部分人畫頁面,一部分人做按鈕實現邏輯。使用命令模式拆分, 繫結點選事件時,只需要知道命令介面是什麼,不關心內部實現邏輯

// 智慧命令,不存在接收者
var MenuBar = {
    refresh: function () {
        console.log('重新整理選單介面')
    }
}

var SubMenu = {
    add: function () {
        console.log('新增子選單')
    },
    del: function () {
        console.log('刪除子選單')
    }
}

// 繫結事件
var bindClick = function (button, func) {
    button.onclick = func
}

bindClick(button1, MenuBar.refresh)
bindClick(button2, SubMenu.add)
bindClick(button3, SubMenu.del)
複製程式碼

可能會有人說這不是命令模式,其實對於JavaScript而言,它是將函式作為一等物件的語言,命令模式早就融入到了JavaScript語言裡, 運算塊可以直接封裝到函式裡, 四處傳遞。

傳統的命令模式在JavaScript 中實現很沒有必要,徒增程式碼複雜度, 有興趣的小夥伴可以去翻閱《JavaScript設計模式與開發實踐》

組合模式

組合模式:又稱為"部分-整體"模式,將物件組合成樹形結構以表示“部分整體”的層次結構(樹型結構)。組合模式是的使用者對單個物件和組合物件的使用具有一致性。

命令模式中有一個概念叫做巨集命令, 它的意思就是一組命令之包含許多子命令,內部可以互相組合,互不影響。 而它們都有統一的execute執行介面。其實這跟組合模式有著異曲同工之妙。

舉例:想象我們現在手上有個萬能遙控器, 當我們回家, 按一下開關, 下列事情將被執行:

  1. 開啟空調
  2. 開啟電視和音響
  3. 關門、開啟電腦、開啟QQ

Code:


// 巨集命令, list 儲存所有子命令, 統一的 execute 執行介面
const MacroCommand = function() {
  return {
    lists: [],
    add: function(task) {
      this.lists.push(task)
    },
    excute: function() { // ①: 組合物件呼叫這裡的 excute,
      for (let i = 0; i < this.lists.length; i++) {
        this.lists[i].excute()
      }
    },
  }
}

var openAcCommond = {
    excute: function () {
        console.log('開啟空調')
    }
}

// 電視跟音響是連在一起的,可以用一個巨集命令包裹
var openTvCommond = {
    excute: function () {
        console.log('開啟電視')
    }
}
var openSoundCommond = {
    excute: function () {
        console.log('開啟音響')
    }
}

var maroCommond1 = MacroCommand()
maroCommond1.add(openTvCommond)
maroCommond1.add(openSoundCommond)

//關門、開啟電腦、開啟QQ 也包裹成一個巨集命令、
var closeDoorCommond = {
    excute: function () {
        console.log('關門')
    }
}
var openPcCommond = {
    excute: function () {
        console.log('開啟電腦')
    }
}
var openQQCommond = {
    excute: function () {
        console.log('開啟QQ')
    }
}

var maroCommond2 = MacroCommand()
maroCommond2.add(closeDoorCommond)
maroCommond2.add(openPcCommond)
maroCommond2.add(openQQCommond)

// 把所有的命令整合到一起
var maroCommond = MacroCommand()
maroCommond.add(openAcCommond)
maroCommond.add(maroCommond1)
maroCommond.add(maroCommond2)

// 給遙控器繫結單機事件, 可以操作所有的命令
document.getElementById('button').onclick = function() {
    maroCommond.execute()
}

// test
// 開啟空調
// 開啟電視
// 開啟音響   
// 關門
// 開啟電腦
// 開啟QQ
複製程式碼

個人理解, 組合模式的重點就是對於組合物件的操作可以實現對所有子物件的覆蓋,兩者操作的一致性。在業務工作中, 事件委派其實也是組合模式的一種理念體現。(ps: 這邊有大佬說是應該理解為是代理模式的體現,子元素把事件代理到了父元素上面)

接觸過 antd 的小夥伴對於裡面的 Form 表單 應該很熟悉, 它把 Form 作為組合物件,Form.Item, Input 等建立的其實就是葉子物件。從而形成了一種層次結構,最後也通過組合物件的 form 可以進行全部表單操作。(個人理解, 不對勿噴)

享元模式

享元模式:一種效能優化方案,使用共享技術拆分內外部物件支援大量細顆粒的物件。大白話就是減少物件建立的個數,提取共用的屬性為內部物件,變化的拆離成外部物件

舉例:某商家有 50 種男款內衣和 50 種款女款內衣, 需要模特來展示它們。

方案一:各新建 50 個男女模特,穿上不同的衣服展示。這顯然是有效能問題的 Code:

const Model = function(gender, underwear) {
  this.gender = gender
  this.underwear = underwear
}

Model.prototype.takephoto = function() {
  console.log(`${this.gender}穿著${this.underwear}`)
}

for (let i = 1; i < 51; i++) {
  const maleModel = new Model('male', `第${i}款衣服`)
  maleModel.takephoto()
}

for (let i = 1; i < 51; i++) {
  const female = new Model('female', `第${i}款衣服`)
  female.takephoto()
}
複製程式碼

他們的性別屬性是可以共享的,只是穿著的衣服是改變的,所以我們只需要建立兩個模特,讓他們接連換上50種衣服就可以

方案二:建立 1 個男模特 1 個女模特, 分別試穿 50 款內衣

const Model = function(gender) {
  this.gender = gender
}

Model.prototype.takephoto = function() {
  console.log(`${this.sex}穿著${this.underwear}`)
}

const maleModel = new Model('male')
const femaleModel = new Model('female')

for (let i = 1; i < 51; i++) {
  maleModel.underwear = `第${i}款衣服`
  maleModel.takephoto()
}

for (let i = 1; i < 51; i++) {
  femaleModel.underwear = `第${i}款衣服`
  femaleModel.takephoto()
}
複製程式碼

但是這樣也存在問題

  1. 通過建構函式 new 兩個模特,但是在其他的系統裡不一定一開始就需要建立所有的物件
  2. 給模特手動設定 underwear, 在複雜的系統裡並不合適,往往更加複雜

方案三:

const Model = function(gender) {
  this.gender = gender
}

Model.prototype.takephoto = function() {
  console.log(`${this.gender}穿著${this.underwear}`)
}

// 優化第一點
// 建立工廠物件, 在需要的時候執行 createModel, 如果已存在則不再建立 model
const modelFactory = (function() {
  // 閉包儲存 modelGender
  const modelGender = {}
  return {
    createModel: function(gender) {
      if (modelGender[gender]) {
        return modelGender[gender]
      }
      return modelGender[gender] = new Model(gender)
    }
  }
}())

// add 方法建立 model, 儲存 underwear 操作
// copy 設定 underwear
const modelManager = (function() {
  // modelObj 用來儲存所有外部狀態
  const modelObj  = {}
  return {
    add: function(gender, i) {
      modelObj[i] = {
        underwear: `第${i}款衣服`
      }
      return modelFactory.createModel(gender)
    },
    copy: function(model, i) { // 優化第二點
      model.underwear = modelObj[i].underwear
    }
  }
}())

for (let i = 1; i < 51; i++) {
  const maleModel = modelManager.add('male', i)
  modelManager.copy(maleModel, i)
  maleModel.takephoto()
}

for (let i = 1; i < 51; i++) {
  const femaleModel = modelManager.add('female', i)
  modelManager.copy(femaleModel, i)
  femaleModel.takephoto()
}
複製程式碼

ps: 大部分場景不需要如此,反而增加了開銷

享元模式在工作中也是很常用的,比如在畫表格元件的時候,都會定義很多物件: 資料物件,分頁物件,行列物件,查詢物件。。如果一個頁面有很多表格,又有很多表格頁面的時候就難免對定義很多相似物件,這時候就可以使用分享模式拆分出通用的屬性,比如分頁的每頁行數,當前頁數、頁數切換方法、查詢的條件屬性、重置按鈕方法,相似的都是可以共享的。

階段總結

個人覺得,還是那句話, 學設計模式是為了更好地學習程式設計理念,能夠更好地實現程式碼的拆分,提高程式碼質量, 完全不是為了生搬硬套, 反而增加程式碼複雜度。只要時刻記住設計模式的主題,即使有時候不用任何模式,程式碼也可以寫的很漂亮。死學概念的人終究被概念要求所束縛

相關文章