1. 程式人生 > 實用技巧 >Javascript-設計模式_裝飾者模式

Javascript-設計模式_裝飾者模式

前言

在js函式開發中,想要為現有函式新增與現有功能無關的新功能時,按普通思路肯定是在現有函式中新增新功能的程式碼。這並不能說錯,但因為函式中的這兩塊程式碼其實並無關聯,後期維護成本會明顯增大,也會造成函式臃腫

比較好的辦法就是採用裝飾器模式。在保持現有函式及其內部程式碼實現不變的前提下,將新功能函式分離開來,然後將其通過與現有函式包裝起來一起執行

裝飾者模式

描述

裝飾者模式是一種為函式或類增添特性的技術,它可以讓我們在不修改原來物件的基礎上,為其增添新的能力和行為。它本質上也是一個函式(在javascipt中,類也只是函式的語法糖),裝飾者模式將一個物件嵌入另一個物件之中,實際上相當於這個物件被另一個物件包裝起來,形成一條包裝鏈

繼承的缺陷

"封裝,繼承,多型"是面向物件的三大特點,開發過程中,我們都不希望某些類一開始就特別龐大,一次性包含太多職責,這時就不得不去面對三大特性的權衡

在傳統的面嚮物件語言中,給物件新增功能常常使用繼承的方式,但是繼承的方式並不靈活,還會帶來許多問題:

  • 會導致超類和子類之間存在強耦合性,當超類改變時,子類也會隨之改變

  • 繼承這種功能複用方式通常被稱為“白箱複用“,“白箱”是相對可見性而言的,在繼承方式中,超類的內部細節是對子類可見的,繼承常常被認為破壞了封裝性

  • 在完成一些功能複用的同時,有可能創建出大量的子類,使子類的數量呈爆炸性增長。比如現在有4種型號的相機,我們為每種相機都定義了一個單獨的類。現在要給每種相機都裝上鏡頭、濾鏡和掛帶這3種配件。如果使用繼承的方式來給每種相機建立子類,則需要4×3=12個子類

但是如果把鏡頭、濾鏡和掛帶這些物件動態組合到相機上面,則只需要額外增加3個類

這種給物件動態地增加職責的方式稱為裝飾者(decorator)模式。裝飾者模式能夠在不改變物件自身的基礎上,在程式執行期間給物件動態地新增職責。跟繼承相比,裝飾者是一種更輕便靈活的做法,這是一種“即用即付”的方式,比如天冷了就多穿一件外套,需要飛行時就在頭上插一支竹蜻蜓

一系列問題

不改動原函式的情況下,給該函式新增些額外的功能

儲存原引用

window.onload = function() {
    console.log('onload')
};

var _onload = window.onload || function() {}

window.onload = function() {
    _onload()
    console.log('_onload')
}

問題

  • 必須維護中間變數

  • 可能遇到this被劫持問題 在window.onload的例子中沒有這個煩惱,是因為呼叫普通函式_onload時,this也指向window,跟呼叫window.onload時一樣

this被劫持

const _getElementById = document.getElementById
document.getElementById = function(id) {
    console.log('_getElementById')
    return _getElementById(id)
}

return _getElementById(id)  // 報錯“Uncaught TypeError: Illegal invocation”

因為_getElementById是全域性函式,當呼叫全域性函式時,this是指向window的,而document.getElementById中this預期指向document

面向物件的裝飾者

一個打飛機遊戲,飛機一開始火力是普通子彈,升2級後是導彈,3級後是核導彈,用裝飾者的寫法是:

class Plane{
  fire(){
    console.log('傳送普通子彈')
  }
}

class Missibe{
  constructor(plane) {
    this.plane = plane
  }
  fire(){
    this.plane.fire()
    console.log('傳送導彈')
  }
}


class Atom{
  constructor(plane) {
    this.plane = plane
  }
  fire(){
    this.plane.fire()
    console.log('傳送核導彈')
  }
}

const plane = new Plane()
const missibePlane = new Missibe(plane)
const atom = new Atom(missibePlane)

atom.fire()

這種給物件動態增加職責的方式,並沒有真正地改動物件自身,而是將物件放入另一個物件之中,這些物件以一條鏈的方式進行引用,形成一個聚合物件。這些物件都擁有相同的介面(fire方法),當請求達到鏈中的某個物件時,這個物件會執行自身的操作,隨後把請求轉發給鏈中的下一個物件。因為裝飾者物件和它所裝飾的物件擁有一致的介面,所以它們對使用該物件的客戶來說是透明的,被裝飾的物件也並不需要了解它曾經被裝飾過,這種透明性使得我們可以遞迴地巢狀任意多個裝飾者物件

AOP裝飾函式

Aop又叫面向切面程式設計,其中“通知”是切面的具體實現,分為before(前置通知)、after(後置通知)、around(環繞通知),在js中AOP是一個被嚴重忽視的技術點

/**
 * 讓新新增的函式在原函式之前執行(前置裝飾)
 */
Function.prototype.before = function(beforefn) {
    var _self = this
    return function() {
      	// 新函式接收的引數會被原封不動的傳入原函式
        beforefn.apply(this, arguments)
        return _self.apply(this, arguments)
    }
}

/**
 * 讓新新增的函式在原函式之後執行(後置裝飾)
 */
Function.prototype.after = function(afterfn) {
    const _self = this
    return function() {
        const ret = _self.apply(this, arguments)
        afterfn.apply(this, arguments)
        return ret
    }
}

document.getElementById = document.getElementById.before(function() {
    console.log('getElementById')
})

Function.prototype.before接受一個函式當作引數,這個函式即為新新增的函式,它裝載了新新增的功能程式碼。接下來把當前的this儲存起來,這個this指向原函式,然後返回一個“代理”函式,這個“代理”函式只是結構上像代理而已,並不承擔代理的職責(比如控制物件的訪問等)。它的工作是把請求分別轉發給新新增的函式和原函式,且負責保證它們的執行順序,讓新新增的函式在原函式之前執行(前置裝飾),這樣就實現了動態裝飾的效果

通過Function.prototype.apply來動態傳入正確的this,保證了函式在被裝飾之後,this不會被劫持

應用場景

用AOP裝飾函式的技巧在實際開發中非常有用。不論是業務程式碼的編寫,還是在框架層面,我們都可以把行為依照職責分成粒度更細的函式,隨後通過裝飾把它們合併到一起,這有助於我們編寫一個鬆耦合和高複用性的系統

  • 用aop動態改變函式的引數

    假設在使用一串模板方法,在非同步請求前,你給before傳入一段函式

    let fnAjax=function(param){
      console.log(arguments)
      console.log(param)
    }
    
    fnAjax=fnAjax.before(function(param){
      param.username='djtao'
    })
    
    fnAjax({password:123456})
    // Object {password: 123456, username: "djtao"}

    可以用它來為你的ajax請求預處理引數。比如帶個token什麼的

  • 外掛式的表單校驗

    假設點選提交時都要校驗,如果把validate寫在submit處理函式中,這段程式碼沒有任何可複用性,可以改寫一下before

    Function.prototype.before = function(fn) {
      const _this = this
      return function() {
        if(fn.apply(this,arguments) === false) return
        return _this.apply(this, arguments)
      }
    }
    
    const validate = function(params) {
      const {username, password} = params
      if(username === '') {
        alert('使用者名稱不得為空')
        return false
      }
      if(password === '') {
        alert('密碼不得為空')
        return false
      }
    }
    
    login = login.before(validate)

    在這段程式碼中,校驗輸入和提交表單的程式碼完全分離開來,它們不再有任何耦合關係,formSubmit = formSubmit.before(validata)這句程式碼,如同把校驗規則動態接在formSubmit函式之前,validata成為一個即插即用的函式,它甚至可以被寫成配置檔案的形式,這有利於我們分開維護這兩個函式。再利用策略模式稍加改造,我們就可以把這些校驗規則都寫成外掛的形式,用在不同的專案當中

  • 統計資料上報

    現有一個按鈕,功能已經開發完成

    const btnFn = () => {
      //...
    }
    
    document.querySelector('#btn', btnFn)

    這時需要加多一個點選統計功能,如果直接在btnFn繼續寫統計,是不合適的。這是兩個層面的功能,卻被耦合在一個函式裡,這時可以考慮aop分離

  • const notePoint = ()=> {
      // 統計邏輯
    }
    
    btnFn = btnFn.after(notePoint)

和代理模式的區別

裝飾者模式和代理模式的結構看起來非常相像,這兩種模式都描述了怎樣為物件提供一定程度上的間接引用,它們的實現部分都保留了對另外一個物件的引用,並且向那個物件傳送請求

代理模式和裝飾者模式最重要的區別在於它們的意圖和設計目的。代理模式的目的是,當直接訪問本體不方便或者不符合需要時,為這個本體提供一個替代者。本體定義了關鍵功能,而代理提供或拒絕對它的訪問,或者在訪問本體之前做一些額外的事情。裝飾者模式的作用就是為物件動態加入行為

換句話說,代理模式強調一種關係(Proxy與它的實體之間的關係),這種關係在一開始就可以被確定。而裝飾者模式用於一開始不能確定物件的全部功能時,代理模式通常只有一層代理本體的引用,而裝飾者模式經常會形成一條長長的裝飾鏈

在虛擬代理實現圖片預載入的例子中,本體負責設定img節點的src,代理則提供了預載入的功能,這看起來也是“加入行為”的一種方式,但這種加入行為的方式和裝飾者模式的偏重點是不一樣的。裝飾者模式是實實在在的為物件增加新的職責和行為,而代理做的事情還是跟本體一樣,最終都是設定src。但代理可以加入一些“聰明”的功能,比如在圖片真正載入好之前,先使用一張佔位的loading圖片反饋給客戶

小結

使用裝飾者模式可以讓我們為原有的類和函式增添新的功能,並且不會修改原有的程式碼或者改變其呼叫方式,因此不會對原有的系統帶來副作用,也不用擔心原來系統會因為它而失靈或者不相容