iT邦幫忙

2022 iThome 鐵人賽

DAY 14
0
Modern Web

就是要搞懂 JavaScript 啦!系列 第 14

Day14 閉包:服務生!麻煩整個打包帶走

  • 分享至 

  • xImage
  •  

在結束提升的討論後,我們來到作用域中的另一個謎題:閉包(Closure)。

閉包在 JS 的程式碼中扮演隱微卻關鍵的角色,函式替作用域創造了封閉性,閉包卻把函式內的秘密帶了出來。

讓我們看看以下程式碼:

function makeClosure() {
	let a = 2;

	function callIt() {
		console.log( a );
	}

	return callIt;
}

let foo = makeClosure();
foo(); // 2

學習並實踐過 JS 的人,一定不會對這段程式碼有什麼疑問,但結合前面對作用域的描述,不覺得上面的執行結果有什麼不對嗎?

按照作用域規則,函式 makeClosure 內部的一切對外應該是保密的,並且在 makeClosure() 執行完畢後,function makeClosure 應該就被記憶體回收了。那麼調用 foo() 後,為什麼能夠順利取得 makeClosure 內部宣告的變數,打印出 2

你或許猜到了,關鍵在於 makeClosure() 的執行結果回傳了 callIt 這個函式,並賦值給 foo,等於打開了一個讓 callIt 來到外部作用域的通道。

而我們能看到,被轉存到 foo 裡面的 callIt 被調用時,並沒有對 console.log( a ); 該打印誰感到疑問,它順利找到 a 指向的參考,毫無錯誤地運行完成。

如此能夠清晰地看見,當函式 callIt 被回傳並賦值給 foo 時,它所能存取的作用域範疇也一併被保留下來,以供 foo 調用時使用,這就是所謂的「閉包」。

閉包包裹了函式 callIt 以及它能夠訪問的作用域,讓原本屬於 callIt 的函式內容能在 makeClosure 內部以外的地方順利執行。

達成閉包一個重要的概念是「一級函式」,也就是函式被當作頭等公民(first class),和字串、數字一樣能夠被當成參數傳遞,也能夠作為返回值。


被保存的作用域

關於閉包,這裡再探討深入一些:

function aFunc(x) {
  return function () {
    console.log(x++);
  }
}

const newFunc = aFunc(1);
newFunc();
newFunc();

執行上面程式碼後,你預期會出現什麼呢?

按照前面的說明,我們已經知道由於閉包的威力, newFunc 裡面被賦值的內容,除了一個函式之外,還包括了這個函式能夠存取的作用域。

所以說,存活於 aFunc 內部的變數 x,它在被調用時就被固定住了嗎?還是依然能夠作為一個變數,擁有改變內容的特性?

如果實際執行過上面的程式碼,會發現兩次 newFunc() 調用分別輸出了 12

也就是說,aFunc(1) 執行完將函式作為結果回傳給 newFunc 後,newFunc() 每次執行時,都更改了原本屬於 aFunc 內部的 x 參數。

aFunc 內部的作用域不但被完整保留了下來,甚至能夠改變內部的變數,表示閉包內儲存的作用域,其保存的內容依然是參考(reference)。

那麼,不論 aFunc 被調用了幾次,裡面全部都指向同個參考嗎?

我們用下面的程式碼來探討這個問題:

function addNum(x) {
  return function (y) {
    console.log(`x=${x} y=${y} x+y=${x + y}`);
  }
}

const addOne = addNum(1);
const addTen = addNum(10);

addOne(4); // x=1 y=4 x+y=5
addTen(2); // x=10 y=2 x+y=12
addOne(56); // x=1 y=56 x+y=57
addTen(90); // x=10 y=90 x+y=100

function(y){...} 來說,x 是一個自由變數,是閉包效果保留下來而未被回收的變數。

自由變數:未在函式內部定義,卻在該函式中被使用的變數,對這個函式來說就是一個「自由變數(free variable)」。

從上面的程式碼可以看到,addOneaddTen 內部原本的參數 x 彼並不會互相影響的,它們在 addNum(1)addNum(10) 調用時分別形成,而且彼此獨立運作,毫無關聯。

這裡我們能夠總結,閉包是函式在調用時創造的,並且每次調用都會仿造函式內部狀態,創造獨立的作用域環境。而回傳給外部的函式,以及函式為它處理好的獨立作用域環境,就是所謂的「閉包」。


閉包的應用

這裡讓我們多看幾個例子:

setTimeout

function wait(message) {
    setTimeout(function timer() {
        console.log(message);
    }, 1000);
}

wait("Hello, closure!");

上面的函式中,等到 1000 毫秒計時完畢,整個程式理論上早就執行完並回收乾淨了。

但我們最後依然能夠看到 Hello, closure! 這行字,因為閉包將尚未執行的函式 timer 連同其作用域訪問權保留了下來,等到 1000 毫秒後打印出這行字。

for 迴圈

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

由於 let 製造了區塊作用域,console.log(i) 每次被丟入計時器等待執行時,都複製了 for 作用域當下的狀態,因此能夠依序跳出 12345


參考資料


上一篇
Day13 提升:為什麼要提升?留在原地不好嗎?
下一篇
Day15 模組:神奇而神祕的工具箱
系列文
就是要搞懂 JavaScript 啦!73
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言