💡本篇主題與重點字:**Closure**
昨天了解 EC 的原理以及變數是如何被 JS 程式所 track,今天可以來看其最大、最被廣泛應用、最有名的應用: 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
閉包引用,未被釋放!
為了能更正確釋放記憶體,最簡單的做法是在外部手動釋放
counter = null;
在更深入探討閉包可能造成的 memory leak 問題前,先來看一下閉包帶來的好處以及應用。
1. 封裝私有變數: 利用閉包模擬 private 屬性,避免外部直接訪問。以下的 🌰栗子🌰(?) 例子中,count
只能被 increment
和 getCount
閉包存取,外部無法直接改動。
2. 事件處理器綁定: 閉包記住了事件綁定當下的變數值,事件 callback function 透過閉包記住了綁定時 name
的值,點擊不同按鈕會分別跳出不同的 Alert 訊息。
3. 迴圈綁定處理: 避免迴圈變數在回呼函式中值錯亂
這邊有一個小陷阱,就是當我們使用 var
來宣告變數就會失敗(不如預期輸出 0, 1, 2),因為所有閉包共用同一個 i
(在全域作用域),最終 i = 3
。所以使用 let
可以限定 for loop 中的作用域(如上),或使用 IIFE 封裝資料與方法產生閉包。
IIFE (Immediately Invoked Function Expression,立即執行函式)是一個定義完後立刻執行的匿名函式,有其獨立作用域。
想玩看看以上範例的話,可以點Live code連結
4. 函式工廠: 回傳定制化函式makeAdder
返回的函式閉包住 x
,形成不同的加法器。
回到剛剛的第一個 demo:雖然outer
執行完畢,但count
仍被 inner
閉包引用,沒能被釋放的變數會造成 memory leak。或是若閉包長期存在(如綁定到 DOM、定時器、全域變數),其引用的作用域鏈就不會釋放,可能造成記憶體洩漏。
我們繼續來看可能的情況以及如何解決:
事件裡的 callback 匿名函式是閉包,引用了 data
。即使 bindEvent()
執行完,data
無法釋放,直到 el
被解除事件或 DOM 被銷毀。
在這個條件下,可以使用 el.removeEventListener()
在不需要時釋放閉包。
相同的情況,這邊的閉包引用 name
,而 setInterval
無限執行,閉包長期存在,name
永遠不會釋放。除了在適當時機使用 clearInterval
,也可以將 name
移至閉包內部,變數就僅在需要時產生。
畫面一轉,讓我們來看平常使用 React 框架實作時會遇到什麼樣的問題,以及如何避免。
已知 React 的設計幾乎全部的機制都是完全依賴 JavaScript 內建的特性,沒有任何不透明的魔法或特規的指令語法。 我們可以推論 React 的運作將可能高度依賴閉包,而閉包陷阱(Closure Traps)是很多中階 React 工程師升級時必須跨越的一道坎。這些陷阱會導致 state 永遠不更新、事件不響應甚至記憶體洩漏。
在 functional component 中,每次 render 都會建立新的函數作用域與新的閉包。因此 Hook callback 中引用的變數,預設是該 callback 被定義當下的值,而非最新值。
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]);
useEffect
或 useCallback
中依賴過時變數問題在於 user
是 state,但 effect 的依賴陣列是 []
,這樣 React 只會在第一次 mount 執行一次 effect,即使 user
之後更新了,也不會重新觸發,導致記憶體中 user
永遠是 null 或初始值,導致副作用錯誤或無效。
除了可以透過前面提到的 useRef
儲存最新 user,也可以透過正確設置依賴來解決,就改寫如下
當然也可以進一步設定 Linter 來提醒這種情況,免得我們不小心忘記: ESLint plugin for React Hooks(eslint-plugin-react-hooks
),它是 React 官方提供的工具,會針對 useEffect
、useCallback
、useMemo
等 hook 的依賴檢查給出警告。
這邊的問題在於即使元件卸載,閉包還存在記憶體中。若未清除,可能導致記憶體洩漏或錯誤。總之,當有使用 setInterval
確保搭配 clearInterval
釋放記憶體,並使用 useRef
儲存最新值。
let
、 const
來取代 var
removeEventListener
、 clearTimeout
)useRef
與函式型更新,就能避免大多數陷阱。
useEffect
、 useCallback
setX(prev => newValue)
useRef
把最新值記起來Closures | MDN
Day 6 :JavaScript 型別與他們的地雷(3):函式是一等公民
[Day 01] 前言:React 為什麼這麼難學的好?