2024-02-01|閱讀時間 ‧ 約 33 分鐘

【前端基礎】JavaScript 中的閉包是什麼?React 閉包應用情境

在 2021 年的剛轉職成為前端工程師的時候,我在面試時滿常會被詢問到 JavaScript 中閉包的議題,當時候自己回答的滿差的,於是在 2022 年時,我寫了一系列的有關於函式程式設計鐵人賽的文章, 裡頭就有簡單提到有關於閉包的議題。

過了一年回過頭看,覺得當初還是有些東西寫得不夠完整,所以想要針對閉包再完整的複習一次,也順便加深一下自己的印象(這種東西真的平常都在用,但要有脈絡性的回答還是會有點卡)。

希望這篇文章不是一篇很死板、單純是為了準備面試題的「標準答案」,而是能從 JavaScript 這門語言當中深入的切入,與帶到實務上的應用情境,這些應用情境主要會是以 React 框架或是純 JavaScript 為主,本篇文章主要會以幾個角度深入淺出解釋閉包的運作:

  1. JavaScript 中調用函式的生命週期
  2. 呼叫堆疊(Call stack)的運作機制
  3. JavaScript 中函式的記憶體運作機制
  4. 閉包是什麼?為什麼需要閉包?
  5. JavaScript 中的閉包應用情境
  6. React 中的閉包應用情境

那麼就讓我們開始吧!

JavaScript 中調用函式的生命週期

不知道大家有沒有想過當函式被呼叫的時候大致上會經歷什麼過程呢?假設我們有個函式:

const printName = (name) => console.log(name);

printName('Vvn');

在瀏覽器端的光是這兩行程式碼就會經歷以下生命週期:

  1. 宣告階段(Declaration Phase):在這階段,JavaScript 引擎讀取程式碼並創建所有的變數和函式。在這個例子中,JavaScript 引擎會創建一個名為 printName 的變數,並且分配給它一個箭頭函式 (name) => console.log(name)。這個階段發生在任何程式碼被執行之前。
  2. 初始化階段(Initialization Phase):在這階段,所有的變數和函式都會被初始化。對於 constlet 宣告的變數,他們在這階段會被初始化為 undefined,等待後續賦值。對於這個例子來說,printName 在宣告的同時也已經被初始化為指定的函式。
  3. 執行階段(Execution Phase):在這階段,JavaScript 引擎會按照程式碼的順序來執行所有的代碼。在這個例子中,當遇到 printName('Vvn')printName 函式就會被調用,並傳入一個字符串 'Vvn' 作為參數。
  4. 調用階段(Invocation Phase):這是函式被調用的階段。在 printName 函式中,它接收一個參數 name,並且將這個參數傳入 console.log() 函式,這將導致 'Vvn' 字符串被印出到控制台。
  5. 結束階段(Termination Phase):當所有的程式碼都已經被執行完畢,JavaScript 程式就會結束。

以上是在執行函式時,會在 JavaScript 引擎(看是在瀏覽器還是 Node.js 裡)中經歷的生命週期,在執行階段時 JavaScript 引擎其實還會進行一個更複雜的行為,稱為事件循環(Event Loop)。

不過要了解閉包的運作機制不太需要理解一整個事件循環的機制,我們會從事件循環中的其中一個機制:呼叫堆疊(Call stack)開始說起。

呼叫堆疊(Call stack)的運作機制

呼叫堆疊顧名思義有「堆疊」二字,所以就是演算法中「後進先出」的概念,要以具象化的說明這個現象的話,就有點像是洗衣籃,每當我們回家的時候就會把一件髒衣服丟進洗衣籃,等到要洗衣服的時候,只能由上至下一件件拿出來洗,由於洗衣籃只有一個開口,所以只能依照這個順序來洗衣服。

JavaScript 的呼叫堆疊會出現在函式被呼叫、調用的時候,被調用的函式會被丟進呼叫堆疊,直到函式結束完才會離開這個堆疊,舉例來說:

function second () {
console.log('second Function starts');
console.log('second Function ends');
};

function first() {
console.log('first Function starts')
second();
console.log('first Function ends')

};

first();

各位可以想想,如果套用呼叫堆疊的概念,這些程式碼會怎麼樣被印出呢?答案會是:

// first Function starts
// second Function starts
// second Function ends
// first Function ends

當只有一層函式的時後,呼叫堆疊非常好理解,但如果包成一層又一層就會需要思考到底執行的順序是什麼了,用圖像化的方式來看會是長這樣:


  1. 當我們呼叫 first 函式時,這個函式就進入的呼叫堆疊中。
  2. 由於我們在 first 函式中呼叫了 second 函式,此時呼叫堆疊中就有了兩個函式,最下面的是 first 函式, first 函式上又堆疊了 second 函式。
  3. second 函式結束,離開呼叫堆疊
  4. first 函式結束,離開呼叫堆疊

這個範例中並沒有用到變數,不過這裡要繼續說明,因為函式的運作機制會是「執行完畢」才離開呼叫堆疊,此時函式所有用到的變數也會因而被釋放記憶體掉。

換句話說,如果這個執行堆疊一直沒有結束,在這個呼叫堆疊中的函式,都可以取用到呼叫堆疊中的變數,酷吧?理解到這裡我們可以總結一下 JavaScript 中函式記憶體運作的機制。

JavaScript 中函式的記憶體運作機制

函式在被調用時,也就是在上方所提到的生命週期的調用階段,會產生一個執行環境(Execution Context),這個執行環境會包含在函式中被宣告的變數、被傳入的參數,以及使用到的函式。

當這個執行環境中的函式一一被執行完後,就會依序由後進先出離開呼叫堆疊,離開呼叫堆疊的函式所用到的記憶體,也會依照後進先出的方式被釋放掉,在大部分情況下,這些記憶體會被垃圾回收機制(Garbage Collector,後續簡稱 GC)釋放掉,以釋放記憶體空間。

在這個過程中,當呼叫堆疊的深度(也就是 Call Stack 中函式的數量)超出了 JavaScript 引擎所設定的限制時,會產生一個 「RangeError: Maximum call stack size exceeded」的錯誤,這種情況通常被稱為 Stack Overflow,現在知道某知名論壇的名稱來由是什麼了吧!

但這裡有個例外,就是閉包!讓我們來看看閉包如何解決 Stack Overflow 的狀態吧。

閉包是什麼?為什麼需要閉包?

閉包是,透過在回傳函式引用內部被宣告的變數、被傳入的參數,以及使用到的函式,記住引用變數、參數狀態的一種函式。

舉例來說:

function createGreeting(name) {
return function() {
console.log("Hello, " + name);
}
}

let greetJohn = createGreeting("John");
greetJohn(); // logs "Hello, John"

在上面的例子中,createGreeting 函式回傳了一個新的函式,這個新的函式可以取用到、訪問到(visit) createGreeting 函數的 name 參數。即使 createGreeting 函數已經離開呼叫堆疊,並且它的作用域已經算是「離開」了,被回傳的函式仍然可以訪問 name 參數及當時的作用域,這就是閉包的運作原理,而 name 參數因為被引用到,所以也不會被 GC 清理掉。

小結一下,閉包有幾個重要的特色:

  1. 數據隱藏和封裝:閉包可以用來隱藏和保護變數,防止它們被外部程式碼修改。
  2. 創建函式工廠:像上面的 createGreeting 函數一樣,我們可以創建一個函式,回傳另一個可以訪問其參數的函式(希望改天可以來深入聊聊所謂的工廠模式,但不是這篇文章的重點,所以先略過)。
  3. 創建有狀態的函式:閉包可以用來儲存和操作它們有所相關環境中的變數,這些被創建的函式就可以擁有自己的狀態。

這大致上就是閉包的概念了,不過要注意的是,使用太多的閉包,會造成大量變數因為被函式引用而無法被 JavaScript 釋放記憶體,會造成所謂記憶體洩漏(Memory Leak)的狀況,所以評估可以評估必要性後再來使用。

到這裡閉包的介紹告一個段落,不過這麽乾說可能還是會對閉包有點陌生,讓我們接著來看一些實務上的應用範例:

JavaScript 中的閉包應用情境

在 JavaScript 中有許多閉包的應用,不過我認為在 ES6 以後比較不會出現全域污染的情境後,就比較少出現需要將本地變數私有化的狀況,取而代之會是一些 UI 元件開發上比較常應用,關於這一點會在後續的應用情境提到。

除此之外,在過去的開發經驗當中,我認為柯理化(Currying)會是一個很好的應用方式,也有許多函式庫透過這樣的方式讓函式的使用效率變的更高,有興趣了解柯里化應用的人,可以參考我以前寫的這一篇文章

React 中的閉包應用情境

在 React 中就有比較多有趣的範例可以列舉了,有兩個 Hoooks 就是使用使用閉包的方式來取得內部狀態的,分別為 useStateuseEffect

  • useState 中的閉包:
const [count, setCount] = useState(0);

// 可以在 useState 傳入 Callback,使用第一個參數取得前一個狀態值
setCount((prev) => prev + 1 );
  • useEffect 中的閉包:
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(currentCount => {
console.log(currentCount); // This will log the current count
return currentCount + 1;
});
}, 1000);

return () => clearInterval(id);
}, []);

透過在 useEffect 的第一個 Callback 函式再回傳一個函式,也可以透過閉包的方式記憶住當前 Callback 中的內部變數,透過這樣的方式在下一次渲染畫面時,就可以將計時器清空,避免記憶體洩漏。

除了 React Hooks 外,整個 Functional Component 也使用了閉包來記憶狀態,例如:外部傳入的 Prop、內部變數、函式,這些要素被傳遞至子元件,就會因為閉包的特性記憶住作用域與變數,子元件才可以取到值:

import { Button } from './Button';

const ExampleComponent = ({ value }) => {
const handleClick = () => {
console.log("Clicked value: ", value);
}

return <Button onClick={handleClick}>Click me</Button>;
}

從載入父元件到載入子元件,到兩者生命週期結束,就是一個 Call stack 的開始堆疊到結束堆疊:

// first component starts
// second component starts
// second component ends
// first component ends

看到這邊會不會覺得其實自己平常在開發時,早就用到一堆閉包了呢?自己在重新研究這個議題的過程中,才發現原來看似複雜的 React 本質上就是使用大量的閉包在進行內部狀態的管理,真的滿有趣的!

希望這篇文章可以用不同的角度來重新認識閉包,如果有任何問題再麻煩各路大神指教,我是 Vivian,我們下次見!


參考資料:

致 JavaScript 開發者的 Functional Programming 新手指南 - Day 20 :什麼是 Currying(2)?JavaScript Call Stack

分享至
成為作者繼續創作的動力吧!
從 Google News 追蹤更多 vocus 的最新精選內容從 Google News 追蹤更多 vocus 的最新精選內容

作者的相關文章

Vivian Yeh - 跨領域轉職的軟體工程師 的其他內容

你可能也想看

發表回應

成為會員 後即可發表留言
© 2024 vocus All rights reserved.