iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 23
1
Modern Web

JavaScript基本功修煉系列 第 23

JavaScript基本功修練:Day23 - 閉包

閉包最常用的情況是在巢狀函式裏(即是函式裏的函式)。它最強大的功能就是能夠把函式裏的資料狀態保存下來。一般而言,函式一旦被執行掉後,函式內的資料就會被銷毀,從而釋放記憶體空間。

但是閉包就能避免以上情況,引用Kuro大大這篇文章,他指出「閉包」是當我們回傳內層函式時,除了回傳函式本身,也會回傳內層函式當時環境的變數值,以及記下當時的環境。因此,我可以利用這個特性來保存我們想要的資料。

題外話,雖然巢狀函式(即是函式裏的函式)才會常常用到閉包的概念。然而,這不代表只有巢狀函式才會產生閉包。事實上,只要是函式,就會產生閉包

回到重點,這篇文章會整理以下知識:

  • 範圍鏈概念
  • 理解閉包概念的例子
  • 函式工廠、私有化

範圍鏈(Scope chain)

為什麼要提及範圍鏈?以上提過,當回傳內層函式時,也會一併回傳它當時環境的變數值,記下當時的環境。「環境」這一詞,就是與範圍鏈有關。範圍鏈的意思是:

  • 內層函式可以取得外層函式的變數,但外層函式就不能取得內層函式的變數
  • 如果在內層函式裏,找不到某個變數,就會往上層查找,直至找到為止,最後會找到全域

簡單例子:

function funcA(){
    var num = 10;
    
    function funcB(){
        //內層沒有num,往上層找num
        var x = 100;
        console.log(num) //10
    }
    funcB()
    
    //外層不能訪問內層
    console.log(x) //x is not defined
}

funcA()

閉包概念

重溫完範圍鏈的概念後,就來看看閉包的意思,以下例子所做的事:

  1. 把內層函式inner存在一個變數a
  2. 呼叫變數a
var name = '全域name'
function outer(){
    name = '區域name'
    function inner(){
        console.log(name)
    }
    return inner 
}

const a = outer()
a() //區域name

最後結果會回傳區域name,並非全域name。為什麼?我們不是在全域呼叫inner內層函式(即是變數a)嗎?

然而,範圍鏈在建立函式的當下已經被定義好,而非在呼叫時定義,所以即使在全域呼叫它,它的值仍然是根據它當時在函式裏的範圍鏈而定。

inner裏的name,會取得外層的name,即是區域name,這個範圍鏈已經定義好了。即使你之後在全域呼叫此內層函式,它裏面的name還是會指向outer裏的name(區域name)

閉包概念小總結

總結以上例子,當我們把內層函式存在變數裏,並呼叫該變數,即是呼叫該內層函式。這時候,它是依照當時在函式時,定義好的範圍鏈去執行。

正因為它是依照當時的範圍鏈執行,所以我們可以說,當回傳內層函式時,除了回傳內層函式自己本身,也同時取得內層函式當時環境的變數值,以及記下當時的環境,這就是函包的用處。

理解閉包概念的例子

例子一

以下例子很經典,經常被用來解釋閉包。我們預期會得出0,1,2,但卻得出3,3,3:

function func(){
    var arr = [];
    for(var i=0; i<3; i++){
        arr.push( function(){
            console.log(i);
        })
    }
    return arr;
}

var result = func();
result[0]()
result[1]()
result[2]()

//回傳3,3,3

迴圈跑3次,每次把一個匿名函式推到arr裏,所以arr現在是:

[function(){console.log(i);}, function(){console.log(i);}, function(){console.log(i);}]

當時在func()函式裏時,這些匿名函式裏並沒有i這個變數,所以往上查找全域的i,並取得全域i的數值,之後才被推到arr陣列裏。即使現在我們在全域一一執行它們,這裏是i也會是全域i數值(沿用當時已定義好的範圍鏈),所以它們都會吐出3這數值。

要解決這個問題,可以用立即函式去把每次跑迴圈的i數值帶進去匿名函式裏:

function func(){
    var arr = [];
    for(var i=0; i<3; i++){
        (function(i){
            arr.push( function(){
                console.log(i);})
        })(i)
    }
    return arr;
}

var result = func();
result[0]()
result[1]()
result[2]()

//回傳0,1,2

另外一個最常見做法就是把var i=0改成let i=0,使i只是存活在{}中,這裏就不多解釋。

例子二

當我在閱讀Kuro大大的文章時,看到他文章底下網友的提問,也一度使自己的腦袋打結,發現自己沒有把閉包的概念理解清楚,所以也想在此記錄一下自己想不通的地方。

例子是做一個累加器,每次執行函式,就會累加一次。以下例子中,以執行3次為例,預期結果是1,2,3

以下為Kuro大大的正確例子:

function counter(){
    var count = 0;
    return function(){
        return ++count
    }
}

var countFunc = counter();

console.log(countFunc()); //1
console.log(countFunc()); //2
console.log(countFunc()); //3

該位網友留言裏的例子,他只能印出1:

function counter(){
    var count = 0;
  
    function innerCounter(){
      return ++count;
    }
    
    var tmpV = innerCounter();
    // console.log( '>>>' + tmpV ); (這行無關重要所以先註解起來)
    return tmpV;
  }
console.log( counter() );   // 1
console.log( counter() );   // 1
console.log( counter() );   // 1

Kuro做法:

把回傳的內層函式存放到變數 > 呼叫變數(呼叫內層函式) > 得出數值 

網友做法:

呼叫外層函式 > 把內層函式結果存到變數 > 回傳變數 > 得出數值

這裏的關鍵是有沒有執行var count = 0。網友的做法中,雖然有把內層函式innerCounter的結果儲存起來並且回傳,可是,當他第1次打後再呼叫counter時,他每次都會執行var count = 0,使count數值再次歸零,所以每次都只會回傳1。

相反,Kuro的做法是直接接呼叫內層函式innerCounter,不會呼叫外層函式counter而導致每次都會跑到var count = 0。每次呼叫內層函式時,因為之前已定義好innerCounter內層的count會從外層count變數取得,而且,內層的count是會改變外層的count。所以每次累加後,外層countercount都會被+1,從而達成累加效果。

如果我們在中間加多一行程式碼就更清楚:

function counter(){
    var count = 0;
    return function(){
        console.log('上一次的數目:' + count)
        return ++count
    }
}
var countFunc = counter();

console.log(countFunc()); //1
console.log(countFunc()); //2
console.log(countFunc()); //3

函式工廠

上面提及到,我們透過呼叫內層函式,保存到外層函式的資料狀態。我們可以應用此原理到一些需要預設值的情況,範例如下:

function calculate(init){
    //預設價錢是100     
    var price = init || 100;
    return function(num){
        price += num;
        return price;
    }
}

var item1 = calculate(500);
var item2 = calculate(2000);
var item3 = calculate();

console.log(item1(50)) //550
console.log(item2(1000)) //3000
console.log(item3(10)) //110

此例可見,外層的資料其實是可變的(傳入參數init)。這3個item的數值不會影響對方。每次再呼叫內層函式,內層price會取得外層price的值,這就是建立calculate函式時已經定義好的範圍鏈。

以下的部分,就是3個獨立的函式,它們之間並無關係:

var item1 = calculate(500);
var item2 = calculate(2000);
var item3 = calculate();

之後,我們再各自呼叫這3個變數(即是3個獨立存在的函式),並呼叫內層函式,然後就是在跑我們之前討論過的流程了。

這就是函式工廠的做法。這裏的函式就如工廠一樣,我們可以傳進不同的原材料,得出不同的產品,但工作流程是一樣的。

給它不同原材料(值) > 做一樣的流程 > 生產不同結果

私有化

另一個更彈性的做法就是私有化,意思是在函式裏放有多種方法任由自己選擇,使我們可以選擇用某個方法來產生值。例如:

function calculate(init){
    //預設價錢是100    
    var price = init || 100;
    return {
        add: function(num){
            return price += num
        },
        deduct: function(num){
            return price += num
        }
    }
}

var item1 = calculate(500);
var item2 = calculate(2000);

console.log(item1.add(100)); //600
console.log(item1.add(100)); //700
console.log(item2.deduct(500)); //2500

以上例子中,我們把多種方法放到一個物件裏,可以任意選擇需要的方法來產生值,這就是私有化的做法。

上面提及的兩種做法,好處是可以提高一個函式的可重用性,使我們不用建立多餘的函式,導致程式碼過分冗長。

總結

  • 閉包是指當我們回傳內層函式時,除了回傳函式本身,也會回傳內層函式當時環境的變數值,以及記下當時的環境
  • 範圍鏈在建立函式時已經定義好,不是在呼叫函式時定義
  • 閉包最常用在巢狀函式,即是函式裏的函式
  • 閉包強大功能是可以把函式裏的資料狀態保存下來

參考資料

重新認識 JavaScript: Day 19 閉包 Closure
闭包
從ES6開始的JavaScript學習生活
JS 原力覺醒 Day08 - Closures
JS 核心篇(六角學院)


上一篇
JavaScript基本功修練:Day22 - 回傳函式與立即函式(IIFE)
下一篇
JavaScript基本功修練:Day24 - 非同步執行與事件佇列
系列文
JavaScript基本功修煉31

尚未有邦友留言

立即登入留言