簡單易懂的設計模式(上)

一、單例模式

1. 什麼是單例模式

單例模式的定義是,保證一個類僅有一個實例,並提供一個訪問它的全局訪問點。

有一些對象,比如線程池/全局緩存/瀏覽器中的 window 對象等等,我們就只需要一個實例。

下面將根據實際場景進行介紹。

2. 實際場景

1. 登錄浮窗

當我們單擊登錄按鈕時,頁面中會出現一個登錄的浮窗,而這個登錄浮窗是唯一的,無論單擊多少次登錄按鈕,這個浮窗都只會被創建一次,那麼這個登錄浮窗就適合用單例模式來創建。

1.1 傳統做法

傳統做法在頁面加載完成時,就創建好登錄浮窗,當用戶點擊登錄按鈕時,顯示登錄浮窗,實現代碼如下:

<button id="loginBtn">登錄</button>
var loginLayer = (() => {
	let div = document.createElement('div')
	div.innerHTML = '我是登錄彈窗'
	div.style.display = 'none'

	document.body.appendChild(div)

	return div
})()

document.getElementById('loginBtn').onclick = () => {
	loginLayer.style.display = 'block'
}

上述代碼有以下缺點:

  1. 在無需登錄的情況下,也會新增登錄浮窗的 DOM 節點,浪費性能。

現在優化一下,將代碼改爲,在用戶點擊登錄按鈕後,才新增登錄浮窗的 DOM 節點。

代碼如下:

var createLoginLayer = () => {
	let div = document.createElement('div')
	div.innerHTML = '我是登錄彈窗'
	div.style.display = 'none'

	document.body.appendChild(div)

	return div
}

document.getElementById('loginBtn').onclick = () => {
	const loginLayer = createLoginLayer()
	loginLayer.style.display = 'block'
}

上述代碼也存在缺陷,具體如下:

  1. 每次點擊登錄按鈕,都會創建一個登錄浮窗,頻繁的創建 DOM 節點更加浪費性能。

實際上,我們只需要創建一次登錄浮窗。

1.2 單例模式

通過單例模式,重構上述代碼。

const createLoginLayer = () => {
	const div = document.createElement('div')
	div.innerHTML = '我是登錄彈窗'
	div.style.display = 'none'
	console.log(123)

	document.body.appendChild(div)
	return div
}

const createSingle = (function () {
	var instance = {}
	return function (fn) {
		if (!instance[fn.name]) {
			instance[fn.name] = fn.apply(this, arguments)
		}
		return instance[fn.name]
	}
})()

const createIframe = function () {
	const iframe = document.createElement('iframe')
	document.body.appendChild(iframe)
	iframe.style.display = 'none'
	return iframe
}

const createSingleLoginLayer = createSingle(createLoginLayer)
const createSingleIframe = createSingle(createIframe)

document.getElementById('loginBtn').onclick = () => {
	const loginLayer = createSingleLoginLayer
	const iframe = createSingleIframe
	loginLayer.style.display = 'block'
	iframe.style.display = 'block'
}

經過重構,代碼做了以下優化:

  1. 將創建實例對象 createLoginLayer / createIframe 的職責和管理單例對象 createSingle 的職責分離,符合單一職責原則;
  2. 通過閉包存儲實例,並進行判斷,不管點擊登錄按鈕多少次,只創建一個登錄浮窗實例
  3. 易於擴展,當下次需要創建頁面中唯一的 iframe / script 等其他標籤時,可以直接複用該邏輯。

3. 總結

單例模式是一種簡單但非常實用的模式,特別是惰性單例技術,在合適的時候才創建對象,並且只創建唯一的一個。更奇妙的是,創建對象和管理單例的職責被分佈在兩個不同的方法中,這兩個方法組合起來才具有單例模式的威力。

二、策略模式

1. 什麼是策略模式

當我們計劃國慶出去遊玩時,在交通方式上,我們可以選擇貴而快的飛機、價格中等但稍慢的動車、便宜但超級慢的火車,根據不同的人,選擇對應的交通方式,且可以隨意更換交通方式,這就是策略模式

策略模式的定義是,定義一系列算法,把它們一個個封裝起來,並且使它們可以相互替換。

2. 實際場景

1. 計算年終獎

1.1 傳統做法

有一個計算員工年終獎的需求,假設,績效爲 S 的員工年終獎是 4 倍工資,績效爲 A 的員工年終獎是 3 倍工資,績效爲 B 的員工年終獎是 2 倍工資,下面我們來計算員工的年終獎。

var calculateBonus = function(performanceLevel, salary) {
	if (performanceLevel === 'S') {
		return salary * 4;
	}
	if (performanceLevel === 'A') {
		return salary * 3;
	}
	if (performanceLevel === 'B') {
		return salary * 2;
	}
};

calculateBonus('B', 20000); // 輸出:40000 
calculateBonus( 'S', 6000 ); // 輸出:24000

上述代碼有以下缺點:

  1. 使用 if-else 語句描述邏輯,代碼龐大;
  2. 缺乏彈性,如果需要修改績效 S 的獎金係數,必須修改 calculateBonus 函數,違反了開放-封閉原則;
  3. 無法再次複用,當其他地方需要用到這套邏輯,只能再複製一份。

1.2 策略模式做法

使用策略模式改良後

const strategies = {
	S: salary => {
		return salary * 4
	},
	A: salary => {
		return salary * 3
	},
	B: salary => {
		return salary * 2
	}
}

const calculateBonus = (level, salary) => {
	return strtegies[level](salary)
}

console.log(calculateBonus('s', 20000))
console.log(calculateBonus('a', 10000))

可以看到上述代碼做了以下改動:

  1. 策略類 strategies 封裝了具體的算法和計算過程(每種績效的計算規則);
  2. 環境類 calculateBonus 接受請求,把請求委託給策略類 strategies(員工的績效和工資;
  3. 將算法的使用和算法的實現分離,代碼清晰,職責分明;
  4. 消除大量的 if-else 語句。

1.3 小結

策略模式使代碼可讀性更高,易於拓展更多的策略算法。當績效係數改變,或者績效等級增加,我們只需要爲 strategies 調整或新增算法,符合開放-封閉原則。

2. 表單校驗

當網頁上的表單需要校驗輸入框/複選框等等規則時,如何去實現呢?

現在有一個註冊用戶的表單需求,在提交表單之前,需要驗證以下規則:

  1. 用戶名不能爲空
  2. 密碼長度不能少於 6 位
  3. 手機號碼必須符合格式

2.1 傳統做法

使用 if-else 語句判斷表單輸入是否符合對應規則,如不符合,提示錯誤原因。

<!DOCTYPE html>
<html>
<head>
	<title></title>
</head>
<body>
	<form id='registerForm' action="xxx" method="post">
		用戶名:<input type="text" name="userName">
		密碼:<input type="text" name="password">
		手機號:<input type="text" name="phone">
		<button>提交</button>
	</form>
	<script type="text/javascript">
        let registerForm = document.getElementById('registerForm')

        registerForm.onsubmit = () => {
                if (registerForm.userName.value) {
                        alert('用戶名不能爲空')
                        return false
                }

                if (registerForm.password.value.length < 6) {
                        alert('密碼長度不能少於6')
                        return false
                }

                if (!/(^1[3|5|8][0-9]$)/.test(registerForm.phone.value)) {
                        alert('手機號碼格式不正確')
                        return false
                }
        }
        </script>
</body>
</html>

image.png

上述代碼有以下缺點:

  • onsubmit 函數龐大,包含大量 if-else 語句;
  • onsubmit 缺乏彈性,當有規則需要調整,或者需要新增規則時,需要改動 onsubmit 函數內部,違反開放-封閉原則;
  • 算法複用性差,只能通過複製,複用到其他表單。

2.2 策略模式做法

使用策略模式重構上述代碼。

<!DOCTYPE html>
<html>
<head>
	<title></title>
</head>
<body>
	
	<form action="http://xxx.com/register" id="registerForm" method="post">
		 請輸入用戶名:
		<input type="text" name="userName" />
		 請輸入密碼:
		<input type="text" name="password" />
		 請輸入手機號碼:
		<input type="text" name="phoneNumber" />
		<button>
			提交
		</button>
	</form>
	<script type="text/javascript" src="index.js">
		
	</script>            
</body>  
</html>
// 表單dom
const registerForm = document.getElementById('registerForm')

// 表單規則
const rules = {
    userName: [
        {
            strategy: 'isNonEmpty',
            errorMsg: '用戶名不能爲空'
        },
        {
            strategy: 'minLength:10',
            errorMsg: '用戶名長度不能小於10位'
        }	
    ],
    password: [
        {
            strategy: 'minLength:6',
            errorMsg: '密碼長度不能小於6位'
        }
    ],
    phoneNumber: [
        {
            strategy: 'isMobile',
            errorMsg: '手機號碼格式不正確'
        }
    ]
}

// 策略類
var strategies = {
    isNonEmpty: function(value, errorMsg) {
        if (value === '') {
            return errorMsg;
        }
    },
     minLength: function(value, errorMsg, length) {
        console.log(length)
        if (value.length < length) {
            return errorMsg;
        }
    },
     isMobile: function(value, errorMsg) {
        if (!/(^1[3|5|8][0-9]{9}$)/.test(value)) {
            return errorMsg;
        }
    }
};

// 驗證類
const Validator = function () {
    this.cache = []
}

// 添加驗證方法
Validator.prototype.add = function ({ dom, rules}) {
    rules.forEach(rule => {
        const { strategy, errorMsg } = rule
        console.log(rule)
        const [ strategyName, strategyCondition ] = strategy.split(':')
        console.log(strategyName)
        const { value } = dom
        this.cache.push(strategies[strategyName].bind(dom, value, errorMsg, strategyCondition))
    })
}

// 開始驗證
Validator.prototype.start = function () {
    let errorMsg
    this.cache.some(cacheItem => {
            const _errorMsg = cacheItem()
            if (_errorMsg) {
                    errorMsg = _errorMsg
                    return true
            } else {
                    return false
            }
    })

    return errorMsg
}

// 驗證函數
const validatorFn = () => {
    const validator = new Validator()
    console.log(validator.add)

    Object.keys(rules).forEach(key => {
        console.log(2222222, rules[key])
        validator.add({
            dom: registerForm[key],
            rules: rules[key]
        })
    })

    const errorMsg = validator.start()
    return errorMsg
}


// 表單提交
registerForm.onsubmit = () => {
    const errorMsg = validatorFn()
    if (errorMsg) {
        alert(errorMsg)
        return false
    }
    return false
}

上述代碼通過 strategies 定義規則算法,通過 Validator 定義驗證算法,將規則和算法分離,我們僅僅通過配置的方式就可以完成表單的校驗,這些校驗規則也可以複用在程序的任何地方,還能作爲插件的形式,方便的被移植到其他項目中。

3. 總結

策略模式是一種常用且有效的設計模式,通過上述例子,可以總結出策略模式的一些優點:

  • 策略模式利用組合/委託和多態等技術和思想,可以有效的避免多重條件選擇語句;
  • 策略模式提供了對開放-封閉原則的完美支持,將算法封裝中獨立的策略類中,使得它們易於切換/理解/擴展;
  • 在策略模式中利用組合和委託來讓 Context 擁有執行算法的能力,這也是繼承的一種更輕便的代替方案。

三、代理模式

1. 什麼是代理模式

代理模式是爲一個對象提供一個代用品或佔位符,以便控制對它的訪問。

代理模式的關鍵是,當客戶不方便直接訪問一個對象或者不滿足需要的時候,提供一個替身對象來控制對這個對象的訪問,客戶實際上訪問的是替身對象。

2. 模擬場景

1. 小明送花給小白

1.1 傳統做法

傳統做法是小明直接把花送給小白,小白接收到花,代碼如下:

const Flower = function () {
	return '玫瑰🌹'
}

const xiaoming = {
	sendFlower: target => {
		const flower = new Flower()
		target.receiveFlower(flower)
	}
}

const xiaobai = {
	receiveFlower: flower => {
		console.log('收到花', flower)
	}
}

xiaoming.sendFlower(xiaobai)

1.2 代理模式

但是,小明並不認識小白,他想要通過小代,幫他打探小白的情況,在小白心情好的時候送花,這樣成功率更高。代碼如下:

const Flower = function () {
	return '玫瑰🌹'
}

const xiaoming = {
	sendFlower: target => {
		const flower = new Flower()
		target.receiveFlower(flower)
	}
}

const xiaodai = {
	receiveFlower: flower => {
		xiaobai.listenGoodMood().then(() => {
			xiaobai.receiveFlower(flower)
		})
	}
}

const xiaobai = {
	receiveFlower: flower => {
		console.log('收到花', flower)
	},
	listenGoodMood: fn => {
		return new Promise((reslove, reject) => {
			// 10秒後,心情變好
			reslove()
		})
	}
}

xiaoming.sendFlower(xiaodai)

以上,小明通過小代,監聽到小白心情的心情變化,選擇在小白心情好時送花給小白。不僅如此,小代還可以做以下事情:

  1. 幫助小白過濾掉一些送花的請求,這就叫做保護代理;
  2. 幫助小明,在小白心情好時,再執行買花操作,這就叫做虛擬代理。虛擬代理把一些開銷很大的對象,延遲到真正需要它的時候纔去創建。

3. 實際場景

1. 圖片預加載

圖片預加載時一種常見的技術,如果直接給 img 標籤節點設置 src 屬性,由於圖片過大或網絡不佳,圖片的位置往往有一段時間時空白。

1.1 傳統做法

const myImage = (() => {
	const imgNode = document.createElement('img')
	document.body.appendChild(imgNode)

	return {
		setSrc: src => {
			imgNode.src = src
		}
	}
})()

myImage.setSrc('https://img30.360buyimg.com/ling/jfs/t1/187775/5/8271/435193/60c8117eE7d79ef41/1d21db2c4dca9a90.png')

通過開發者工具把網速設置爲 5kb/s 時,會發現在很長一段時間內,圖片位置是空白的。

image.png

1.2 虛擬代理

下面用虛擬代理優化該功能,把加載圖片的操作交給代理函數完成,在圖片加載時,先用一張loading 圖佔位,當圖片加載成功後,再把它填充進 img 節點。

代碼如下:

const myImage = (() => {
	const imgNode = document.createElement('img')
	document.body.appendChild(imgNode)

	return {
		setSrc: src => {
			imgNode.src = src
		}
	}
})()

const loadingSrc = '../../../../img/loading.gif'
const imgSrc = 'https://img30.360buyimg.com/ling/jfs/t1/187775/5/8271/435193/60c8117eE7d79ef41/1d21db2c4dca9a90.png'

const proxyImage = (function () {
	const img = new Image()
	img.onload = () => {
		myImage.setSrc(img.src)
	}

	return {
		setSrc: src => {
			myImage.setSrc(loadingSrc)
			img.src = src
		}
	}
})()

proxyImage.setSrc(imgSrc)

上述代碼有以下優點:

  1. 通過 proxyImage 控制了對 MyImage 的訪問,在 MyImage 未加載成功之前,使用 loading 圖佔位;

  2. 踐行單一職責原則,給 img 節點設置 src 的函數 MyImage,預加載圖片的函數 proxyImage,都只有一個職責;

  3. 踐行開放-封閉原則,給 img 節點設置 src 和預加載圖片的功能,被隔離在兩個對象裏,它們可以各自變化不影響對方。

2. 合併HTTP請求

假設我們要實現一個同步文件的功能,通過複選框,當複選框選中的時候,將該複選框對應的 id 傳給服務器,告訴服務器需要同步 id 對應的文件。

思考一下,會發現,如果每選中一個複選框,就請求一次接口,假設 1s 內選中了 10 個複選框,那麼就要發送 10 次請求。

2.1 虛擬代理

可以通過虛擬代理來優化上述做法,新增一個代理,幫助複選框發起同步文件的請求,收集在這 1s 內的請求,1s 後再一起把這些文件 id 發送到服務器。

代碼如下:

<!DOCTYPE html>
<html>
<meta charset="utf-8" />
<head>
	<title></title>
</head>
<body>
  a <input type="checkbox" value="a" />
  b <input type="checkbox" value="b" />
  c <input type="checkbox" value="c" />
  d <input type="checkbox" value="d" />
	<script type="text/javascript" src="index.js">
	</script>
</body> 
</html>
const synchronousFile = cache => {
  console.log('開始同步文件,id爲:'+ cache.join('/'))
}

const proxySynchronousFile = (() => {
  const cache = []

  let timer

  return id => {
    console.log(id)
    cache.push(id)

    if (timer) {
      return
    }

    timer = setTimeout(() => {
      synchronousFile(cache)
      clearTimeout(timer)
      timer = null
      cache.length = 0
    }, 2000)
  }
})()

const checkbox = document.getElementsByTagName('input')

Array.from(checkbox).forEach(i => {
  console.log(i)
  i.onclick = () => {
    if (i.checked) {
      proxySynchronousFile(i.value)
    }
  }
})


3. ajax異步請求數據

在列表需要分頁時,同一頁的數據理論上只需要去後臺拉取一次,可以把這些拉取過的數據緩存下來,下次請求時直接使用緩存數據。

3.1 緩存代理

使用緩存代理實現上述功能,代碼如下:

(async function () {
  function getArticle (currentPage, pageSize) {
    console.log('getArticle', currentPage, pageSize)
    // 模擬一個ajax請求
    return new Promise((resolve, reject) => {
      resolve({
        ok: true,
        data: {
          list: [],
          total: 10,
          params: {
            currentPage, 
            pageSize
          }
        }
      })
    })
  }
  
  const proxyGetArticle = (() => {
    const caches = []
  
    return async (currentPage, pageSize) => {
  
      const cache = Array.prototype.join.call([currentPage, pageSize],',')
  
      if (cache in caches) {
        return caches[cache]
      }
      const { data, ok } = await getArticle(currentPage, pageSize)
  
      if (ok) {
        caches[cache] = data
      }
  
      return caches[cache]
    }
  })()

  // 搜索第一頁
  await proxyGetArticle(1, 10)
  
  // 搜索第二頁
  await proxyGetArticle(2, 10)

  // 再次搜索第一頁
  await proxyGetArticle(1, 10)
  
})()


通過緩存代理,在第二次請求第一頁的數據時,直接在緩存數據中拉取,無須再次從服務器請求數據。

4. 總結

上面根據實際場景介紹了虛擬代理和緩存代理的做法。

當我們不方便直接訪問某個對象時,找一個代理方法幫我們去訪問該對象,這就是代理模式。

可通過 github源碼 進行實操練習。

希望本文能對你有所幫助,感謝閱讀❤️~


歡迎關注凹凸實驗室博客:aotu.io

或者關注凹凸實驗室公衆號(AOTULabs),不定時推送文章:

歡迎關注凹凸實驗室公衆號

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章