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

閱讀時間約 13 分鐘

在 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

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

raw-image


  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

為了追求可以窩在座位上、可以心無旁騖思考問題、座位可以亂七八糟沒關係、不需要到處哈腰點頭跑客戶,不用腳踩十公分、連妝都可以不用化的職場人生,文組少女毅然決然踏上RD的養成日常。
留言0
查看全部
發表第一個留言支持創作者!
在之前的文章當中曾經提到過 JavaScript 中的物件有一個特別的機制:傳參考(Called by reference),如果正確性再高一點的話,則可以稱之為傳共享(Called by sharing)。
在先前的型別文章中,我們曾經聊過 JavaScript 常用的一些型別,但針對布林這個型別,我們沒有做太多的解釋,原因在於布林值在 JavaScript 會有一個特殊的規則:自動轉型 。 自動轉型可說是讓 JavaScript 為弱型別、且難以管理的最重要的要素,接著就來讓我們來聊聊什麼是自動轉型
在剛開始寫 JavaScript 可能大多數的人不會特別意識到 JavaScript 的型別系統有什麼特別之處,我是在看完 Youtube 上 CS50 的課程,才理解到在不同的程式語言中,會因為語言的特性而有不同的系統,JavaScript 就是偏向比較特別的那一種。
在前端開發中,很常會有需要轉址的需求,且處理的手法滿因人而異的,所以今天就想要來整理一些常見的 JavaScript 頁面轉址方式,以及各自的差異。
Functional Programming 中文譯作函式程式設計,或是功能性程式設計,常簡稱為:FP,是一種透過使用純函式(Pure Funciton)進行軟體開發,且避免副作用的程式設計典範,比起宣告式的流程控制,在 FP 採用主要以表達式的方式撰寫程式碼。
Hoisting 可以說是 ES6 問世之後,去面試還是會爾偶被問到的面試考題,雖然 Hoisting 離 Modern JavaScript 的技術有點距離,實作上幾乎不太會用到,但透過了解 Hoisting 的概念,可以對這門語言有更深的了解與掌握度。
在之前的文章當中曾經提到過 JavaScript 中的物件有一個特別的機制:傳參考(Called by reference),如果正確性再高一點的話,則可以稱之為傳共享(Called by sharing)。
在先前的型別文章中,我們曾經聊過 JavaScript 常用的一些型別,但針對布林這個型別,我們沒有做太多的解釋,原因在於布林值在 JavaScript 會有一個特殊的規則:自動轉型 。 自動轉型可說是讓 JavaScript 為弱型別、且難以管理的最重要的要素,接著就來讓我們來聊聊什麼是自動轉型
在剛開始寫 JavaScript 可能大多數的人不會特別意識到 JavaScript 的型別系統有什麼特別之處,我是在看完 Youtube 上 CS50 的課程,才理解到在不同的程式語言中,會因為語言的特性而有不同的系統,JavaScript 就是偏向比較特別的那一種。
在前端開發中,很常會有需要轉址的需求,且處理的手法滿因人而異的,所以今天就想要來整理一些常見的 JavaScript 頁面轉址方式,以及各自的差異。
Functional Programming 中文譯作函式程式設計,或是功能性程式設計,常簡稱為:FP,是一種透過使用純函式(Pure Funciton)進行軟體開發,且避免副作用的程式設計典範,比起宣告式的流程控制,在 FP 採用主要以表達式的方式撰寫程式碼。
Hoisting 可以說是 ES6 問世之後,去面試還是會爾偶被問到的面試考題,雖然 Hoisting 離 Modern JavaScript 的技術有點距離,實作上幾乎不太會用到,但透過了解 Hoisting 的概念,可以對這門語言有更深的了解與掌握度。
你可能也想看
Google News 追蹤
Thumbnail
這個秋,Chill 嗨嗨!穿搭美美去賞楓,裝備款款去露營⋯⋯你的秋天怎麼過?秋日 To Do List 等你分享! 秋季全站徵文,我們準備了五個創作主題,參賽還有機會獲得「火烤兩用鍋」,一起來看看如何參加吧~
Thumbnail
這篇文章更偏向純紀錄性質,方便日後有需要時直接複製相同指令來完成 Bootstrap 與 Sass 的引入,也會做成一個專案起手式的模板放在 Github ,未來在建置新專案時可以透過直接複製專案,來省去前面重複的這過程。
Thumbnail
在參加 2022 年六角學院舉辦的公益程式體驗營之後,我認知到一個專業具有就職水準的切版能力不是只是會 html、css 以及一些 css framework 就足夠,魔鬼藏在細節裡,而我想要朝專業級前進。 文章節錄當時的檢核點,作為日後開發的 check list。
Thumbnail
閉包(Closure)簡單來說就是一個 Function,應該說是 Function 內的 Function,而內層的變數記憶體不會被釋放,所以即使外部函式已經執行完畢,仍然能記住並存取它自己所在的外部函式的變數。
Thumbnail
微前端是一種現代前端架構,旨在將大型前端應用拆分為獨立、可獨立部署的小模組。這與微服務在後端的策略相似。 本文將探討微前端的基本概念,以及如何在基於Gin的後端系統中實施它,從而實現真正的全棧模組化。
Thumbnail
在 JavaScript ES6 之前,JavaScript 的函式主要是使用 function 關鍵字來定義的。而箭頭函式是 JavaScript ES6 中新增的功能,它提供了一種更簡潔的方式來定義函式。
Thumbnail
let 和 const 是 JavaScript 在 ES6 版本中新的變數宣告方式。使用 let 宣告的變數可以重新賦值,而使用 const 宣告的變數賦值後則不能改變。這兩種新的宣告方式提供了比 var 更嚴格和清晰的變數作用域管理。
Thumbnail
JavaScript 陣列的操作方法,forEach() 是用於遍歷陣列的每個元素,並對每個元素執行提供的函數,map() 是創建一個新陣列,其結果是對原陣列中的每個元素調用提供的函數後返回的結果。
Thumbnail
本文將介紹陣列的基本操作方法,包括建立陣列、存取元素、陣列遍歷和修改陣列等,接下來將逐一介紹這些操作,並附上程式碼範例,讓你更易於理解和運用。。
Thumbnail
JavaScript 陣列的操作方法,push() 可以將值加入到陣列的最後一個位置 ,pop() 會移除(取出)陣列的最後一個元素,shift() 會移除陣列的第一個元素,unshift() 則會將指定的元素添加到第一個位置。
Thumbnail
這個秋,Chill 嗨嗨!穿搭美美去賞楓,裝備款款去露營⋯⋯你的秋天怎麼過?秋日 To Do List 等你分享! 秋季全站徵文,我們準備了五個創作主題,參賽還有機會獲得「火烤兩用鍋」,一起來看看如何參加吧~
Thumbnail
這篇文章更偏向純紀錄性質,方便日後有需要時直接複製相同指令來完成 Bootstrap 與 Sass 的引入,也會做成一個專案起手式的模板放在 Github ,未來在建置新專案時可以透過直接複製專案,來省去前面重複的這過程。
Thumbnail
在參加 2022 年六角學院舉辦的公益程式體驗營之後,我認知到一個專業具有就職水準的切版能力不是只是會 html、css 以及一些 css framework 就足夠,魔鬼藏在細節裡,而我想要朝專業級前進。 文章節錄當時的檢核點,作為日後開發的 check list。
Thumbnail
閉包(Closure)簡單來說就是一個 Function,應該說是 Function 內的 Function,而內層的變數記憶體不會被釋放,所以即使外部函式已經執行完畢,仍然能記住並存取它自己所在的外部函式的變數。
Thumbnail
微前端是一種現代前端架構,旨在將大型前端應用拆分為獨立、可獨立部署的小模組。這與微服務在後端的策略相似。 本文將探討微前端的基本概念,以及如何在基於Gin的後端系統中實施它,從而實現真正的全棧模組化。
Thumbnail
在 JavaScript ES6 之前,JavaScript 的函式主要是使用 function 關鍵字來定義的。而箭頭函式是 JavaScript ES6 中新增的功能,它提供了一種更簡潔的方式來定義函式。
Thumbnail
let 和 const 是 JavaScript 在 ES6 版本中新的變數宣告方式。使用 let 宣告的變數可以重新賦值,而使用 const 宣告的變數賦值後則不能改變。這兩種新的宣告方式提供了比 var 更嚴格和清晰的變數作用域管理。
Thumbnail
JavaScript 陣列的操作方法,forEach() 是用於遍歷陣列的每個元素,並對每個元素執行提供的函數,map() 是創建一個新陣列,其結果是對原陣列中的每個元素調用提供的函數後返回的結果。
Thumbnail
本文將介紹陣列的基本操作方法,包括建立陣列、存取元素、陣列遍歷和修改陣列等,接下來將逐一介紹這些操作,並附上程式碼範例,讓你更易於理解和運用。。
Thumbnail
JavaScript 陣列的操作方法,push() 可以將值加入到陣列的最後一個位置 ,pop() 會移除(取出)陣列的最後一個元素,shift() 會移除陣列的第一個元素,unshift() 則會將指定的元素添加到第一個位置。