javscript 閉包應用介紹
一、閉包的概念和特性
首先看個閉包的例子:
function makeFab () {
let last = 1, current = 1
return function inner() {
[current, last] = [current + last, current]
return last
}
}
let fab = makeFab()
console.log(fab()) // 1
console.log(fab()) // 2
console.log(fab()) // 3
console.log(fab()) // 5
這是一個生成斐波那契數列的例子。makeFab的返回值就是一個閉包,makeFab像一個工廠函式,每次呼叫都會建立一個閉包函式,如例子中的fab。fab每次呼叫不需要傳引數,都會返回不同的值,因為在閉包生成的時候,它記住了變數last和current,以至於在後續的呼叫中能夠返回不同的值。能記住函式本身所在作用域的變數,這就是閉包和普通函式的區別所在。
二、閉包——函數語言程式設計之魂
JavaScript和python這兩門動態語言都強調一個概念:萬物皆物件。自然,函式也是物件。
在JavaScript裡,我們可以像操作普通變數一樣,把函式在我們的程式碼裡拋來拋去,然後在某個時刻呼叫一下,這就是所謂的函數語言程式設計。函數語言程式設計靈活簡潔,而語言對閉包的支援,讓函數語言程式設計擁有了靈魂。以實現一個可複用的確認框為例,比如在使用者進行一些刪除或者重要操作的時候,為了防止誤操作,我們可能會通過彈窗讓使用者再次確認操作。因為確認框是通用的,所以確認框元件的邏輯應該足夠抽象,僅僅是負責彈窗、觸發確認、觸發取消事件,而觸發確認/取消事件是非同步操作,這時候我們就需要使用兩個回撥函式完成操作,彈窗函式confirm接收三個引數:一個提示語句,一個確認回撥函式,一個取消回撥函式:
function confirm (confirmText, confirmCallback, cancelCallback) { // 插入提示框DOM,包含提示語句、確認按鈕、取消按鈕 // 新增確認按鈕點選事件,事件函式中做dom清理工作並呼叫confirmCallback // 新增取消按鈕點選事件,事件函式中做dom清理工作並呼叫cancelCallback}
這樣我們可以通過向confirm傳遞迴調函式,並且根據不同結果完成不同的動作,比如我們根據id刪除一條資料可以這樣寫:
function removeItem (id) { confirm('確認刪除嗎?', () => { //使用者點選確認, 傳送遠端ajax請求 api.removeItem(id).then(xxx) }, () => { // 使用者點選取消, console.log('取消刪除') })}
這個例子中,confirmCallback正是利用了閉包,建立了一個引用了上下文中id變數的函式,這樣的例子在回撥函式中比比皆是,並且大多數時候引用的變數是很多個。試想,如果語言不支援閉包,那這些變數要怎麼辦?作為引數全部傳遞給confirm函式,然後在呼叫confirmCallback/cancelCallback時再作為引數傳遞給它們?顯然,這裡閉包提供了極大便利。
三、閉包的一些例子
1. 防抖、節流函式
前端很常見的一個需求是遠端搜尋,根據使用者輸入框的內容自動傳送ajax請求,然後從後端把搜尋結果請求回來。為了簡化使用者的操作,有時候我們並不會專門放置一個按鈕來點選觸發搜尋事件,而是直接監聽內容的變化來搜尋(比如像vue的官網搜尋欄)。這時候為了避免請求過於頻繁,我們可能就會用到“防抖”的技巧,即當用戶停止輸入一段時間(比如500ms)後才執行傳送請求。可以寫一個簡單的防抖函式實現這個功能:
function debounce (func, time) { let timer = 0 return function (...args) { timer && clearTimeout(timer) timer = setTimeout(() => { timer = 0 func.apply(this, args) }, time) }} input.onkeypress = debounce(function () { console.log(input.value) // 事件處理邏輯}, 500)
debounce函式每次呼叫時,都會建立一個新的閉包函式,該函式保留了對事件邏輯處理函式func以及防抖時間間隔time以及定時器標誌timer的引用。類似的還有節流函式:
function throttle(func, time) { let timer = 0 // 定時器標記相當於一個鎖標誌 return function (...args) { if (timer) return func.apply(this, args) timer = setTimeout(() => timer = 0, time) }}
2. 優雅解決按鈕多次連續點選問題
使用者點選一個表單提交按鈕,前端會向後臺傳送一個非同步請求,請求還沒返回,焦急的使用者又多點了幾下按鈕,造成了額外的請求。有時候多發幾次請求最多隻是多消耗了一些伺服器資源,而另外一些情況是,表單提交本身會修改後臺的資料,那多次提交就會導致意料之外的後果了。無論是為了減少伺服器資源消耗還是避免多次修改後臺數據,給表單提交按鈕新增點選限制是很有必要的。怎麼解決呢?一個常用的辦法是打個標記,即在響應函式所在作用域宣告一個布林變數lock,響應函式被呼叫時,先判斷lock的值,為true則表示上一次請求還未返回,此次點選無效;為false則將lock設定為true,然後傳送請求,請求結束再將lock改為false。很顯然,這個lock會汙染函式所在的作用域,比如在vue元件中,我們可能就要將這個標記記錄在元件屬性上;而當有多個這樣的按鈕,則還需要不同的屬性來標記(想想給這些屬性取名都是一件頭疼的事情吧!)。而生成閉包伴隨著新的函式作用域的建立,利用這一點,剛好可以解決這個問題。下面是一個簡單的例子:
let clickButton = (function () { let lock = false return function (postParams) { if (lock) return lock = true // 使用axios傳送請求 axios.post('urlxxx', postParams).then( // 表單提交成功 ).catch(error => { // 表單提交出錯 console.log(error) }).finally(() => { // 不管成功失敗 都解鎖 lock = false }) }})() button.addEventListener('click', clickButton)
這樣lock變數就會在一個單獨的作用域裡,一次點選的請求發出以後,必須等請求回來,才會開始下一次請求。
當然,為了避免各個地方都宣告lock,修改lock,我們可以把上述邏輯抽象一下,實現一個裝飾器,就像節流/防抖函式一樣。以下是一個通用的裝飾器函式:
function singleClick(func, manuDone = false) { let lock = false return function (...args) { if (lock) return lock = true let done = () => lock = false if (manuDone) return func.call(this, ...args, done) let promise = func.call(this, ...args) promise ? promise.finally(done) : done() return promise }}
預設情況下,需要原函式返回一個promise以達到promise決議後將lock重置為false,而如果沒有返回值,lock將會被立即重置(比如表單驗證不通過,響應函式直接返回),呼叫示例:
let clickButton = singleClick(function (postParams) { if (!checkForm()) return return axios.post('urlxxx', postParams).then( // 表單提交成功 ).catch(error => { // 表單提交出錯 console.log(error) })})button.addEventListener('click', clickButton)
在一些不方便返回promise或者請求結束還要進行其它動作之後才能重置lock的地方,singleClick提供了第二個引數manuDone,允許你可以手動呼叫一個done函式來重置lock,這個done函式會放在原函式引數列表的末尾。使用例子:
let print = singleClick(function (i, done) { console.log('print is called', i) setTimeout(done, 2000)}, true) function test () { for (let i = 0; i < 10; i++) { setTimeout(() => { print(i) }, i * 1000) }}
print函式使用singleClick裝飾,每次呼叫2秒後重置lock變數,測試每秒呼叫一次print函式,執行程式碼輸出如下圖:
可以看到,其中一些呼叫沒有列印結果,這正是我們想要的結果!singleClick裝飾器比每次設定lock變數要方便許多,這裡singleClick函式的返回值,以及其中的done函式,都是一個閉包。
3. 閉包模擬私有方法或者變數
“封裝”是面向物件的特性之一,所謂“封裝”,即一個物件對外隱藏了其內部的一些屬性或者方法的實現細節,外界僅能通過暴露的介面操作該物件。js是比較“自由”的語言,所以並沒有類似C++語言那樣提供私有變數或成員函式的定義方式,不過利用閉包,卻可以很好地模擬這個特性。比如遊戲開發中,玩家物件身上通常會有一個經驗屬性,假設為exp,"打怪"、“做任務”、“使用經驗書”等都會增加exp這個值,而在升級的時候又會減掉exp的值,把exp直接暴露給各處業務來操作顯然是很糟糕的。在js裡面我們可以用閉包把它隱藏起來,簡單模擬如下:
function makePlayer () { let exp = 0 // 經驗值 return { getExp () { return exp }, changeExp (delta, sReason = '') { // log(xxx),記錄變動日誌 exp += delta } }} let p = makePlayer()console.log(p.getExp()) // 0p.changeExp(2000)console.log(p.getExp()) // 2000
這樣我們呼叫makePlayer()就會生成一個玩家物件p,p內通過方法操作exp這個變數,但是卻不可以通過p.exp訪問,顯然更符合“封裝”的特性。
四、總結
閉包是js中的強大特性之一,然而至於閉包怎麼使用,我覺得不算是一個問題,甚至我們完全沒必要研究閉包怎麼使用。我的觀點是,閉包應該是自然而言地出現在你的程式碼裡,因為它是解決當前問題最直截了當的辦法;而當你刻意想去使用它的時候,往往可能已經走了彎路。