1. 程式人生 > 實用技巧 >javscript 閉包應用介紹

javscript 閉包應用介紹

一、閉包的概念和特性

首先看個閉包的例子:

function makeFab () {  let last = 1, current = 1  return function inner() {    [current, last] = [current + last, current]    return last  }}
let fab = makeFab()console.log(fab()) // 1console.log(fab()) // 2console.log(fab()) // 3console.log(fab()) // 5

這是一個生成斐波那契數列的例子。makeFab的返回值就是一個閉包,makeFab像一個工廠函式,每次呼叫都會建立一個閉包函式,如例子中的fab。fab每次呼叫不需要傳引數,都會返回不同的值,因為在閉包生成的時候,它記住了變數last和current,以至於在後續的呼叫中能夠返回不同的值。能記住函式本身所在作用域的變數,這就是閉包和普通函式的區別所在。

MDN中給出的閉包的定義是:函式與對其狀態即詞法環境的引用共同構成閉包。這裡的“詞法環境的引用”,可以簡單理解為“引用了函式外部的一些變數”,例如上述例子中每次呼叫makeFab都會建立並返回inner函式,引用了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中的強大特性之一,然而至於閉包怎麼使用,我覺得不算是一個問題,甚至我們完全沒必要研究閉包怎麼使用。我的觀點是,閉包應該是自然而言地出現在你的程式碼裡,因為它是解決當前問題最直截了當的辦法;而當你刻意想去使用它的時候,往往可能已經走了彎路。