iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 20
0

這是 JavaScript 惡名昭彰的東西之一,閉包 ( closure )。
Tony 將會跟你說要抓到最重要的兩個重點就好!

頓悟

這邊請當故事看,說明閉包真的需要花時間來了解,才會有剎那間得到的瞬間。是一個不斷挖金礦的過程。

閉包不是必修學科,也不是威猛武器。

但閉包到處都是,一開始 Tony 還認不出來。現在想辦法來找到威利吧。

基本事實

closure 是函式記得並存取其語彙範圍的能力,甚至當函式是在其語彙範圍外執行時,也是如此。

function foo(){
    var a = 2;
    
    function bar(){
        console.log( a ); //2
    }
    
    bar(); // 在語彙範圍外不能執行
}
foo();

bar() 能夠存取 a,是因為語彙範疇的 RHS 查找規則。這或許算是閉包,但如果照定義看就不完全是。因為不能在語彙範疇外執行。

function foo() {
    var a = 2;
    function bar() {
        console.log( a );
    }
    return bar;
}
var baz = foo();
baz();   // 2

兩個重點

  1. bar() 範疇內的 a,會藉由 RHS 找到 foo 的內層範疇。
  2. foo 會回傳 bar 的函式物件。

開始執行

  1. 回到全域,baz 會等同執行了 foo(),就會得到回傳的 bar 函式物件。
  2. 代表執行 baz(),就是執行 bar()。
  3. 可是這時候的 foo 的環境消失了,因為執行過就會被丟掉!
  4. 但是 bar() 的執行會想辦法找到 a 的語彙範疇 ( 或記憶體位置 ),也就是之前的 foo 的內層。
  5. 一樣使用 a 的參考,這就是閉包 ( closure )。

下一個範例

function foo() {
    var a = 2;
    function baz() {
        console.log( a ); // 2
    }
    bar(baz);
}

function bar(fn) {
    fn(); 
}

執行 foo() 可以得到想要的結果,但不算嚴格的閉包。因為 foo 的環境不會消失。

var fn;
function foo() {
    var a = 2;
    
    function baz() {
        console.log( a );
    }
    fn = baz;
}

function bar() {
    fn();
}
foo();
bar(); // 2

一樣兩個重點

  1. baz() 會用到 foo 內部的語彙範疇。
  2. 執行了 foo,就可以讓全域的 fn,等同於 baz。執行 fn 等同執行 baz。

這邊的意思是,只要能夠保留著

  1. 內部的函數會藉由 RHS 綁定語彙範疇。
  2. 在語彙範疇外執行,一樣能找到參考。
    就是在使用閉包。

現在我能看到了

下面丟出了共三個範例

  1. setTimeout
function wait( message ){
    setTimeout( function timer(){
        console.log( message );
    },1000);
}
wait( "Hello, closure!" );
  1. 使用框架
function setupBot(name, selector){
    $( selector ).click( funtion activator(){
        console.log("Activating: " + name);
    });
}
setupBot( "closure Bot 1", "#bot_1" );
setupBot( "closure Bot 2", "#bot_2" );

以上這兩個範例,泛指只要把函數當成一級 ( first-class ) 的值傳遞 ( Functional Programming ),就是用到閉包。

(這邊有點想不透,因為他們都還是直接呼叫函式本身,所處的環境並沒有被回收。)

  1. IIFE
var a = 2;
(funtion IIFE(){
    console.log( a );
}());

a 是藉由正常的語彙範疇查找成功,並非真的透過 closure。所以不算是使用了 closure,就算 IIFE 和 closure 關係緊密。
( Tony 有點搞糊塗了,因為 global 也和前面兩個一樣沒有被回收,但就不算是閉包?)

迴圈與 Closure

下面迴圈使用結果,預期是每過一秒印出 1, 2,.., 5。

for (var i = 1; i < 5; i++) {
    setTimeout( function timer(){
        console.log( i );
    }, i*1000 );
}

但實際上是 6, 6, 6, 6, 6。

咦?

這邊有兩個問題

  1. 為什麼是 6?
  2. 為什麼都是 6?

ans1: 因為 for 迴圈,在處理的時候,會先判定是否 i <= 5。只有 i > 5 時會跳出迴圈,也就是 6。

ans2:

  1. for 迴圈跑完後,setTimeout 才會執行內部的回乎 ( timeout function callbacks )。
  2. 回乎時才會執行函數。
  3. 這時取得的,就是 for 迴圈結算後,不斷被覆蓋的 i。

首先,這就會意識到

  1. 每個 for 在執行時,需要存下語彙範疇的值。
  2. 回呼時,要呼叫對應範疇的值。

就用 IIFE 吧!既可以切割範疇,又可以當下執行。

for (var i = 1; i <= 5; i++ ) {
    (function() {
        setTimeout( function timer(){
            console.log( i );
        },i * 1000);
    })();
}

這個結果是不行的。
因為我們的確切割出範疇,但還是要存下值才有用。不然他還是空的範疇。

for (var i = 1; i < 5; i++ ) {
    (function() {
        var j = i;
        setTimeout( function timer(){
            console.log( j );
        },j * 1000);
    })();
}

這時候才有切割好的範疇,和不同的值。

稍微變化的寫法。運用隱含的 LHS。

for (var i = 1; i < 5; i++ ) {
    (function(j) {
        setTimeout( function timer(){
            console.log( j );
        },j * 1000);
    })(i);
}

重訪區塊範疇

另一個更棒的方法。
let 會劫持區塊,並在區塊宣告變數。( 剛好都有滿足! )

for ( var i = 1; i <= 5; i++) {
    let j = i;
    setTimeout( function timer() {
        console.log(j);
    }, j * 1000 );
}

最後就變成

for ( let i = 1; i <= 5; i ++) {
    setTimeout( function timer() {
        console.log(i);
    }, i * 1000 );
}

其實這是在每次的迭代都宣告一次。而且他是用前一次迭代最後得值。這是 let 在 for 迴圈標頭 ( header ) 的特殊行為。

最後的結果就變成,只要把 var 換成 let 就可以了。

那讓我成為了一名快樂的 JavaScript 編程員 ( JavaScripter )。

參考資料

  1. 你所不知道的JS
  2. 克服 JavaScript 奇怪的地方 46, 47

上一篇
Day19 - 拉升 ( hoisting )
下一篇
Day21 - 運用閉包模組化
系列文
你為什麼不問問神奇 JavaScript 呢?30

尚未有邦友留言

立即登入留言