理解 JavaScript 中的閉包

4Ark發表於2019-01-11

前言

繼上一篇《理解 JavaScript 中的作用域》後,我又立刻寫下了這篇文章,因為這兩者是存在關聯的,在理解閉包前,你需要知道作用域。

而對於那些有一點 JavaScript 使用經驗的人來說,理解閉包可以看做是某種意義上的重生,但這並不簡單,你需要付出非常多的努力和犧牲才能理解這個概念。

如果你理解了閉包,你會發現即便是沒理解閉包之前,你也用到了閉包,但我們要做的就是根據自己的意願正確地識別、使用閉包。

什麼是閉包

閉包的定義,你需要掌握它才能理解和識別閉包:

當函式可以記住並訪問所在的詞法作用域時,就產生了閉包,即便函式是在當前詞法作用域之外執行。

下面用一些程式碼來解釋這個定義:

function foo(){
    var a = 2;
    function bar(){
        console.log(a); // 2
    }
    bar();
}
foo();
複製程式碼

很明顯這是一個巢狀作用域,而bar的作用域也確實能夠訪問外部作用域,但這就是閉包嗎?

不,不完全是,但它是閉包中很重要的一部分:根據詞法作用域的查詢規則,它能夠訪問外部作用域。

下面再來看這段程式碼,它清晰地使用了閉包:

function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    return bar;
}
var baz = foo();
baz(); // 2 —— 這就是閉包
複製程式碼

由於bar的詞法作用域能夠訪問foo的內部作用域,然後我們把bar這個函式本身當作返回值,然後在呼叫foo時把bar引用的函式賦值給baz(其實是兩個識別符號引用同一個函式),所以baz能夠訪問foo的內部作用域。

而這裡正是印證前面的定義:函式是在當前詞法作用域之外執行。

其實按正常情況下,引擎有垃圾回收器用來釋放不再使用的記憶體空間,當foo執行完畢時,自然會將其回收,但閉包的神奇之處正是可以阻止這件事情的發生,因為內部作用域依然存在,bar在使用它。

由於bar宣告位置的原因,它涵蓋了foo內部作用域的閉包,使得該作用域能夠一直存活,以供bar在之後任何時間進行引用。

bar依然有對該作用域的引用,而這個引用就叫做閉包。

因此,當baz在呼叫時,它自然能夠訪問到foo的內部作用域。

當然,無論使用何種方式對函式型別的值進行傳遞,當函式在別處被呼叫時都可以觀察到閉包的存在:

function foo(){
    var a = 2;
    function baz(){
        console.log(a);
    }
    bar(baz);
}
function bar(fn){
    fn(); // 2 —— 這也是閉包
}
複製程式碼

把內部函式baz作為fn引數傳遞給bar,當呼叫fn時,它能夠訪問到foo的內部作用域。

傳遞函式也可以是間接的:

var fn;
function foo(){
    var a = 2;
    function baz(){
        console.log(a);
    }
    fn = baz;
}
foo();
fn(); // 2 —— 這也是閉包
複製程式碼

所以:

無論通過何種方式將內部函式傳遞到所在的詞法作用於之外,它都會持有對原始定義作用域的引用,無論在何處執行這個函式都會使用閉包。

閉包的使用

既然前面說閉包無處不在,那不妨看看幾個平時經常看到的片段,看看閉包的妙用。

function wait(message){
    setTimeout(function timer(){
        console.log(message);
    },1000);
}
wait("Hello, closure!");
複製程式碼

將一個內部函式(這裡叫做timer)作為引數傳遞給setTimeout,而timer能夠訪問wait的內部作用域。

如果你使用過jQuery,不難發現下面程式碼中也使用了閉包:

function setupBot(name,selector){
    $(selector).click(function activator(){
        console.log("Activating:" + name);
    })
}
setupBot("Closure Bot 1","#btn_1");
setupBot("Closure Bot 2","#btn_2");
複製程式碼

本質上無論何時何地,如果將函式( 訪問它們各自的詞法作用域)當作第一級的值型別併到處傳遞, 你就會看到閉包在這些函式中的應用。 在定時器、 事件監聽器、Ajax請求、 跨視窗通訊、Web Workers或者任何其他的非同步( 或者同步)任務中, 只要使用了回撥函式,實際上就是在使用閉包!

再來看一個很經典的閉包面試題:

for (var i=1; i<=5; i++){
	setTimeout(function(){
		console.log(i);
    },i*1000);
}
複製程式碼

正常情況下,我們對這段程式碼行為的預期是每秒一次輸出1~5。

但實際上,這段程式碼在執行時會以每秒一次的頻率輸出五次6。

為什麼?

首先解釋6是從哪裡來的,這個迴圈的終止條件是i不再<=5,所以當條件成立時,i等於6。因此,輸出顯示的是迴圈結束時i的最終值。

也就是我們陷入了一個這樣的誤區:以為迴圈中每個迭代在執行時都會複製一個i的副本,但根據作用域的工作原理,它們都共享同一個全域性作用域,因此實際上只有一個i

要使這段程式碼的執行與我們預期一致,解決方法如下:

for (var i=1; i<=5; i++){
    (function(j){
        setTimeout(function(){
            console.log(j);
        },j*1000);
    })(i)
}
複製程式碼

在這段程式碼中我們使用了IIFE,將i作為引數j傳遞進去,在每個迭代IIFE會生成一個自己的作用域,它們接受引數j不一樣,所以這段程式碼能夠符合我們預期地執行。

還有別的解決方案嗎?

是的,使用 ES6 新出的let可以解決這個問題:

for (let i=1; i<=5; i++){
	setTimeout(function(){
		console.log(i);
    },i*1000);
}
複製程式碼

我們僅僅把var替換為let就輕鬆地解決了該問題,原因如下:

  • for中有自己的塊作用域(()是父級作用域,{}是子級作用域)。
  • 使用let能夠建立塊作用域的變數。

好了,到現在你應該能夠很容易地識別閉包,那麼接下來,我們繼續介紹閉包更高階的用法。

假設我們有這樣一個物件:

var box = {
    age : 18,
}
console.log(box.age); // 18
複製程式碼

然而這裡有一個問題,那就是屬性age可以隨意改變,如果我們使用閉包,就可以實現私有化,將age屬性保護起來,只做允許的修改。

var box = (function (){
    var age = 18;
    return {
        birthday : function(){
            age++;
        },
        sayAge : function(){
            console.log(age);
        }
    }
})();
box.birthday();
box.sayAge(); // 19
複製程式碼

這樣我們就保證age屬性只能增加,而不能減少,畢竟沒有人能夠越活越年輕。

注意:

  1. 其實物件也有方法可以控制屬性的修改,但這裡主要講述閉包,就不過多贅述。
  2. 使用閉包能夠輕鬆實現原本在 JavaScript 較複雜的設計。

後記

其實當你理解了閉包之後,你就會發現一切都是那麼的理所當然,就彷彿它本該如此。

最後,如果你已經理解了閉包並且想練習一下,那麼我可以出一道題目給你:

實現一個add函式,功能:add(1)(2)(3); // 6

難一點的:

實現一個add函式,功能:add(3)(‘*’)(3); // 9

有幾點:

  1. add函式可以被無限呼叫。
  2. 呼叫完畢後將結果輸出到控制檯。

感謝觀看!

注:此文為原創文章,如需轉載,請註明出處。

相關文章