iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 20
0
Modern Web

教練我想學 JavaScript 系列 第 20

Day 20 閉包

閉包 (Closures) 是要瞭解 JavaScript 的重要觀念,
我們已經知道呼叫函數會將函數的執行環境放進執行堆中執行,
,在函數被加進執行堆上方,
在執行時如果函數裡有變數會依據你指派的值將變數在變數環境裡的記憶體位址中的值重新設值,
當函數執行完,這個函數的執行環境就從執行堆中被清除掉了,

如果我們想在全域執行環境取用函數中的變數你覺得辦的到嗎?
來看段程式碼:

function greet(whattosay) {

  return function(name) {
    console.log(whattosay + ' ' + name);
  }

}

greet('Hi')('Jimmy');

我在函數 greet 中 透過 return 回傳一個函數(表達式會回傳一個值),
在全域執行環境呼叫函數 greet 並傳入 'Hi' 當作參數 whattosay 的值,
緊鄰著另一個括號來執行回傳的匿名函數,
接著我們到 Console 中來看結果:

這看起來很正常,但是其實有一些細節需要注意,

我們將回傳的函數指派給一個變數 sayHi,
程式碼如下:

function greet(whattosay) {

  return function(name) {
    console.log(whattosay + ' ' + name);
  }

}

var sayHi = greet('Hi');
sayHi('Jimmy');

我們來看 Console 中的結果:

你會看到結果跟剛才一樣,
但如果我們仔細來看呼叫函數 greet 並指派給變數 sayHi 這行,

程式碼如下:

var sayHi = greet('Hi');

呼叫函數 greet 時會創造函數 greet 的執行環境與變數環境,
當執行階段時會將函數 greet 的執行環境放進執行堆,
函數 greet的參數(變數) whattotsay 在執行時記憶體位址裡的值被設值成 'Hi',
如下圖:

圖片來源:JavaScript 全攻略:克服 JS 的奇怪部分課程第 4 節講座 46 影片截圖

當函數執行完程式屬性的內容後會從執行堆中清除,
如下圖:

圖片來源:JavaScript 全攻略:克服 JS 的奇怪部分課程第 4 節講座 46 影片截圖

但是函數 greet 執行環境裡的變數還在記憶體中存在著,
接著會回到全域執行環境中,
呼叫 sayHi 指向的函數,
一樣會在變數環境替變數 sayHi 指向的記憶體位址設值成匿名函數,
如下圖:

圖片來源:JavaScript 全攻略:克服 JS 的奇怪部分課程第 4 節講座 46 影片截圖

接著當匿名函數被呼叫也會創造匿名函數的執行環境到執行堆中,

如下圖:

圖片來源:JavaScript 全攻略:克服 JS 的奇怪部分課程第 4 節講座 46 影片截圖

因為呼叫匿名函數時有傳入參數所以也會在匿名函數的執行環境裡的變數環境替變數(參數) 設值,
接著在執行 console.log 裡面的內容:

console.log(whattosay + ' ' + name);

這時因為在匿名函數內找不到變數(參數) whattosay ,
我們說過執行環境有自己的外部環境可以參考,所以會進到範圍鏈找,
這邊會往外一層的函數裡找,
雖然函數的執行環境已經離開執行堆,但是變數還在記憶體中某處,因此可以找到變數 whattosay,
如下圖:

圖片來源:JavaScript 全攻略:克服 JS 的奇怪部分課程第 4 節講座 46 影片截圖

當匿名函數的執行堆在最上方時(函數裡的程式屬性還在執行時),
匿名函數的執行環境可以把外部詞彙環境的變數包住,
如下圖:

圖片來源:JavaScript 全攻略:克服 JS 的奇怪部分課程第 4 節講座 46 影片截圖

這種現象在 JavaScript 中就稱為閉包,雖然函數的執行環境已經離開執行堆,但執行環境裡的變數還在記憶體中,
因此可以取用到在匿名函數中不存在的變數。

接著我們來看另一個範例,
程式碼如下:

function buildFunctions() {
  var arr = [];

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

  return arr;
}

fs = buildFunctions();

fs[0]();
fs[1]();
fs[2]();

猜猜結果會是什麼?

在 Console 中的結果如下:

有沒有很意外輸出同樣的結果?

讓我們來看看到底發生了什麼事,
程式碼在剛開始執行時都會因為創造階段的 Hoisting 特性替我們將變數、函數都先創造到全域執行環境裡的變數環境,
當開始執行時全域執行環境被放進執行堆,
執行每一行程式碼時會將我們的變數設值,
如下圖:

圖片來源:JavaScript 全攻略:克服 JS 的奇怪部分課程第 4 節講座 47 影片截圖

函數 buildFunctions 中有個 for 迴圈陳述句,for 迴圈裡每次會透過陣列的 push 方法將匿名函數加進陣列裡面,
最後會回傳這個變數 arr ,變數 arr 現在是有3個匿名函數的陣列,

我們透過變數 fs 來儲存呼叫函數 buildFunctions 後執行完回傳的變數 arr,

函數 buildFunctions 的 for 迴圈每次執行時會將匿名函數加進陣列中,
但加進陣列時匿名函數沒有被執行所以不會有自己的執行環境,
因此每個匿名函數的變數 i 的值不會被分別設值成 0, 1, 2,
當 for 迴圈的變數 i = 3 時,因為不符合 i < 3 的條件,所以迴圈中斷,
變數 i 在函數 buildFunctions 裡最後被設值成 3,
如下圖:

圖片來源:JavaScript 全攻略:克服 JS 的奇怪部分課程第 4 節講座 47 影片截圖

當函數 buildFunctions 執行完後執行環境會從執行堆裡被清除,但變數仍然存在記憶體中,
如下圖:

圖片來源:JavaScript 全攻略:克服 JS 的奇怪部分課程第 4 節講座 47 影片截圖

所以當呼叫第一個陣列中的函數時一樣會將執行環境放到執行堆中執行,
如下圖:

圖片來源:JavaScript 全攻略:克服 JS 的奇怪部分課程第 4 節講座 47 影片截圖

但是在執行時匿名函數裡沒有變數 i ,所以會進到範圍鏈裡面找,
因為匿名函數在函數 buildFunctions裡面被創造,
所以匿名函數的外部環境參照到函數 buildFunctions,
雖然函數 buildFunctions 的執行環境已經被清除,但是變數 i 與 變數 arr 仍然在記憶體中,
所以可以找到變數 i ,此時變數 i 的值是3 ,因為函數buildFunctions 離開執行環境前 ,
for 迴圈因為條件的關係,最後將變數 i 設值成 3,
如下圖:

圖片來源:JavaScript 全攻略:克服 JS 的奇怪部分課程第 4 節講座 47 影片截圖

所以當匿名函數執行 console.log(i) 時, i 是3,

執行第2個與第3個匿名函數也是如此,

因為閉包會將外部環境參照到的變數包起來並在執行時找到,
所以匿名函數執行console.log(i) 時輸出的都是 3,

這門課程的講師在這邊的說法是:
「如果問同個爸爸生的不同年齡的小孩爸爸年齡幾歲?他們不會因為年齡不同,在回答時回答不一樣的答案」。

那我們要怎麼樣讓輸出是 0, 1, 2 呢?

可以透過以下兩種方式,
第1種是使用 ES6 宣告變數的新語法 let 來達成,
程式碼如下 :

function buildFunctions2() {
  var arr = [];

  for(var i = 0; i < 3; i++) {
    let j = i;
    arr.push(function() {
      console.log(j);
    });
  }

  return arr;
}

fs2 = buildFunctions2();

fs2[0]();
fs2[1]();
fs2[2]();

這時 Console 中的結果如下:

這樣就符合我們預期的輸出了,

我們來看這發生了什麼事,
當呼叫函數buildFunctions2 時執行環境被丟進執行堆執行,
裡面的 for 迴圈在每次的迴圈中看到你用 let 宣告變數 j ,
在創造變數 j 時會產生不同的變數環境(記憶體位址),
在執行時在不同的變數環境裡分別被設成不同的值,不會覆蓋相同一個變數 j 的值,
雖然函數buildFunctions2 執行完執行環境被清除,
但每個被設成不同值的變數 j 仍然保存在記憶體中,
所以在執行時取用的變數 j 會是不同的值,

第2種方法是用 IIFE,
程式碼如下:

function buildFunctions2() {
  var arr = [];

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

  return arr;
}

fs2 = buildFunctions2();

fs2[0]();
fs2[1]();
fs2[2]();

Console 中的結果如下:

因為透過 IIFE 在每次 for 迴圈執行時同時呼叫匿名函數來產生不同的執行環境來儲存變數 j ,
每次迴圈將 IIFE 加進陣列時同時產生不同的執行環境來儲存變數 j,變數 j 在記憶體中的值被設成不同的值,
所以每次 IIFE 執行完時執行環境被清除後,變數 j 還留在記憶體中,
當我執行每個陣列中的回傳的匿名函數會因為沒有自己的變數 j 而進入範圍鏈中找到那次迴圈執行完 IIFE 產生的執行環境從執行堆被清除後還留在記憶體中的變數 j ,所以每次呼叫回傳的匿名函數在 console.log(j)時,變數 j 都會是不同的結果。


上一篇
Day 19 立即呼叫的函數表達式 IIFE
下一篇
Day 21 Function Factories、閉包與回呼
系列文
教練我想學 JavaScript 30

尚未有邦友留言

立即登入留言