iT邦幫忙

2025 iThome 鐵人賽

DAY 2
0
Modern Web

JavaScript 進階修煉與一些 React ——離開初階工程師新手村的頭30天系列 第 2

離開 JS 初階工程師新手村的 Day 02|閉!殺!技!:閉包 Closure

  • 分享至 

  • xImage
  •  
💡本篇主題與重點字:**Closure**

昨天了解 EC 的原理以及變數是如何被 JS 程式所 track,今天可以來看其最大、最被廣泛應用、最有名的應用: Closure 閉包。對於新手工程師,可能僅有了解如何使用與大致的原理,這邊提供一些更深入的解釋,並盡可能點出開剛始接觸閉包時會有的盲點。

Closure 定義

即使函式在該作用域之外執行,函式可以記住並訪問其定義時的作用域。

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives a function access to its outer scope. In JavaScript, closures are created every time a function is created, at function creation time.
Closures - JavaScript | MDN

原理

每當 function 被創建時,它會同時創建一個與之相關的「閉包」,這個閉包會封存其當時的外部變數環境(Lexical Environment),並且持續引用這些變數,即使外部函式已經返回,這些變數仍然存在於記憶體中。

例如我們看到跟昨天類似的例子:outer() 執行後返回 inner() 函式,並將其賦值給 counter。此處的 inner 即是閉包,它記住了外部的 count 變數。即使 outer 執行完畢,其作用域中的 count 仍被 inner 閉包引用,未被釋放!

https://ithelp.ithome.com.tw/upload/images/20250911/20168365xBla1JJ0aB.png

為了能更正確釋放記憶體,最簡單的做法是在外部手動釋放

counter = null;

在更深入探討閉包可能造成的 memory leak 問題前,先來看一下閉包帶來的好處以及應用。

常見應用

1. 封裝私有變數: 利用閉包模擬 private 屬性,避免外部直接訪問。以下的 🌰栗子🌰(?) 例子中,count 只能被 incrementgetCount 閉包存取,外部無法直接改動。
https://ithelp.ithome.com.tw/upload/images/20250911/20168365wSi2KtOjVC.png

2. 事件處理器綁定: 閉包記住了事件綁定當下的變數值,事件 callback function 透過閉包記住了綁定時 name 的值,點擊不同按鈕會分別跳出不同的 Alert 訊息。

https://ithelp.ithome.com.tw/upload/images/20250911/20168365y5esEnzLmE.png

3. 迴圈綁定處理: 避免迴圈變數在回呼函式中值錯亂
https://ithelp.ithome.com.tw/upload/images/20250911/20168365rD0EfUemFE.png

這邊有一個小陷阱,就是當我們使用 var 來宣告變數就會失敗(不如預期輸出 0, 1, 2),因為所有閉包共用同一個 i(在全域作用域),最終 i = 3。所以使用 let 可以限定 for loop 中的作用域(如上),或使用 IIFE 封裝資料與方法產生閉包。

https://ithelp.ithome.com.tw/upload/images/20250911/20168365qjkDCAyFph.png

IIFE (Immediately Invoked Function Expression,立即執行函式)是一個定義完後立刻執行的匿名函式,有其獨立作用域。

https://ithelp.ithome.com.tw/upload/images/20250911/20168365Z8VAywUAyB.png

想玩看看以上範例的話,可以點Live code連結

4. 函式工廠: 回傳定制化函式
makeAdder 返回的函式閉包住 x,形成不同的加法器。
https://ithelp.ithome.com.tw/upload/images/20250912/20168365UmeffQpksu.png


記憶體管理與閉包造成的記憶體洩漏

回到剛剛的第一個 demo:雖然outer 執行完畢,但count 仍被 inner 閉包引用,沒能被釋放的變數會造成 memory leak。或是若閉包長期存在(如綁定到 DOM、定時器、全域變數),其引用的作用域鏈就不會釋放,可能造成記憶體洩漏。

我們繼續來看可能的情況以及如何解決:

範例 1:未釋放的 DOM 綁定造成洩漏

https://ithelp.ithome.com.tw/upload/images/20250912/20168365QCEz0Vr1QK.png

事件裡的 callback 匿名函式是閉包,引用了 data。即使 bindEvent() 執行完,data 無法釋放,直到 el 被解除事件或 DOM 被銷毀。

在這個條件下,可以使用 el.removeEventListener() 在不需要時釋放閉包。

範例 2:setInterval 與閉包導致洩漏

https://ithelp.ithome.com.tw/upload/images/20250912/20168365O6XtDcd99z.png

相同的情況,這邊的閉包引用 name,而 setInterval 無限執行,閉包長期存在,name 永遠不會釋放。除了在適當時機使用 clearInterval,也可以將 name 移至閉包內部,變數就僅在需要時產生。


畫面一轉,讓我們來看平常使用 React 框架實作時會遇到什麼樣的問題,以及如何避免。

已知 React 的設計幾乎全部的機制都是完全依賴 JavaScript 內建的特性,沒有任何不透明的魔法或特規的指令語法。 我們可以推論 React 的運作將可能高度依賴閉包,而閉包陷阱(Closure Traps)是很多中階 React 工程師升級時必須跨越的一道坎。這些陷阱會導致 state 永遠不更新、事件不響應甚至記憶體洩漏。

原理

在 functional component 中,每次 render 都會建立新的函數作用域與新的閉包。因此 Hook callback 中引用的變數,預設是該 callback 被定義當下的值,而非最新值。

常見陷阱

1. 事件處理器中的過時 state(Stale Closure)

https://ithelp.ithome.com.tw/upload/images/20250912/20168365t0rVYT57zV.png

handleClick 被綁定到 setInterval 時,閉包記住的是初始 count = 0,所以無論 render 幾次,setInterval 裡的 count 永遠是 0,導致 count 一直是 1。

我們可使用函式型更新避免閉包依賴

setCount(prev => prev + 1);

或是使用 useRef 去保存最新 state。在 callback 中使用 countRef.current 就可以提取最新的 state。

const countRef = useRef(count);
useEffect(() => {
  countRef.current = count;
}, [count]);

2. useEffect useCallback 中依賴過時變數

https://ithelp.ithome.com.tw/upload/images/20250912/20168365A6WPjSMZ9Q.png

問題在於 user 是 state,但 effect 的依賴陣列是 [],這樣 React 只會在第一次 mount 執行一次 effect,即使 user 之後更新了,也不會重新觸發,導致記憶體中 user 永遠是 null 或初始值,導致副作用錯誤或無效。

除了可以透過前面提到的 useRef 儲存最新 user,也可以透過正確設置依賴來解決,就改寫如下
https://ithelp.ithome.com.tw/upload/images/20250912/20168365DiiHVYeF0s.png

當然也可以進一步設定 Linter 來提醒這種情況,免得我們不小心忘記: ESLint plugin for React Hookseslint-plugin-react-hooks),它是 React 官方提供的工具,會針對 useEffectuseCallbackuseMemo 等 hook 的依賴檢查給出警告。

https://ithelp.ithome.com.tw/upload/images/20250912/20168365rY5I7YiWWg.png


3. 記憶體洩漏:未清理閉包引用

https://ithelp.ithome.com.tw/upload/images/20250912/20168365hAeuOL8tAE.png

這邊的問題在於即使元件卸載,閉包還存在記憶體中。若未清除,可能導致記憶體洩漏或錯誤。總之,當有使用 setInterval 確保搭配 clearInterval釋放記憶體,並使用 useRef 儲存最新值。


結語

  • 在寫 JavaScript 的時候
    • 首先避免使用全域變數,並總是使用 letconst 來取代 var
    • 使用 event listener 或 timer 要記得 reset ( removeEventListenerclearTimeout
    • 僅在必要的時候才使用閉包,如果有大資料要記得避免,免得閉包導致 memory leak
  • React Hooks 中閉包陷阱的本質是「render 時閉包封存的環境是靜態的」。深入理解這點,並搭配 useRef 與函式型更新,就能避免大多數陷阱。
    • 記得把依賴項寫入 useEffectuseCallback
    • 使用函式型更新 setX(prev => newValue)
    • 使用 useRef 把最新值記起來

引用

Closures | MDN
Day 6 :JavaScript 型別與他們的地雷(3):函式是一等公民
[Day 01] 前言:React 為什麼這麼難學的好?


上一篇
離開 JS 初階工程師新手村的 Day 01|冒險開始:執行上下文 Execution Context
下一篇
離開 JS 初階工程師新手村的 Day 03|找到真正的冒險者:this 是誰
系列文
JavaScript 進階修煉與一些 React ——離開初階工程師新手村的頭30天4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言