“征服 JavaScript 面試”是我寫的一系列文章,來幫助面試者準備他們在面試 JavaScript 中、高階職位中將可能會遇到的一些問題。這些問題我自己在面試中也經常會問。
在我面試時問出的一系列問題裡,閉包通常是我問的第一個或最後一個問題。坦白地說,如果你連閉包也弄不明白,你是不會在 JavaScript 的道路上走多遠的。
你別東張西望,說的就是你。你真的理解如何構建一個嚴謹的 JavaScript 應用?你真的理解程式碼背後發生的事情或者說一個應用程式是如何工作的?我表示懷疑。如果連個閉包問題都搞不清的話,真是有點夠嗆。
你不僅僅應該瞭解閉包的機制,更應該瞭解閉包為什麼很重要,以及能夠很容易地回答出閉包的幾種可能的應用場景。
閉包在 JavaScript 中常用來實現物件資料的私有,在事件處理和回撥函式中也常常會用到它,此外還有偏函式應用(partial applications)和柯里化(currying),以及其他函數語言程式設計模式。
我不在乎面試者是否知道“closure”這個單詞或者它的專業定義。我只想弄清他們是否理解基本原理。如果他們沒有,那麼通常意味著這些面試者在構建實際 JavaScript 應用方面並沒有很多經驗。
如果你不能回答這個問題,你只是個初級開發者。不管你實際上已經幹這個多久了。
為了快速理解下面的內容:你想一下能否舉出兩個閉包的通用場景?
什麼是閉包?
簡言之,閉包是由函式引用其周邊狀態(詞法環境)綁在一起形成的(封裝)組合結構。在 JavaScript 中,閉包在每個函式被建立時形成。
這是基本原理,但為什麼我們關心這些?實際上,由於閉包與它的詞法環境綁在一起,因此閉包讓我們能夠從一個函式內部訪問其外部函式的作用域。
要使用閉包,只需要簡單地將一個函式定義在另一個函式內部,並將它暴露出來。要暴露一個函式,可以將它返回或者傳給其他函式。
內部函式將能夠訪問到外部函式作用域中的變數,即使外部函式已經執行完畢。
閉包使用的例子
閉包的用途之一是實現物件的私有資料。資料私有是讓我們能夠面向介面程式設計而不是面向實現程式設計的基礎。而面向介面程式設計是一個重要的概念,有助於我們建立更加健壯的軟體,因為實現細節比介面約定相對來說更加容易被改變。
“面向介面程式設計,別面向實現程式設計。” 設計模式:可複用物件導向軟體的要素
在 JavaScript 中,閉包是用來實現資料私有的原生機制。當你使用閉包來實現資料私有時,被封裝的變數只能在閉包容器函式作用域中使用。你無法繞過物件被授權的方法在外部訪問這些資料。在 JavaScript 中,任何定義在閉包作用域下的公開方法才可以訪問這些資料。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
const getSecret = (secret) => { return { get: () => secret }; }; test('Closure for object privacy.', assert => { const msg = '.get() should have access to the closure.'; const expected = 1; const obj = getSecret(1); const actual = obj.get(); try { assert.ok(secret, 'This throws an error.'); } catch (e) { assert.ok(true, `The secret var is only available to privileged methods.`); } assert.equal(actual, expected, msg); assert.end(); }); |
在上面的例子裡,get()
方法定義在 getSecret()
作用域下,這讓它可以訪問任何 getSecret()
中的變數,於是它就是一個被授權的方法。在這個例子裡,它可以訪問引數 secret
。
物件不是唯一的產生私有資料的方式。閉包還可以被用來建立有狀態的函式,這些函式的執行過程可能由它們自身的內部狀態所決定。例如:
1 |
const secret = (msg) => () => msg; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// Secret - creates closures with secret messages. // https://gist.github.com/ericelliott/f6a87bc41de31562d0f9 // https://jsbin.com/hitusu/edit?html,js,output // secret(msg: String) => getSecret() => msg: String const secret = (msg) => () => msg; test('secret', assert => { const msg = 'secret() should return a function that returns the passed secret.'; const theSecret = 'Closures are easy.'; const mySecret = secret(theSecret); const actual = mySecret(); const expected = theSecret; assert.equal(actual, expected, msg); assert.end(); }); |
在函數語言程式設計中,閉包經常用於偏函式應用和柯里化。為了說明這個,我們先定義一些概念:
函式應用:一個過程,指將引數傳給一個函式,並獲得它的返回值。
偏函式應用:一個過程,它傳給某個函式其中一部分引數,然後返回一個新的函式,該函式等待接受後續引數。換句話說,偏函式應用是一個函式,它接受另一個函式為引數,這個作為引數的函式本身接受多個引數,它返回一個函式,這個函式與它的引數函式相比,接受更少的引數。偏函式應用提前賦予一部分引數,而返回的函式則等待呼叫時傳入剩餘的引數。
偏函式應用通過閉包作用域來提前賦予引數。你可以實現一個通用的函式來賦予指定的函式部分引數,它看起來如下:
1 2 |
partialApply(targetFunction: Function, ...fixedArgs: Any[]) => functionWithFewerParams(...remainingArgs: Any[]) |
如果你要更進一步理解上面的形式,你可以看這裡。
partialApply
接受一個多引數的函式,以及一串我們想要提前賦給這個函式的引數,它返回一個新的函式,這個函式將接受剩餘的引數。
下面給一個例子來說明,假設你有一個函式,求兩個數的和:
1 |
const add = (a, b) => a + b; |
現在你想要得到一個函式,它能夠對任何傳給它的引數都加 10,我們可以將它命名為add10()
。add10(5)
的結果應該是 15
。我們的 partialApply()
函式可以做到這個:
1 2 |
const add10 = partialApply(add, 10); add10(5); |
在這個例子裡,引數 10
通過閉包作用域被提前賦予 add()
,從而讓我們獲得 add10()
。
現在讓我們看一下如何實現 partialApply()
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
// Generic Partial Application Function // https://jsbin.com/biyupu/edit?html,js,output // https://gist.github.com/ericelliott/f0a8fd662111ea2f569e // partialApply(targetFunction: Function, ...fixedArgs: Any[]) => // functionWithFewerParams(...remainingArgs: Any[]) const partialApply = (fn, ...fixedArgs) => { return function (...remainingArgs) { return fn.apply(this, fixedArgs.concat(remainingArgs)); }; }; test('add10', assert => { const msg = 'partialApply() should partially apply functions' const add = (a, b) => a + b; const add10 = partialApply(add, 10); const actual = add10(5); const expected = 15; assert.equal(actual, expected, msg); }); |
如你所見,它只是簡單地返回一個函式,這個函式通過閉包訪問了傳給 partialApply()
函式的fixedArgs
引數。
輪到你來試試了
你用閉包來做什麼?如果你有最喜歡的應用場景,舉一些例子,在評論中告訴我。