從實踐出發,前端怎麼利用設計模式寫出更「優雅」的js代碼

前言

介紹一些我在js編程中經常使用的一些設計模式,本文沒有理論的設計模式的知識,每一個模式都會從實際的例子出發,說明爲何要使用相應的設計模式?怎麼去使用?vue

你們也不要以爲設計模式很難,很高級,之因此以爲「難」,只是由於純理論知識的枯燥難懂,我會從實際例子出發,用很是接地氣的方式,給你們列舉一些咱們平時經常使用,好用的一些設計模式的具體實踐。java

設計模式簡介

簡單介紹一下設計模式,指導理論一共有5個基本原則react

  • 單一功能原則
  • 開放封閉原則
  • 裏式替換原則
  • 接口隔離原則
  • 依賴反轉原則

23個經典的模式 這些內容看過一遍就行,不須要深刻去了解。對於基本原則,在js的編程設計中,瞭解「單一功能「和「開放封閉」基本就夠用。對於模式上,不少的模式其實我也根本沒有使用過,由於設計模式的產生初衷,是爲了補充 Java 這樣的靜態語言的不足。許多"經典"設計模式,在編程語言的演化中,早已成爲語言機制的一部分。好比,export 內建了對單例模式的支持、將內容用 function 包裝一層就是工廠模式、yield 也實現了迭代器模式等等。jquery

爲何要使用設計模式

設計模式的核心思想只有一個,那就是封裝變化。借用修言大佬的話ios

實際開發中,不發生變化的代碼能夠說是不存在的。咱們能作的只有將這個變化形成的影響最小化 —— 將變與不變分離,確保變化的部分靈活、不變的部分穩定。git

這就是平時咱們常說的「健壯」的代碼,而設計模式就是幫助咱們實現這個目的的工具。es6

簡單工廠模式

不說理論,直接上例子。github

在王者榮耀裏,根據每一個人的星級數量,都會有一個排位的等級,如今好比有三個等級,黃金,鑽石,王者。它們三個有一點區別,黃金段位是全英雄匹配,鑽石和王者是BP模式的匹配。王者段位能夠進行巔峯賽,可是其餘兩個不行。如今有個需求,讓你經過段位來返回一個相應的實例,並且須要符合這些區別?面試

這對於咱們來講,也過輕鬆了吧。噼裏啪啦几几分鐘,代碼就寫好了。算法

class 黃金 {
  constructor() {
    this.level = '黃金'
    this.ifBP = false
    this.canJoinPeaked = false
  }
}
class 鑽石 {
  constructor() {
    this.level = '鑽石'
    this.ifBP = true
    this.canJoinPeaked = false
  }
}
class 王者 {
  constructor() {
    this.level = '王者'
    this.ifBP = true
    this.canJoinPeaked = true
  }
}
function Factory(level) {
  switch(level) {
    case '黃金':
      return new 黃金()
      break
    case '鑽石':
      return new 鑽石()
      break
    case '王者':
      return new 王者()
      break
  }
}	
複製代碼

後面王者更新了,若是新增了10個新的段位,你要怎麼改這個代碼?仍是一個個手動添加嗎?

如今讓咱們來改造一下

class 段位通用類 {
  constructor(level, ifBP, canJoinPeaked) {
    this.level = level
    this.ifBP = ifBP
    this.canJoinPeaked = canJoinPeaked
  }
}
function Factory(level) {
	let ifBP, canJoinPeaked
  switch(level) {
    case '黃金':
      ifBP = false
      canJoinPeaked = false
      break
    case '鑽石':
      ifBP = true
      canJoinPeaked = false
      break
    case '王者':
      ifBP = true
      canJoinPeaked = true
      break
  }
  return new 段位通用類(level, ifBP, canJoinPeaked)
}	
複製代碼

這個就是簡單工廠模式的具體應用,將建立對象的過程封裝,咱們不須要去關心具體的內容,只要傳入參數,拿到工廠給咱們的對象便可。

策略模式

王者榮耀裏,咱們若是進行排位賽,會根據你的段位去匹配一塊兒遊戲的玩家,如今有個需求,要求寫一個排位匹配函數,根據玩家當前的段位等級,來執行不一樣段位的排位匹配功能?

這對於習慣了if-else的咱們來講,也是如此簡單。

class 王者帳號 {
  constructor() {}
  排位匹配(level) {
    if (level === '黃金') {
      console.log('執行黃金段位的匹配')
      // 這裏只是舉個例子,平時開發,這裏可能會有很長一段的複雜代碼邏輯
    }
    if (level === '鑽石') {
      console.log('執行鑽石段位的匹配')
    }
    if (level === '王者') {
      console.log('執行王者段位的匹配')
    }
  }
}
王者帳號.排位匹配('黃金')
複製代碼

代碼寫完了,功能實現了,運行起來的確沒問題。可是其實這裏存在多個隱患。

  • 沒有遵循單一功能原則,這裏在一個函數裏處理了多種狀況的邏輯,萬一其中有一個出了bug,後續的邏輯就都沒法運行了。並且功能都放在一塊兒,功能的抽離複用變得很困難。
  • 沒有遵循開放封閉原則(只新建,不修改),若是後續又多了一個段位,只能繼續經過if去判斷,致使每次新增都要對這個排位匹配函數進行測試迴歸,增長工做量。

如今咱們來對其進行改造,首先遵循單一功能原則,把每一項的功能邏輯抽離出來。

function 黃金匹配() {
  console.log('執行黃金段位的匹配')
}
function 鑽石匹配() {
  console.log('執行鑽石段位的匹配')
}
function 王者匹配() {
  console.log('執行王者段位的匹配')
}
class 王者帳號 {
  constructor() {}
  排位匹配(level) {
    if (level === '黃金') {
      黃金匹配()
    }
    if (level === '鑽石') {
      鑽石匹配()
    }
    if (level === '王者') {
      王者匹配()
    }
  }
}
王者帳號.排位匹配('黃金')
複製代碼

接下來,咱們來遵循開放封閉原則(只新建,不修改),封裝變化

const 匹配邏輯 = {
  黃金() {
    console.log('執行黃金段位的匹配')
  },
  鑽石() {
    console.log('執行鑽石段位的匹配')
  },
  王者() {
    console.log('執行王者段位的匹配')
  },
}
class 王者帳號 {
  constructor() {}
  排位匹配(level) {
    匹配邏輯[level]()
  }
}
王者帳號.排位匹配('黃金')
複製代碼

改動以後,後續無論是新增仍是刪除,咱們都不須要去修改排位匹配這個函數,只用對匹配邏輯進行修改就好。

策略模式的核心就是把變化算法提取封裝好,並是讓其可替換。適合表單驗證、或者存在大量 if-else 的場景使用。

狀態模式

狀態模式跟策略模式其實沒啥本質上差異,可是多了一個狀態的概念,咱們仍是剛上一個排位匹配的代碼來示例。

王者裏有個機制,信譽分,信譽分太低系統會禁止玩家排位功能。我這邊稍做修改來當作例子,王者帳號這個類裏有一個信譽分的參數,信譽分達到80分,黃金段位能夠排位,信譽分達到90分鑽石段位才能夠排位,信譽分達到100分,王者段位才能夠排位。如今要求實現這個邏輯?

const 匹配邏輯 = {
  黃金() {
    console.log('執行黃金段位的匹配')
  },
  鑽石() {
    console.log('執行鑽石段位的匹配')
  },
  王者() {
    console.log('執行王者段位的匹配')
  },
}
class 王者帳號 {
  constructor() {
    this.creditPoints = 80
    //80黃金,90鑽石,100王者
  }
  排位匹配(level) {
    匹配邏輯[level]()
  }
}
複製代碼

經過上面的練習,如今你們應該想到,要把這單個的功能在到匹配邏輯裏的各項裏去,這樣後續若是有段位的新增和刪除,或者信譽分邏輯的修改,咱們都不須要去修改排位匹配這個函數,能夠減小測試的工做量。

可是這個信譽分的狀態怎麼拿到呢?如今咱們來使用狀態模式的思想來改造一下。

class 王者帳號 {
  constructor() {
    this.creditPoints = 80
    //80黃金,90鑽石,100王者
  }
  匹配邏輯 = {
    that: this,
    黃金() {
      if (this.that.creditPoints >= 80) {
        console.log('執行黃金段位的匹配')
      }
    },
    鑽石() {
      if (this.that.creditPoints >= 90) {
        console.log('執行鑽石段位的匹配')
      }
    },
    王者() {
      if (this.that.creditPoints >= 100) {
        console.log('執行王者段位的匹配')
      }
    }
  }
  排位匹配(level) {
    匹配邏輯[level]()
  }
}
複製代碼

是否是很簡單?

狀態模式主要解決的是當控制一個對象狀態的條件表達式過於複雜時的狀況。把狀態的判斷邏輯轉移到表示不一樣狀態的一系列類中,能夠把複雜的判斷邏輯簡化。

單例模式

要求實現一個全局惟一的Modal彈框

這是一道很是經典的單例模式的例子,也是比較常見的面試題。直接上答案了。

const SingleModal = (function() {
  let modal
  // 利用閉包實現單例
  return function() {
    if(!modal) {
        modal = document.createElement('div')
        modal.innerHTML = '全局惟一的Modal'
        modal.style.display = 'none'
        document.body.appendChild(modal)
    }
    return modal
  }
})()
// 建立和顯示
const modal = SingleModal()
modal.style.display = 'block'

// 隱藏
const modal = SingleModal()
modal.style.display = 'none'
複製代碼

後續每次調SingleModal()返回的都是第一次運行時建立的那個Modal彈框。也可使用類的方式實現單例。

class SingleModal{
  // 這裏是定義了一個靜態方法,也能夠寫在類的構造函數裏。你們能夠本身試着寫一下
  static createModal() {
      if (!SingleModal.instance) {
        let modal
        modal = document.createElement('div')
        modal.innerHTML = '全局惟一的Modal'
        modal.style.display = 'none'
        document.body.appendChild(modal)
        SingleModal.instance = modal
      }
      return SingleModal.instance
  }
}
const modal1 = SingleModal.createModal()
const modal2 = SingleModal.createModal()

modal1 === modal2 // true
複製代碼

單例模式的目的就是保障無論多少次的調用,返回的都是同一個實例。

vuex就是典型的單例實現,全部子組件訪問到的store其實都是根組件的那個store實例,修改的都是同一個由vuex建立出來的vue實例。

裝飾器模式

王者榮耀裏,基本每一個英雄都有好幾套皮膚,酷炫的皮膚帶來了更佳的遊戲體驗。拿我最喜歡的英雄李白爲了例子,我如今假設出了一個神級皮膚,換上這套皮膚以後,李白會再多出一個技能,這個技能的效果就是「嘲諷」,並且沒有cd,無限的嘲諷攻擊,讓對手失去理智。要求實現這個皮膚的效果?

先來一個李白實例,本來有三個技能。

class 李白 {
  技能1() {
    console.log('將進酒')
  }
  技能2() {
    console.log('神來之筆')
  }
  技能3() {
    console.log('青蓮劍歌')
  }
}
複製代碼

如今要求根據是否使用了這個皮膚來判斷,是否要添加「嘲諷」這個技能。怎麼寫?

很輕鬆嘛,根據皮膚狀態來判斷一下就ok嘛。

class 李白 {
  constructor(skin) {
    if (skin === '神級皮膚') {
      this.嘲諷 = () => {
        console.log('釋放嘲諷')
      }
    }
  }
  技能1() {
    console.log('將進酒')
  }
  技能2() {
    console.log('神來之筆')
  }
  技能3() {
    console.log('青蓮劍歌')
  }
}
複製代碼

首先,這個實現,違背了開放封閉原則,咱們但願可以遵照「只新增,不修改」的原則。其次,「嘲諷」這種做爲普適性很強的行爲極可能會被加到其餘的英雄上面去,好比後續需求變動了,全部的英雄都會出一個神級皮膚,都須要有一個嘲諷技能怎麼辦?全部的英雄一個個去加嗎?

這時候,咱們可使用裝飾器的思想去改造。

class 李白 {
  技能1() {
    console.log('將進酒')
  }
  技能2() {
    console.log('神來之筆')
  }
  技能3() {
    console.log('青蓮劍歌')
  }
}
class 嘲諷技能裝飾器 {
  constructor(hero) {
    this.hero = hero
  }
  技能1() {
    this.hero.技能1()
  }
  技能2() {
    this.hero.技能2()
  }
  技能3() {
    this.hero.技能3()
  }
  嘲諷() {
    console.log('釋放嘲諷')
  }
}
let hero = new 李白()
if (skin === '神級皮膚') {
  hero = new 嘲諷技能裝飾器(hero)
  hero.嘲諷()
}
複製代碼

這樣,咱們沒有對李白這個實例進行任何的修改,只是新增了一個裝飾器,並且這個裝飾品還能夠複用於全部其餘英雄的實例上。

裝飾器的核心思想就是不對原先的功能有任何的影響,只使其具有新的能力。

es7中,js能夠經過@語法糖對類或者類中的函數方法添加裝飾器。這塊內容你們有興趣的話本身去了解一下,篇幅限制,這裏就不細講了。給你們推薦一個優秀的第三方裝飾器庫 core-decorators

裝飾器的應用很普遍,再講一些其餘例子。

// 對於Math.abs來講,add也算一個裝飾器
const add = (a, b, abs) => {
    return abs(x) + abs(y);
}
const num = add(1, -1, Math.abs);
複製代碼
// react裏很常見的高階組件,也是裝飾器的一個應用
const withDoSomthing = (component) => {
  const NewComponent = (props) =>{
    return <component {...props} />
  }
  return NewComponent
}
複製代碼

適配器模式

適配器主要是爲了解決兼容性的問題,幫助咱們抹平差別。

舉個例子,我用的是蘋果手機,充電口是Lightning接口。今天我一不當心,把個人蘋果充電線弄斷了。手機快沒電了,可我這局王者纔開始,這一局是進階賽,贏了就上王者了。可我家裏只有一根安卓的type-c充電線,還有一根usb的充電線。我看着1%的電量感慨到,若是能有一個轉換頭,能把type-c以及usb的接口轉換成蘋果的Lightning接口那該有多好。

這個轉換頭就是適配器。這邊再舉兩個實際的例子給你們參考。

jquery的each遍歷

你們對forEach應該特別熟悉,咱們在遍歷數組的時候常常會用到,好比

let arr = ['a', 'b', 'c']
arr.forEach(item => {
  console.log(item)
})
複製代碼

可是若是咱們換一個對象

const divList = document.getElementsByTagName('div')
for (let i = 0;i < divList.length;i ++) {
  console.log(divList[i])
}
// 正常
document.getElementsByTagName('div').forEach(item => {
  console.log(item)
})
// Uncaught TypeError: document.getElementsByTagName(...).forEach is not a function
複製代碼

咱們會發現,for方法能夠正常打印全部的div標籤。可是forEach方法會報錯,爲何呢?

由於這裏的divList是一個類數組對象,它本質上是一個對象,只是它的key是0,1,2這種格式,並且存在length屬性。既然它不是數組,咱們固然不能用forEach來對她進行遍歷。

但若是咱們使用jqueryeach方法。

const arr = ['a', 'b', 'c']
const divList = document.getElementsByTagName('div')
$.each(arr, function (index, item) {
  console.log(item)
})
// 正常遍歷
$.each(divList, function (index, item) {
  console.log(item)
})
// 正常遍歷
複製代碼

咱們發現對於這兩種類型,均可以進行遍歷,這是由於jqueryeach內部已經幫咱們抹平了差別,我可使用一樣的方法來讀取不一樣類型的列表數據。這就是適配器的典型表現。

axios

axios的不一樣配置方式也是適配器的一種表現形式。

axios({
   url: '/post',
   method: 'post',
   data: {
     msg: 'hello'
   }
 })
 axios('/post', {
   method: 'post',
   data: {
     msg: 'hello'
   }
 })
 axios.request({
   url: '/post',
   method: 'post',
   data: {
     msg: 'hello'
   }
 })
 axios.post('/post', { msg: 'hello' })
複製代碼

上面4種配置方式,均可以實現相同的接口調用,不愧是axios啊。

代理模式

代理模式在平時的開發中也是應用很是普遍,並且代理模式的理念可以帶來很是直接的性能提高,很是實用。

事件代理

利用點擊事件的冒泡機制實現的事件代理,這個太基礎了,不細講了,略過。

緩存代理

把一些計算頻繁的模塊內容存下來,等到下次用到了,直接讀取,再也不二次計算,看幾個具體例子吧。

// 最多見,最簡單的緩存代理
for (let i = 0; i < document.getElementsByTagName('div').length;i ++) {
  console.log(document.getElementsByTagName('div')[i])
}
// 使用緩存代理
const divList = document.getElementsByTagName('div')
for (let i = 0; i < divList.length;i ++) {
  console.log(divList[i])
}

// 對一些須要遍歷對象深層次數據也是同理
const obj = { child: { child: { child: [1,2,3,4,5] } } }
const childList = obj.child.child.child
for (let i = 0; i < childList.length;i ++) {
  console.log(childList[i])
}
複製代碼

進階版的緩存代理,取自修言大佬的JavaScript 設計模式核⼼原理與應⽤實踐小冊

// 計算全部參數之和
const addAll = function() {
    let result = 0
    const len = arguments.length
    for(let i = 0; i < len; i++) {
        result += arguments[i]
    }
    return result
}
// 爲求和方法建立代理
const proxyAddAll = (function(){
    // 求和結果的緩存池
    const resultCache = {}
    return function() {
        // 將入參轉化爲一個惟一的入參字符串
        const args = Array.prototype.join.call(arguments, ',')
        
        // 檢查本次入參是否有對應的計算結果
        if(args in resultCache) {
            // 若是有,則返回緩存池裏現成的結果
            return resultCache[args]
        }
        return resultCache[args] = addAll(...arguments)
    }
})()
複製代碼

緩存代理能夠減小二次計算,提升性能,真是太實用了。

vue在生成子組件的時候,就是使用了緩存代理,在第一次生成子組件以後,後面若是須要再次生成該子組件,vue會從緩存當中返回子組件實例,避免了組件生成邏輯的從新計算。

攔截代理

攔截代理其實在es6以前其實沒啥很特別的表現形式,具體的形式,其實就只是一些判斷而已。

function 吃飯() {
  console.log('吃飯')
}
// 這個方法其實就是攔截代理
function 我要不要吃飯(status) {
  if (status === '我餓了') {
    吃飯()
  }
}
function 午餐時間到了(status) {
  我要不要吃飯()
}
複製代碼

es6以後,咱們有一個新的攔截器的方法,Proxy

這邊舉一個最多見的setget的例子。

let myMessage = { name: "zouwowo", age: 27, sex: '男' }
// 添加Proxy攔截
let message = new Proxy(myMessage, {
  get(target, key) {
    if (key === 'age') {
      // 只要獲取個人age,永遠都是18歲
      return 18
    } else {
      return target[key]
    }
  },
  set(target, key, value) {
    if (key === 'sex') {
      // 我是男的,怎麼改,都改不了性別
      target[key] = '男'
    } else {
      target[key] = value
    }
  }
});
console.log(message.age) // 無論myMessage變量裏的age是多少,永遠返回的是18歲
message.sex = '女' // myMessage裏的sex不會被修改
複製代碼

Proxy有10多種監聽攔截的方法,有興趣的同窗能夠去了解學習一下。vue3的數據監聽也從Object.defineProperty方法改到了Proxy,解決了以前新增的深度數據,部分數組修改方法沒法監聽的問題,足以見其的強大。

觀察者模式

小明昨天玩王者榮譽,被對面有神級皮膚的李白瘋狂嘲諷,一整場下來,被李白殺了10屢次,「一羣菜雞隊友,否則確定吊打這個XX李白。」小明氣不過,加了李白好友,約定組個隊再打一局。小明找了本身好友裏段位最高的四我的,小王、小者、小榮、小耀。五我的一塊兒拉了一個微信羣,小明說:「你們稍等一下子,等要開打了,叫大家」。四我的各自忙本身的事情去了,而後等到晚上9點,小明在羣裏一吼:「兄弟們上號!」。四人收到了消息,各自上號。最終小明依然經歷了一次邊被嘲諷,邊被虐殺的遊戲體驗。

上面這個例子,就是一個典型的觀察者模式。

在上述的過程當中,發佈者只有一個——小明,可是觀察者有多個,小王、小者、小榮和小耀。發佈者發佈事件,全部觀察者都能經過微信羣觀察到發佈者的指令,而後執行各自的任務(各自上號)。

咱們來整理一下發佈者觀察者各自都須要實現什麼功能。

發佈者須要兩個功能

  1. 建立微信羣(添加觀察者)
  2. 通知上號(發佈事件)

觀察者須要兩個功能

  1. 等待羣主發佈通知上號(接受通知)
  2. 各自上號(執行各自的任務)

如今咱們來實現這個最簡單的觀察者模式

class 發佈者 {
  constructor() {
    this.observers = []
  }
  // 添加觀察者
  addObserver(observer) {
    this.observers.push(observer)
  }
  // 發佈事件,通知全部的觀察者
  notify() {
    this.observerList.forEach(observer => observer.update())
  }
}
class 觀察者 {
  constructor(work) {
    this.work = work
  }
  update() {
      console.log(this.work)
  }
}
const 小明 = new 發佈者()
const 小王 = new 觀察者('輔助')
const 小者 = new 觀察者('打野')
const 小榮 = new 觀察者('中單')
const 小耀 = new 觀察者('上單')
// 小明建立微信羣,拉人
小明.addObserver(小王)
小明.addObserver(小者)
小明.addObserver(小榮)
小明.addObserver(小耀)
// 小明通知羣裏的全部人上號,羣裏的人,各自完成本身的任務
小明.notify()
複製代碼

觀察者模式的核心思想就是這種一對多的關係,當發佈者發佈事件,全部的觀察者都會自動完成更新。

vue的響應式依賴實現的核心,就是Dep類,Watch類和Object.defineProperty這三者實現的觀察者模式。

發佈訂閱模式

發佈訂閱模式實現的也是這種事件的發佈和訂閱功能。這個比較好理解,直接看代碼吧。

class EventBus {
  constructor() {
    // 存放全部的事件
    this.events = {}
  }
  // 發佈事件
  subscribe(event, fn) {
    if ( !this.events[event] ) {
        this.events[event] = []
    }
    // 將事件函數放入該事件名的數組裏
    this.events[event].push(fn)
  }
  // 訂閱事件
  publish(event, ...args) {
    if (this.events[event] ) {
      // 調用該事件名下的全部事件
      this.events[event].forEach( fn => fn(...args) )
    }
  }
  // 刪除事件名下某個事件
  unsubscribe(event, fn) {
    if (this.events[event]) {
      const targetIndex = this.events[event].findIndex(item => item === fn) 
      if (targetIndex !== -1) {
        this.events[event].splice(targetIndex, 1)
      }
      // 該事件名下無事件時直接刪除該訂閱事件
      if (this.events[event].length === 0) {
        delete this.events[event]
      }
    }
  }
  // 刪除某個事件名下的全部事件
  unsubscribeAll(event) {
    if (this.events[event]) {
      delete this.events[event]
    }
  }
}
複製代碼

具體使用

const event = new EventBus()
event.subscribe('aaa', ()=> console.log('我訂閱了aaa事件'))
event.subscribe('aaa', ()=> console.log('我又訂閱了aaa事件'))
event.publish('aaa')
// 打印: 我訂閱了aaa事件
// 打印: 我又訂閱了aaa事件
複製代碼

相比較可觀察者模式發佈訂閱模式除了發佈者和訂閱者以外,多了一個事件中心。發佈者和訂閱者之間沒有任何的關聯,二者只能經過事件中心去進行通訊。

vue內部也實現了一個發佈訂閱模式$on,$emit就是對應的發佈者和訂閱者。

寫在最後

各個設計模式並非完成分離的,它們都是相輔相成,能夠互相套用的。好比策略模式,只要適合,能夠用在其它的設計模式裏。

這篇文章不是讓你們強行套用設計模式,咱們須要記住的不是這一個個設計模式的名字,也不是爲了在看一些別人寫的優秀代碼的時候必定要分辨出這是使用了哪一個設計模式。設計模式只是手段,幫助咱們寫出"優雅"代碼的手段,咱們只須要記住這些設計模式的核心思想。君子善假於物,可是不能被「物」所束縛,並且千萬要避免過分設計。

設計是一個按部就班的過程,是從不斷的試錯當中來的,前期再完美的設計並不能知足中後期大量的需求變動,產品的一個需求,可能就能把你以前完美的設計打破,因此不要期望一下把全部細節都設計出來,邊寫邊重構纔是咱們項目開發中的一個好習慣。

參考文章

修言大佬 javaScript設計模式核心原理與應用實踐

感謝

感謝你們的閱讀,若是以爲不錯的話,幫忙點個贊,給咱一點支持,謝謝!

相關文章
相關標籤/搜索