JavaScript 常見設計模式解析

Surmon發表於2017-04-17

設計模式(Design pattern)是一套被反覆使用、多數人知曉的、經過分類編目的、程式碼設計經驗的總結。
使用設計模式是為了可重用程式碼、讓程式碼更容易被他人理解、保證程式碼可靠性。
毫無疑問,設計模式於己於他人於系統都是多贏的;設計模式使程式碼編寫真正工程化;設計模式是軟體工程的基石脈絡,如同大廈的結構一樣。

觀察者模式 Observer Pattern

Observer模式也叫觀察者模式、訂閱/釋出模式,是由GoF提出的23種軟體設計模式的一種。
Observer模式是行為模式之一,它的作用是當一個物件的狀態發生變化時,能夠自動通知其他關聯物件,自動重新整理物件狀態,或者說執行對應物件的方法。
這種設計模式可以大大降低程式模組之間的耦合度,便於更加靈活的擴充套件和維護。

觀察者模式包含兩種角色:

  • 觀察者(訂閱者)
  • 被觀察者(釋出者)

核心思想:觀察者只要訂閱了被觀察者的事件,那麼當被觀察者的狀態改變時,被觀察者會主動去通知觀察者,而無需關心觀察者得到事件後要去做什麼,實際程式中可能是執行訂閱者的回撥函式。

在各種框架中:vue中的$emit,Angular1.x.x中的$on$emit$broadcast,Angular2中的emit...都是最典型的例子。

簡單的例子:
假設你是一個班長,要去通知班裡的某些人一些事情,與其一個一個的手動呼叫觸發的方法(私下裡一個一個通知),不如維護一個列表(建一個群),這個列表存有你想要呼叫的物件方法(想要通知的人);
之後每次通知事件的時候只要迴圈執行這個列表就好了(群發),而不用關心這個列表裡有誰。

Javascript中實現一個例子:

// 我們向某dom文件訂閱了點選事件,當點選發生時,他會執行我們傳入的callback
element.addEventListener(‘click’, callback2, false)
element.addEventListener(‘click’, callback2, false)複製程式碼

我們用Javascript實現一個簡單的播放器:

// 一個播放器類
class Player {

  constructor() {
    // 初始化觀察者列表
    this.watchers = {}

    // 模擬2秒後釋出一個'play'事件
    setTimeout(() => {
      this._publish('play', true)
    }, 2000)

    // 模擬4秒後釋出一個'pause'事件
    setTimeout(() => {
      this._publish('pause', true)
    }, 4000)
  }

  // 釋出事件
  _publish(event, data) {
    if (this.watchers[event] && this.watchers[event].length) {
      this.watchers[event].forEach(callback => callback.bind(this)(data))
    }
  }

  // 訂閱事件
  subscribe(event, callback) {
    this.watchers[event] = this.watchers[event] || []
    this.watchers[event].push(callback)
  }

  // 退訂事件
  unsubscribe(event = null, callback = null) {
    // 如果傳入指定事件函式,則僅退訂此事件函式
    if (callback) {
      if (this.watchers[event] && this.watchers[event].length) {
        this.watchers[event].splice(this.watchers[event].findIndex(cb => Object.is(cb, callback)), 1)
      }

    // 如果僅傳入事件名稱,則退訂此事件對應的所有的事件函式
    } else if (event) {
      this.watchers[event] = []

    // 如果未傳入任何引數,則退訂所有事件
    } else {
      this.watchers = {}
    }
  }
}

// 例項化播放器
const player = new Player()
console.log(player)

// 播放事件回撥函式1
const onPlayerPlay1 = function(data) {
  console.log('1: Player is play, the `this` context is current player', this, data)
}

// 播放事件回撥函式2
const onPlayerPlay2 = data => {
  console.log('2: Player is play', data)
}

// 暫停事件回撥函式
const onPlayerPause = data => {
  console.log('Player is pause', data)
}

// 載入事件回撥函式
const onPlayerLoaded = data => {
  console.log('Player is loaded', data)
}

// 可訂閱多個不同事件
player.subscribe('play', onPlayerPlay1)
player.subscribe('play', onPlayerPlay2)
player.subscribe('pause', onPlayerPause)
player.subscribe('loaded', onPlayerLoaded)

// 可以退訂指定訂閱事件
player.unsubscribe('play', onPlayerPlay2)
// 退訂指定事件名稱下的所有訂閱事件
player.unsubscribe('play')
// 退訂所有訂閱事件
player.unsubscribe()

// 可以在外部手動發出事件(真實生產場景中,釋出特性一般為類內部私有方法)
player._publish('loaded', true)複製程式碼

舉個Vue中的例子吧:

// 事件釋出者使用'vm.$emit、vm.$dispatch(vue1.0)、vm.$broadcast(vue1.0)釋出事件
// 接受方使用$on方法或元件監聽器訂閱事件,傳遞一個回撥函式
vm.$emit(event, […args]) // publish
vm.$on(event, callback) // subscribe
vm.$off([event, callback]) // unsubscribe

// 或者元件中監聽事件
<component @event="callback" />

// 在Vue中無論是$on方法還是元件監聽事件最終都會轉化為例項中的監聽器複製程式碼

各框架中觀察者模式的實現:
Angularjs(AngularJS 1.x.x)中的實現
同樣,Vue中使用Object.defineProperty()實現對資料的雙向繫結,在資料變更時,使用notify廣播事件,最終同樣執行對應屬性所維護的Watchers列表進行回撥。

中介者模式 Mediator Pattern

中介者在程式設計中非常常見,和觀察者模式實現的功能非常相似。

形式上:不像觀察者模式那樣通過呼叫pub/sub的形式來實現,而是通過一箇中介者統一來管理。

實質上:觀察者模式通過維護一堆列表來管理物件間的多對多關係,中介者模式通過統一介面來維護一對多關係,且通訊者之間不需要知道彼此之間的關係,只需要約定好API即可。

簡單說:就像一輛汽車的行駛系統,觀察者模式中,你需要知道車內坐了幾個人(維護觀察者列表),當汽車發生到站、停車、開車...這些事件(被訂閱者事件)時,你需要給這個列表中訂閱對應事件的的每個人進行通知;
在中介者模式中,你只需要在車內發出廣播(到站啦、停車啦、上車啦...請文明乘車尊老愛幼啦...),而不用關心誰在車上,誰要上車誰要下車,他們自己根據廣播做自己要做的事,哪怕他不聽廣播,聽了也不做自己要做的事都無所謂。

中介者模式包含兩種角色:

  • 中介者(事件釋出者)
  • 通訊者

Javascript中實現一個例子:

// 汽車
class Bus {

  constructor() {

    // 初始化所有乘客
    this.passengers = {}
  }

  // 釋出廣播
  broadcast(passenger, message = passenger) {
    // 如果車上有乘客
    if (Object.keys(this.passengers).length) {

      // 如果是針對某個乘客發的,就單獨給他聽
      if (passenger.id && passenger.listen) {

        // 乘客他愛聽不聽
        if (this.passengers[passenger.id]) {
          this.passengers[passenger.id].listen(message)
        }

      // 不然就廣播給所有乘客
      } else {
        Object.keys(this.passengers).forEach(passenger => {
          if (this.passengers[passenger].listen) {
            this.passengers[passenger].listen(message)
          }
        })
      }
    }
  }

  // 乘客上車
  aboard(passenger) {
    this.passengers[passenger.id] = passenger
  }

  // 乘客下車
  debus(passenger) {
    this.passengers[passenger.id] = null
    delete this.passengers[passenger.id]
    console.log(`乘客${passenger.id}下車`)
  }

  // 開車
  start() {
    this.broadcast({ type: 1, content: '前方無障礙,開車!Over'})
  }

  // 停車
  end() {
    this.broadcast({ type: 2, content: '老司機翻車,停車!Over'})
  }
}

// 乘客
class Passenger {

  constructor(id) {
    this.id = id
  }

  // 聽廣播
  listen(message) {
    console.log(`乘客${this.id}收到訊息`, message)
    // 乘客發現停車了,於是自己下車
    if (Object.is(message.type, 2)) {
      this.debus()
    }
  }

  // 下車
  debus() {
    console.log(`我是乘客${this.id},我現在要下車`, bus)
    bus.debus(this)
  }
}

// 建立一輛汽車
const bus = new Bus()

// 建立兩個乘客
const passenger1 = new Passenger(1)
const passenger2 = new Passenger(2)

// 倆乘客分別上車
bus.aboard(passenger1)
bus.aboard(passenger2)

// 2秒後開車
setTimeout(bus.start.bind(bus), 2000)

// 3秒時司機發現2號乘客沒買票,2號乘客被驅逐下車
setTimeout(() => {
  bus.broadcast(passenger2, { type: 3, content: '同志你好,你沒買票,請下車!' })
  bus.debus(passenger2)
}, 3000)

// 4秒後到站停車
setTimeout(bus.end.bind(bus), 3600)

// 6秒後再開車,車上已經沒乘客了
setTimeout(bus.start.bind(bus), 6666)複製程式碼

上面例子中(當然,稍微擴充套件了點哈),Bus即為中介者物件,乘客為通訊者,乘客具有一些統一的方法API,Bus只管開車停車發廣播,執行自己的事物,乘客在不斷地接受廣播,根據廣播資訊的型別和內容作出自己的判斷,執行事務。

代理模式 Proxy Pattern

簡單說就是:為物件提供一種代理以控制對這個物件的訪問。

代理模式使得代理物件控制具體物件的引用。代理幾乎可以是任何物件:檔案,資源,記憶體中的物件,或者是一些難以複製的東西。

舉個例子: 一個工廠製造商品(目標物件),你可以給這個工廠設定一個業務代理(代理物件),提供流水線管理,訂單,運貨,淘寶網店等多種行為能力(擴充套件屬性)。
當然,裡面還有最關鍵的一點就是,這個代理能把一些騙紙和忽悠都過濾掉,將最真實最直接的訂單給工廠,讓工廠能夠專注於生產(控制訪問)。

上面工廠的例子:

// 真實工廠
class Factory {

  constructor(count) {
    // 工廠預設有1000件產品
    this.productions = count || 1000
  }

  // 生產商品
  produce(count) {
    // 原則上低於5個工廠是不接單的
    this.productions += count
  }

  // 向外批發
  wholesale(count) {
    // 原則上低於10個工廠是不批發的
    this.productions -= count
  }
}

// 代理工廠
class ProxyFactory extends Factory {

  // 代理工廠預設第一次合作就從工廠拿100件庫存
  constructor(count = 100) {
    super(count)
  }

  // 代理工廠向真實工廠下訂單之前會做一些過濾
  produce(count) {
    if (count > 5) {
      super.produce(count)
    } else {
      console.log('低於5件不接單')
    }
  }

  wholesale(count) {
    if (count > 10) {
      super.wholesale(count)
    } else {
      console.log('低於10件不批發')
    }
  }

  taobao(count) {
      // ...
  }

  logistics() {
      // ...
  }
}

// 建立一個代理工廠
const proxyFactory = new ProxyFactory()

// 通過代理工廠生產4件商品,被拒絕
proxyFactory.produce(4)

// 通過代理工廠批發20件商品
proxyFactory.wholesale(20)

// 代理工廠的剩餘商品 80
console.log(proxyFactory.productions)複製程式碼

ES6中的Proxy物件:

ES6中Proxy物件可以理解為:在目標物件之前架設一層“攔截”,外界對該物件的訪問,都必須先通過這層攔截,因此提供了一種機制,可以對外界的訪問進行過濾和改寫。Proxy 這個詞的原意是代理,用在這裡表示由它來“代理”某些操作,可以譯為"代理器"。

基本形式:

// 引數分別為目標物件和代理解析器
var proxy = new Proxy(target, handler)複製程式碼

無操作轉發代理:

const target = {}
const p = new Proxy(target, {})
p.a = 3  // 被轉發到代理的操作
console.log(target.a) // 3 操作已經被正確地轉發至目標物件複製程式碼

使用錯誤攔截屬性讀取操作:

const handler = {
    get(target, property) {
        if (property in target) {
            return target[property]
        } else {
            throw new ReferenceError("Property \"" + property + "\" does not exist.")
        }
    }
}

const p = new Proxy({}, handler)
p.a = 1
p.b = undefined

console.log(p.a, p.b) // 1, undefined
console.log('c' in p, p.c) // Uncaught ReferenceError: Property "c" does not exist.複製程式碼

實現一個service客戶端:

function createWebService(baseUrl) {
  return new Proxy({}, {
    get(target, propKey, receiver) {
      return () => httpGet(baseUrl+'/' + propKey)
    }
  })
}

const serviceA = createWebService('http://example.com/data-a')
const serviceB = createWebService('http://example.com/data-b')
const serviceC = createWebService('http://example.com/data-c')

serviceA.employees().then(json => {
  const employees = JSON.parse(json)
  // ···
})

serviceB...複製程式碼

單例模式 Singleton Pattern

簡單說:保證一個類只有一個例項,並提供一個訪問它的全域性訪問點(呼叫一個類,任何時候返回的都是同一個例項)。

實現方法:使用一個變數來標誌當前是否已經為某個類建立過物件,如果建立了,則在下一次獲取該類的例項時,直接返回之前建立的物件,否則就建立一個物件。

類/建構函式例項:

class Singleton {

  constructor(name) {
    this.name = name
    this.instance = null
  }

  getName() {
    alert(this.name)
  }

  static getInstance(name) {
    if (!this.instance) {
      this.instance = new Singleton(name)
    }
    return this.instance
  }
}

const instanceA = Singleton.getInstance('seven1')
const instanceB = Singleton.getInstance('seven2')

console.log(instanceA, instanceB)複製程式碼

閉包包裝例項:

const SingletonP = (function() {
  let instance
  return class Singleton {

    constructor(name) {
      if (instance) {
        return instance
      } else {
        this.init(name)
        instance = this
        return this
      }
    }

    init(name) {
      this.name = name
      console.log('已初始化')
    }
  }
})()

const instanceA = new SingletonP('seven1')
const instanceB = new SingletonP('seven2')

console.log(instanceA, instanceB)複製程式碼

惰性包裝例項:

const getSingle = function (fn) {
    let result
    return function() {
        return result || (result = fn.apply(this, arguments))
    }
}複製程式碼

工廠模式 Factory Pattern

與建立型模式類似,工廠模式建立物件(視為工廠裡的產品)時無需指定建立物件的具體類。
工廠模式定義一個用於建立物件的介面,這個介面由子類決定例項化哪一個類。該模式使一個類的例項化延遲到了子類。而子類可以重寫介面方法以便建立的時候指定自己的物件型別。

簡單說:假如我們想在網頁面裡插入一些元素,而這些元素型別不固定,可能是圖片、連結、文字,根據工廠模式的定義,在工廠模式下,工廠函式只需接受我們要建立的元素的型別,其他的工廠函式幫我們處理。

上程式碼:

// 文字工廠
class Text {
    constructor(text) {
        this.text = text
    }
    insert(where) {
        const txt = document.createTextNode(this.text)
        where.appendChild(txt)
    }
}

// 連結工廠
class Link {
    constructor(url) {
        this.url = url
    }
    insert(where) {
        const link = document.createElement('a')
        link.href = this.url
        link.appendChild(document.createTextNode(this.url))
        where.appendChild(link)
    }
}

// 圖片工廠
class Image {
    constructor(url) {
        this.url = url
    }
    insert(where) {
        const img = document.createElement('img')
        img.src = this.url
        where.appendChild(img)
    }
}

// DOM工廠
class DomFactory {

  constructor(type) {
    return new (this[type]())
  }

  // 各流水線
  link() { return Link }
  text() { return Text }
  image() { return Image }
}

// 建立工廠
const linkFactory = new DomFactory('link')
const textFactory = new DomFactory('text')

linkFactory.url = 'https://surmon.me'
linkFactory.insert(document.body)

textFactory.text = 'HI! I am surmon.'
textFactory.insert(document.body)複製程式碼

裝飾者模式 Decorative Pattern

裝飾者(decorator)模式能夠在不改變物件自身的基礎上,在程式執行期間給對像動態的新增職責(方法或屬性)。與繼承相比,裝飾者是一種更輕便靈活的做法。

簡單說:可以動態的給某個物件新增額外的職責,而不會影響從這個類中派生的其它物件。

例項:假設同事A在window.onload中指定了一些任務,這個函式由同事A維護,如何在對window.onload函式不進行任何修改的基礎上,在window.onload函式執行最後執行自己的任務?

Show me the code:

// 同事A的任務
window.onload = () => {
    console.log('window loaded!')
}

// 裝飾者
let _onload= window.onload || function () {}
window.onload = () => {
    _onload()
    console.log('自己的處理函式')
};複製程式碼

如何在所有函式執行前後分別執行指定函式:

// 新新增的函式在舊函式之前執行
Function.prototype.before = function (beforefn) {
    let _this = this
    return function () {
        beforefn.apply(this, arguments)
        return _this.apply(this, arguments)
    }
}

// 新新增的函式在舊函式之後執行
Function.prototype.after = function(afterfn) {
    let _this = this
    return function () {
        let ret = _this.apply(this, arguments)
        afterfn.apply(this, arguments)
        return ret
    }
}

// 使用
var func = function(param) {
    console.log(param)
}

func = func.before(function(param) {
    param.name = 'beforename'
})

func({ name: 'func' }) // { name: 'beforename' }複製程式碼

不汙染Function原型的做法:

// 裝飾器
const before = function(fn, before) {
    return function() {
        before.apply(this, arguments)
        return fn.apply(this, arguments)
    }
}

// 普通函式
function a() { console.log('a') }
function b() { console.log('b') }

// 使用裝飾器執行函式
const c = before(a, b)

c() // b a複製程式碼

模擬傳統語言的裝飾者:

// 飛機
class Plane {

  constructor(name) {
    this.name = name
  }

  // 發射子彈
  fire() {
    console.log('發射普通子彈')
  }
}

// 武器加強版(裝飾類)
class MissileDecorator {

  constructor(plane) {
    this.plane = plane
    this.plane.name = '高階飛機'
  }

  fire() {
    this.plane.fire()
    console.log('發射導彈')
  }
}

let plane = new Plane('普通飛機')
plane = new MissileDecorator(plane)
plane.fire()

// 發射普通子彈
// 發射導彈複製程式碼

使用ES7中的裝飾器:

首先需要搞清楚ES6中Class語法糖的背後工作原理:

class Cat {
    say() {
        console.log("meow ~")
    }
}

// 實際上當我們給一個類新增一個屬性的時候,會呼叫到 Object.defineProperty 這個方法,它會接受三個引數:target 、name 和 descriptor ,上面的Class本質等同於:
function Cat() {}
Object.defineProperty(Cat.prototype, 'say', {
    value: function() { console.log("meow ~"); },
    enumerable: false,
    configurable: true,
    writable: true
})複製程式碼

ES7裝飾器基本示例:

function isAnimal(target) {
    target.isAnimal = true
    return target
}

// 裝飾器
@isAnimal
class Cat {
    // ...
}
console.log(Cat.isAnimal)    // true

// 上面裝飾器程式碼基本等同於
Cat = isAnimal(function Cat() { ... })複製程式碼

作用於類屬性的裝飾器:

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

class Cat {
    @readonly
    say() {
        console.log("meow ~")
    }
}

var kitty = new Cat()
kitty.say = function() {
    console.log("woof !")
}
kitty.say()    // meow ~複製程式碼

在類的屬性中定義裝飾器的時候,引數有三個:targetnamedescriptor,上面說了,因為裝飾器在作用於屬性的時候,實際上是通過Object.defineProperty來進行擴充套件和封裝的。

所以在上面的這段程式碼中,裝飾器實際的作用形式是這樣的:

let descriptor = {
    value: function() {
        console.log("meow ~")
    },
    enumerable: false,
    configurable: true,
    writable: true
}
descriptor = readonly(Cat.prototype, 'say', descriptor) || descriptor
Object.defineProperty(Cat.prototype, 'say', descriptor)複製程式碼

這裡也是 JS 裡裝飾器作用於類和作用於類的屬性的不同的地方。
當裝飾器作用於類本身的時候,我們操作的物件也是這個類本身,而當裝飾器作用於類的某個具體的屬性的時候,我們操作的物件既不是類本身,也不是類的屬性,而是它的描述符(descriptor),
而描述符裡記錄著我們對這個屬性的全部資訊,所以,我們可以對它自由的進行擴充套件和封裝,最後達到的目的呢,就和之前說過的裝飾器的作用是一樣的。

也可以直接在 target 上進行擴充套件和封裝,比如:

function fast(target, name, descriptor) {
    target.speed = 20
    let run = descriptor.value
    descriptor.value = function() {
        run()
        console.log(`speed ${this.speed}`)
    }
    return descriptor;
}

class Rabbit {
    @fast
    run() {
        console.log("running~")
    }
}

var bunny = new Rabbit()
bunny.run()
// running~
// speed 20
console.log(bunny.speed)   // 20複製程式碼

總結:裝飾器允許你在類和方法定義的時候去註釋或者修改它。裝飾器是一個作用於函式的表示式,它接收三個引數targetnamedescriptor,然後可選性的返回被裝飾之後的descriptor物件。

裝飾者模式和代理模式的區別:

  1. 代理模式的目的是,當直接訪問本體不方便或者不符合需要時,為這個本體提供一個代替者。本體定義了關鍵功能,而代理提供了或者拒絕對他的訪問,或者是在訪問本體之前做一些額外的事情。
  2. 裝飾者模式的作用就是為物件動態的加入某些行為。

內容若有偏差,期待指正修改。

原文地址:surmon.me/article/40

相關文章