一、單例模式
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'
}
上述程式碼有以下缺點:
- 在無需登入的情況下,也會新增登入浮窗的
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'
}
上述程式碼也存在缺陷,具體如下:
- 每次點選登入按鈕,都會建立一個登入浮窗,頻繁的建立
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'
}
經過重構,程式碼做了以下優化:
- 將建立例項物件
createLoginLayer
/createIframe
的職責和管理單例物件createSingle
的職責分離,符合單一職責原則; - 通過閉包儲存例項,並進行判斷,不管點選登入按鈕多少次,只建立一個登入浮窗例項;
- 易於擴充套件,當下次需要建立頁面中唯一的
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
上述程式碼有以下缺點:
- 使用
if-else
語句描述邏輯,程式碼龐大; - 缺乏彈性,如果需要修改績效
S
的獎金係數,必須修改calculateBonus
函式,違反了開放-封閉原則; - 無法再次複用,當其他地方需要用到這套邏輯,只能再複製一份。
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))
可以看到上述程式碼做了以下改動:
- 策略類
strategies
封裝了具體的演算法和計算過程(每種績效的計算規則); - 環境類
calculateBonus
接受請求,把請求委託給策略類strategies
(員工的績效和工資; - 將演算法的使用和演算法的實現分離,程式碼清晰,職責分明;
- 消除大量的
if-else
語句。
1.3 小結
策略模式使程式碼可讀性更高,易於擴充更多的策略演算法。當績效係數改變,或者績效等級增加,我們只需要為 strategies
調整或新增演算法,符合開放-封閉原則。
2. 表單校驗
當網頁上的表單需要校驗輸入框/核取方塊等等規則時,如何去實現呢?
現在有一個註冊使用者的表單需求,在提交表單之前,需要驗證以下規則:
- 使用者名稱不能為空
- 密碼長度不能少於 6 位
- 手機號碼必須符合格式
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>
上述程式碼有以下缺點:
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)
以上,小明通過小代,監聽到小白心情的心情變化,選擇在小白心情好時送花給小白。不僅如此,小代還可以做以下事情:
- 幫助小白過濾掉一些送花的請求,這就叫做保護代理;
- 幫助小明,在小白心情好時,再執行買花操作,這就叫做虛擬代理。虛擬代理把一些開銷很大的物件,延遲到真正需要它的時候才去建立。
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 時,會發現在很長一段時間內,圖片位置是空白的。
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)
上述程式碼有以下優點:
-
通過
proxyImage
控制了對MyImage
的訪問,在MyImage
未載入成功之前,使用loading
圖佔位; -
踐行單一職責原則,給
img
節點設定src
的函式MyImage
,預載入圖片的函式proxyImage
,都只有一個職責; -
踐行開放-封閉原則,給
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),不定時推送文章: