JavaScript closure 的底層機制:function object、environment record 與 scope chain
文字解析 · 1 支影片 · 產生於 2026-10-01 00:54
章節(13)
1. Outline
- 起點 · JavaScript Visualized - Closures
從引擎的記憶體結構出發,一步步推出 closure 是什麼:先看函式結束後環境為何消失,再找出誰能保住它,最後看查值時 scope chain 怎麼走,並用測驗與兩個真實用途檢驗。
2. YouTuber 的思維推導
JavaScript Visualized - Closures
Lydia Hallie · 11m33s · 字幕 en · vision=on (input 指定 vision=true;這支影片是逐格動畫講解,transcript 大量出現「here's a common example」「notice how they both point to」「in this case」等指著畫面的指示語,不看圖無法對上箭頭與記憶體結構。) · YouTube
預估 vs 實際耗時
| 階段 | 預估 | 實際 |
|---|---|---|
| fetch | 6s | 2s |
| segment | 53s | 5m00s |
| shot | 29s | 6s |
| analyze | 5m00s | 9m02s |
| render | 5s | – |
作者沒有一開始就給 closure 下定義,而是反過來從「變數為什麼會消失」出發,把 closure 當成一個要被推導出來的結論。她先把前一支影片的前提壓縮成一句話:每呼叫一次函式,就多一個 function execution context 和一個 function environment record,變數就住在後者裡。接著建立對照組——正常情況下只有 execution context 參考著 environment record,函式一結束,兩個一起被垃圾回收,變數自然消失,而且這很合理,不該白留在記憶體。
於是問題被改寫成一個工程題:要怎麼讓那個 environment record 不被回收?她的答案是一步一步加零件,而且每加一個就親手檢查夠不夠:第一步寫巢狀函式 inner,inner 的 function object 有 [[Environment]] 內部屬性,指著它被定義時所在的 environment record——這是第一條線,但不夠,因為 outer 一結束就沒人參考 inner,兩個一起陪葬。第二步才是關鍵:把 inner 回傳出去、指定給外層變數,讓參考從 outer 外面伸進來,這條鏈就從全域一路釘到 environment record,兩者都不會被回收。
到這裡她才給定義:function object 加上一個被保留的 environment record,就是 closure。但她沒有停在定義,而是繼續問「所以呢,這為什麼有用」,補上另一半機制:呼叫 closure 時新產生的 environment record,其 [[OuterEnv]] 會抄自 function object 的 [[Environment]],把一串 environment record 串成 scope chain,識別字查不到就往外找,因此 outer 早就結束了,count 還是找得到。定義與用途都到位後,她用一個 1、1、2 的小測驗逼出最容易誤解的一點:closure 指向的 environment record 是「呼叫外層函式的那一刻」才產生的,呼叫幾次就有幾個各自獨立的 closure。
最後她把同一個機制翻成正反兩面:memoization 靠它把 cache 留在多次呼叫之間,而 createUserManager 也靠它把幾萬筆 user data 釘在記憶體裡回不去——同一條參考鏈,是功能也是代價,所以收尾的重點不是「closure 好不好」,而是「你知不知道自己的函式抓住了周圍的哪些東西」。
推理鏈:每段留給下一段的線索
每一列是一段,箭頭後的字是這段留給下一段的線索。點段落標題跳到該段。
- 開場:closure 到底是什麼 → 留給下一段的問題是:既然一切要從引擎的記憶體結構談起,那就得先知道——每呼叫一次函式,引擎到底建立了什麼、又在函式結束時拆掉了什麼?
- 複習:執行環境與環境記錄 → 既然 execution context 只是持有 environment record 的參考,那下一段就要問:函式結束、context 被彈出 call stack 之後,那份 record 會怎麼樣?
- 正常情況:變數為什麼會消失 → 既然變數消失的唯一理由是「沒人參考那份 environment record」,那下一段就要找出:誰可以合法地成為第二個參考者?
- 第一步:巢狀函式帶著 environment 屬性 → 第一條參考建立好了,但作者立刻把問題丟回來:這條線的另一端 inner function object,自己又是被誰參考著的?
- 光有引用還不夠 → 缺的那一步因此變得非常明確:必須有一條從 outer 外面(例如全域)伸進來的參考,把 inner function object 釘在可達的範圍內——下一段要做的就是這件事。
- 把 inner 回傳出去:closure 成形 → closure 的結構成形了,但作者馬上追問一個結構圖回答不了的問題:這樣一份被保留的 environment record,在我們真的呼叫 innerFunc 的時候,引擎是怎麼從 inner 內部找到裡面的 count 的?
- 呼叫 closure:outer env 與 scope chain → 定義與用途都齊了,但「這條鏈是呼叫外層函式時才建立的」這個事實還沒被強調——下一段先把整套機制收成一句話,才好用它來檢驗。
- 總結 closure 的兩個條件 → 定義給完了,但定義最容易在一個地方被誤讀:那份被保留的 record 到底是什麼時候產生的?下一段作者用一個小測驗把這個問題逼出來。
- 小測驗:兩個 counter → 兩個 closure 已經各自成形,但真正要驗證的還沒發生:接連呼叫 counter1()、counter2()、counter1() 時,這三次呼叫各自沿著哪一條 [[OuterEnv]] 走?
- 解答:為什麼是 1、1、2 → 機制到這裡完全講完了,剩下的問題只有一個:這種「跨呼叫保留狀態」的能力,在真實的程式碼裡拿來做什麼?
- 用途一:memoization 的快取 → 「被 closure 釘住、永遠不會自己縮小」這句話對 cache 來說是功能,但同一句話換一個被釘住的東西就變成問題——下一段作者就把它翻到反面。
- 用途二的反面:意外的 closure → 同一條參考鏈既是 memoization 的功能,也是這裡的洩漏,所以真正的結論不會是「closure 好」或「closure 壞」——下一段作者要把它收束成一個開發者該有的習慣。
- 收尾:closure 與效能意識
3. 逐段說明
JavaScript Visualized - Closures
1. 開場:closure 到底是什麼 0:00–0:18
作者說明這支影片要回答的問題:closure 在幕後究竟是什麼。並先提醒觀眾,這支影片會假設你已經懂 execution context 與 environment record,建議先看前一支影片。
推理因為 closure 是那種「每天都在用、被問到卻講不清楚」的東西,所以作者把題目定義成 behind the scenes——不是問 closure 怎麼寫,而是問它在引擎裡實際上是什麼東西。也因為這個題目只能用記憶體結構回答,她必須先劃出前提,把不打算重講的部分明確排除掉。
AI 補充這裡值得先說清楚兩件事,後面每一步都建立在上面。第一,這支影片走的是規格層級(ECMAScript 規範)的模型,講的是 environment record、[[Environment]] 這些引擎內部的東西,不是 MDN 那種「函式記得它出生的地方」的比喻;比喻能讓你會用,模型才能讓你預測記憶體行為。第二,作者選的敘事順序是「先建立正常情況,再問怎麼打破它」,所以接下來會有一段看起來跟 closure 無關的複習——那不是離題,那是對照組。如果你只想要一句話的答案,可以先記著:closure 不是一個語法功能,而是一個「該被回收的東西沒被回收」的狀態。
2. 複習:執行環境與環境記錄 0:18–0:59
每呼叫一次函式,就會產生一個新的 function execution context 和一個新的 function environment record。environment record 管理該 context 裡的識別字綁定;creation phase 先配置記憶體,execution phase 才把 context 推上 call stack 執行。

推理因為後面要證明「某個東西不該活著卻活著」,所以作者必須先讓觀眾看清楚這個東西平常是怎麼生出來的。她刻意壓縮這段,只留三個會在後面被用到的事實:一次呼叫產生一對 context 與 record、record 管識別字綁定、creation phase 先配置記憶體 execution phase 才執行。
- CEnvironment record:實際存放變數、參數、函式宣告的記憶體結構,context 只是參考它→ 畫地圖
- Rcreation phase 跟 TDZ 的關係→ 存+回想
- Rcall stack 彈出的是什麼→ 存+回想
AI 補充畫面上那張圖值得多看兩眼,它是後面所有推論的底圖。outer Execution Context 裡包著 LexicalEnvironment,裡面才是 outer Environment Record,record 裡有一格 count,值寫著 uninitialized——這正是 creation phase 的樣子:格子已經開好,值還沒放進去。注意「先配置再執行」不是細節,它解釋了為什麼 let 宣告的變數在賦值前存取會拋 TDZ 錯誤:格子在(所以引擎知道有這個名字),值不在(所以不能用)。另外補一件作者沒明說但等一下會變得很重要的事:environment record 是被 execution context「參考」的,不是被它「包含」的——圖上畫成包含關係只是視覺上的方便。一旦你把它看成參考,就會自然想到一個問題:參考可以有很多個嗎?
3. 正常情況:變數為什麼會消失 0:59–1:37
正常情況下只有 function execution context 持有 environment record 的參考。context 被回收後,environment record 沒人參考,也一起被回收,裡面的變數就存取不到了。作者強調這很合理:outer 執行完就不該再留著 count,留著只是浪費記憶體。

推理因為作者要推導的是「例外」,所以她必須先把「常態」講到讓人覺得理所當然。她甚至多加一句價值判斷——outer 執行完就不會再用到 count,留著只是浪費記憶體——把回收說成合理的、應該的,這樣後面「讓它不被回收」才會顯得需要理由、需要條件,而不是隨便就能做到。
AI 補充畫面上這段動畫其實藏了一個很重要的細節:被打散成星塵飛走的是 outer Execution Context 和它的 environment record,但右邊那個 outer Function Object 完好無缺地留著。為什麼?因為全域的 environment record 裡有一筆 outer 綁定指著它,它有人參考,就不會被回收。這正是垃圾回收的判準:不是「執行完了就死」,而是「沒人參考才死」。作者這裡講的「只有 execution context 持有 record 的參考」是一句條件句,重點在那個「只有」——她其實已經在暗示破解方法了:只要多一個人參考它就好。整支影片接下來要做的事,就是想辦法製造出第二個參考者。
術語:reference
4. 第一步:巢狀函式帶著 environment 屬性 1:37–2:16
要保住 environment record,第一步是寫一個巢狀函式 inner。inner 的 function object 有一個內部屬性 environment,存的是它被定義時所在的 environment record 的參考,也就是 outer 的 function environment record。

推理因為需要的是一個「本來就會指向 outer 那份 record」的東西,所以作者不去發明機制,而是指出引擎既有的一個內部屬性:每個 function object 建立時,都會把「我被定義在哪一份 environment record 裡」記進 [[Environment]]。這條線是免費的、宣告函式就有,作者只是把它挖出來給你看。
- C[[Environment]]:function object 建立時寫死的內部槽,記著它被定義時所在的 environment record→ 畫地圖
- Rfunction object、execution context、environment record 的差別→ 存+回想
AI 補充看圖上的兩條箭頭,方向不一樣,別看混了。白色那條是 outer environment record 裡的 inner 綁定指向 inner Function Object(「outer 裡有一個叫 inner 的變數,值是這個函式」);紫色那條是 inner Function Object 的 [[Environment]] 指回 outer Environment Record(「這個函式是在那份環境裡被定義的」)。兩條線方向相反,構成一個環——這件事很關鍵,因為互相參考的環在垃圾回收裡不會互相救活對方,現代引擎用的是可達性而不是參考計數,所以整個環只要沒有外面的人指進來,還是會被整包回收。作者接下來就要說這件事。另外補一句她一句話帶過的重點:[[Environment]] 記的是「被定義的地方」,不是「被呼叫的地方」,這就是 JavaScript 採用語彙作用域而不是動態作用域的實作依據——你在讀程式碼時看到的巢狀結構,就是引擎最後串出來的那條鏈。
5. 光有引用還不夠 2:16–2:44
現在 inner function object 與 outer environment record 之間有了參考,但這還不夠:outer 執行完後沒有任何人參考 inner function object,它會被回收;inner 一被回收,environment record 又沒人參考,也跟著被回收。
推理因為上一段建立的參考鏈只在 outer 的內部繞圈,所以作者接著親手推翻自己剛提出的方案:outer 一結束,程式碼裡沒有任何地方碰得到 inner function object,它被回收;它一被回收,environment record 又回到沒人參考的狀態,也跟著被回收。她甚至加了一句「毫不留情」,強調引擎不會因為你們互相指著就手下留情。
AI 補充這是整支影片推導上最關鍵的一步,而它之所以成立,靠的是一個上一段圖上就看得到、但沒被點名的原則:可達性。inner 指著 record、record 裡的 inner 綁定又指著 inner,兩者互相參考,如果引擎用的是「數一數有幾個人指著我」的參考計數法,這個環會永遠活著,成為記憶體洩漏——早期 IE 的 DOM 循環參考就是這樣漏的。現代引擎改用標記清除:從 global 物件與 call stack 這些根出發,走得到才留下。outer 結束後,從根出發沒有任何一條路走得進這個環,於是整個環一起被清掉。這正好回答了「為什麼還要多做一步」:問題從來不是「有沒有人指著它」,而是「從外面走不走得到它」。
6. 把 inner 回傳出去:closure 成形 2:44–4:03
解法是從 outer 外面保住 inner function object 的參考:把 inner 回傳,指定給外層的變數 innerFunc。這樣 outer 執行完後,innerFunc 仍握著 function object,function object 又握著 outer 的 environment record,兩者都不會被回收。這個「function object + 被保留的 environment record」的組合就叫 closure。


推理因為需要的參考必須來自 outer 外面,而 JavaScript 裡函式是值,所以作者用最直接的方式製造它——把 inner 當作回傳值送出去,讓外面的變數接住。她特別停下來強調「我們回傳的是宣告本身,不是呼叫它」,因為這一個字之差(inner 和 inner())決定了外面接到的是函式物件還是一個數字,也就決定了整條參考鏈成不成立。
- C被保留的環境記錄:所屬函式已結束、context 已銷毀,卻因仍被某 function object 參考而沒被回收→ 畫地圖
- Rreturn inner 跟 return inner() 差一個字差很多→ 存+回想
AI 補充看 240 秒那張圖,把鏈條從左往右讀一次,就是 closure 的全貌:全域 environment record 裡的 innerFunc 綁定 → inner Function Object → 它的 [[Environment]] → outer Environment Record(count 是 0,整個被高亮框起來)。全域 record 永遠可達,所以這條路上的每一站都可達,沒有一站會被回收;而左邊的 outer Execution Context 已經整個暗掉不見了——execution context 死了,environment record 活著。這正是作者說「closure 特別的地方」時要你看的畫面。補一個她沒說、但常見的誤解:closure 保留的是「整份 environment record」,不是「count 這個變數的複本」。這代表兩件事——第一,如果 outer 裡還有別的變數,它們也一起被保留下來(影片最後的記憶體警告就是從這裡長出來的);第二,closure 看到的是變數的當下值而不是快照,誰改了 count,透過這份 record 看到的就是改過的值。另外提醒:`return inner;` 之後 outer 就結束了,但這不代表「回傳才會產生 closure」——真正的條件是那條參考有沒有留在外面,只是回傳是最常見的做法。
術語:function declarationretained environment record
7. 呼叫 closure:outer env 與 scope chain 4:03–5:54
呼叫 innerFunc 時會產生新的 execution context 與 environment record,後者的內部屬性 outer env 取自 function object 的 environment 屬性。outer env 把一連串 environment record 串成 scope chain:查不到的識別字就沿著鏈往外找。inner 裡沒有 count,於是在被保留的 outer environment record 裡找到它。


推理因為前面只證明了「那份 record 沒被回收」,還沒證明「我們碰得到它」,所以作者必須補上查找機制。她的接法很漂亮:呼叫 inner 會產生一份全新的 inner environment record,而這份新 record 的 [[OuterEnv]] 不是憑空決定的,它抄自被呼叫的那個 function object 的 [[Environment]]——也就是說,第 4 段那條在定義時就寫死的線,到了呼叫時才被兌現成查找路徑。
- CScope chain:呼叫時新 record 的 [[OuterEnv]] 抄自 [[Environment]],查不到就往外層找→ 畫地圖
- E290秒/345秒兩張圖:定義時的關係變成查找時真的被走過的路徑→ 存+演練
- R語彙作用域為什麼不是動態的→ 存+回想
AI 補充把 290 秒和 345 秒兩張圖連起來看,就能看到「兌現」的過程。290 秒:inner Environment Record 的 [[OuterEnv]] 格子裡填的是 outer Environment Record,而 outer Environment Record 自己的 [[OuterEnv]] 填的是 Global Environment Record——一條三站的鏈已經串好。345 秒:程式碼跑到 const incrementedCount = ++count,inner 的 record 裡只有 incrementedCount(還是 uninitialized),沒有 count,於是引擎沿著 [[OuterEnv]] 跳到 outer 那份 record,在那裡找到 count。這裡有兩個作者沒明講的重點。第一,查找是沿著 [[OuterEnv]] 走的,而 [[OuterEnv]] 又來自定義時寫死的 [[Environment]],所以作用域完全由程式碼的巢狀位置決定,跟誰在哪裡呼叫它一點關係都沒有——這就是語彙作用域的完整證明。第二,這條鏈是單向的:inner 找得到 outer 的東西,outer 找不到 inner 的東西,而且鏈的盡頭是 global record,再找不到就丟 ReferenceError。順帶一提,345 秒畫面上 outer 那份 record 的 count 還顯示 0,因為 ++count 還沒執行完;下一格畫面它才會變成 1,而且是永久地變成 1——那份 record 從此就記著這個值。
8. 總結 closure 的兩個條件 5:54–6:22
closure 是 function object 加上透過 environment 屬性被保留的 environment record;呼叫它時這個被保留的環境是 scope chain 的一部分。發生條件:有巢狀函式,而且內層函式的參考被留在外層函式之外。
推理因為前面的推導橫跨了結構(誰參考誰)與行為(怎麼查值)兩層,所以作者把兩層併成一句定義,再補上一條可操作的判準:有巢狀函式,而且內層函式的參考被留在外層函式之外。前者告訴你 closure 是什麼,後者讓你能在自己的程式碼裡指認出來。
AI 補充這兩個條件值得逐字檢查一次,因為它們同時也是最好的除錯清單。「有巢狀函式」對應到 [[Environment]] 會指向外層 record;「參考被留在外層函式之外」對應到可達性。缺前者,指的是 global record,本來就永遠活著,沒有保留可言;缺後者,整組互相參考的東西一起被回收。順帶把常見的誤解也用這兩個條件消掉:closure 不需要用 return,把 inner 塞進外部陣列、註冊成事件監聽器、丟給 setTimeout,都一樣是「參考留在外面」;closure 也不是「呼叫時才建立」,function object 一被建立,它與 environment record 的關係就定了,只是要等到外層結束、參考仍在,我們才會用 closure 這個名字稱呼它。最後補一個經典的說法對照:其他語言的教材會說 closure 是「函式加上它捕獲的自由變數」,那裡的『捕獲』在 JavaScript 裡就是這裡的 [[Environment]],而且是整份環境一起帶走,不是逐個變數複製。
術語:first-class function
9. 小測驗:兩個 counter 6:22–7:46
作者出一題:createCounter 被呼叫兩次,counter1、counter2 各呼叫幾次,會印出什麼?答案是 1、1、2。他先逐步演示兩次呼叫各自產生新的 execution context 與全新的 environment record,count 都從 0 開始,回傳 increment 後各自形成一個 closure。


推理因為前面的定義都是用單一個 outer/inner 講的,只看一次呼叫,很容易把 environment record 誤以為是跟函式綁在一起、只有一份。所以作者把外層函式呼叫兩次,讓兩個 closure 同時存在,答案是 1、1、2 而不是 1、2、3 或 1、1、1——這個結果只有在「record 屬於呼叫、不屬於函式」的模型下才算得對。
AI 補充先把題目自己算一次再往下看,這題的價值在於它能同時抓出兩種誤解。答 1、2、3 的人,是把 count 當成跟 createCounter 這個函式綁在一起的一份共用狀態;答 1、1、1 的人,是以為每次呼叫 counter1() 都重新跑一次 createCounter,count 又被初始化成 0。正確的模型在 458 秒那張圖上寫得很清楚:畫面標了 Closure 的那個框裡,increment Function Object 的 [[Environment]] 指著 createCounter Environment Record,而右邊另一個一模一樣的框指著另一份 record——同一個 increment 函式宣告,被執行兩次就產生了兩個不同的 function object,各自黏著各自那次呼叫的 record。這也解釋了 counter1 和 counter2 為何互不干擾。另外注意 412 秒那張圖上 global record 的 [[OuterEnv]] 是 null,那就是 scope chain 的盡頭。
術語:factory function
10. 解答:為什麼是 1、1、2 7:46–8:45
兩個 closure 指向不同的 environment record——外層函式每呼叫一次就產生一個新的 record,也就產生一個新的 closure。counter1 第一次印 1,counter2 第一次也印 1(各自的 count 都從 0 起算),counter1 第二次沿著同一個 record 拿到 1 再加一,印 2。

推理因為兩個 closure 的差別只在 [[Environment]] 指向不同的 record,所以作者把重點壓在那一句總結上——closure 指向的那份 outer environment record,是在呼叫外層函式的當下才產生的,而外層函式可以呼叫很多次,呼叫幾次就有幾個 closure。有了這句話,三次輸出就只是把同一條規則套三遍。
AI 補充518 秒那張圖是整題的答案:counter1 第二次呼叫時,新的 increment Environment Record 的 [[OuterEnv]] 指的還是第一份 createCounter Environment Record,而那份 record 裡的 count 已經是 1(不是 0),所以 ++count 之後印出 2。這裡有個容易滑過去的細節值得補:每次呼叫 counter1() 都會產生一份全新的 increment environment record(用完就被回收),被保留下來、跨呼叫累積的只有 createCounter 那一份。換句話說,closure 保留的是外層的環境,不是內層的環境。另外,程式碼寫的是 return ++count 而不是 count++,前置遞增先加再取值,所以第一次就印 1;如果寫成 count++ 就會印出 0、0、1。最後把這一段的結論擴大一句:既然一份 record 對應一次外層呼叫,那麼「多個 closure 是否共用狀態」這件事就完全由它們是不是在同一次外層呼叫裡被建立來決定——同一次呼叫裡回傳的兩個函式會共用同一份 record,這正是稍後那個回傳 retrieve 與 update 的例子成立的前提。
術語:encapsulation
11. 用途一:memoization 的快取 8:45–9:58
closure 擅長在多次呼叫之間保留狀態,可省記憶體、減少昂貴呼叫。memoize 裡的 cache 物件透過回傳函式的 environment 屬性被保留下來;每次呼叫回傳的函式,新的 environment record 以 memoize 的 record 為 outer env,因此仍能存取 cache,命中就直接回傳、沒命中才呼叫昂貴函式。

推理因為 counter 的例子只證明了「狀態能留下來」,還沒說明留下來有什麼價值,所以作者換一個一眼就看得出效益的場景。memoize 的結構跟 outer/inner 完全一樣,只是把 count 換成 cache 物件——她刻意保持結構相同,好讓你把注意力放在「留下來的東西可以是任何值」這件事上。
AI 補充看 576 秒那張圖,右邊 memoize Environment Record 裡就是那個 cache,畫成一個被高亮的空盒子;左邊 memoizedFunction Environment Record 裡只有參數 number(值是 10),它的 [[OuterEnv]] 指向 memoize 的 record——所以 cache[number] 這一行的 number 在自己的 record 找得到,cache 找不到,得沿鏈往外拿。這正是第 7 段那條查找路徑,只是換了一組名字。有三件作者沒說、但決定這個模式好不好用的事。第一,快取必須住在 memoize 的 record 而不是 memoizedFunction 的 record,因為後者每次呼叫都是全新的一份,放在那裡等於沒有快取。第二,這個模式只對純函式安全:如果 expensiveFunction 的結果會隨時間或外部狀態改變,或者它有副作用,快取就會回傳過期的答案、也會吃掉副作用。第三,cache[number] 用的是物件屬性,鍵會被轉成字串,所以 memoizedFactorial(10) 和 memoizedFactorial("10") 會撞在同一格;改用 Map 可以避開,代價是要自己管容量——因為這個 cache 會隨著不同參數一直長大,而且被 closure 釘住,永遠不會自己縮小。
術語:memoization
12. 用途二的反面:意外的 closure 9:58–10:55
closure 也可能不小心把大量資料留在記憶體裡。createUserManager 抓了幾萬筆 user data,回傳的 retrieve/update 兩個箭頭函式的 environment 屬性指向這個 environment record,即使它們根本沒用到那些資料,整包資料仍被留在記憶體,這時就該重構。

推理因為 closure 的成本和它的好處來自完全同一條參考鏈,所以作者不需要換一套機制來講風險,只要換一個被保留的內容就好。她挑的例子刻意做得無辜——retrieve 和 update 是兩個看起來很小的箭頭函式,卻讓一個非常大的物件永遠留在記憶體裡。
- C記憶體洩漏:不是東西太大,是不再需要卻還留著一條從根走得到它的參考→ 畫地圖
- EcreateUserManager:兩個小箭頭函式釘住整面 user 資料牆→ 存+演練
- R箭頭函式在 closure 上跟一般函式一樣→ 存+回想
AI 補充632 秒那張圖把代價畫得很直白:右邊 createUserManager Environment Record 裡的 userData 被畫成一整面紅框的 user 方格牆,而左邊兩個小小的 update 和 retrieve function object,各自用 [[Environment]] 一條線把整面牆釘住。這裡最值得記住的是「顆粒度」問題:被保留的單位是整份 environment record,不是你用到的那幾個變數,所以函式再小也可能扣住整個作用域。另外補一個作者沒提、但同源的常見情境:把函式註冊成事件監聽器或 setInterval 回呼,只要沒有解除註冊,那條參考就一直從根走得到,該函式扣住的整份環境也就一直在——這是前端記憶體洩漏最典型的來源,而它跟這個例子是同一個機制。至於怎麼改:把要留的東西縮到最小(例如只把 user.id 或那筆 user 帶進回傳的函式,不要讓大陣列跟它們待在同一層),或多包一層函式,把大資料放在一個沒有任何回傳函式參考得到的作用域裡。
術語:module pattern
「we're not actually using any of this data in The Returned functions」
13. 收尾:closure 與效能意識 10:55–11:33
作者收尾:重點是理解函式如何與周圍的 environment record 互動,這對 app 效能很有幫助;平時要檢查有沒有意外的 closure 抓著大量資料。最後再複述一次 closure 的定義。
推理因為前面兩個用途剛好是同一機制的正反兩面,所以作者不再加新知識,而是把整支影片的價值定位講明:理解函式如何與周圍的 environment record 互動,能實際幫到 app 的效能。她最後再複述一次 closure 的定義,讓機制與習慣一起被記住。
AI 補充把這支影片能帶走的東西整理成一組可以直接用的判斷。第一,看到巢狀函式被送到外面(回傳、放進陣列、註冊成監聽器、丟給計時器),就知道那一層的整份環境會跟著活下來,活多久等於外面那個參考活多久。第二,被保留的是整份 environment record,不是你用到的那幾個變數,所以要縮小成本就得縮小那一層作用域裡放的東西,而不是縮小函式。第三,要驗證而不是猜:DevTools 的 Memory 面板拍一張 heap snapshot,找到那個大物件,看 Retainers 面板上的保留路徑,如果路徑上出現 context 或 closure 之類的節點,就是被某個函式的環境扣住了。第四,把「closure 是什麼」和「這裡有沒有 closure」當成兩個不同的問題:前者已經有標準答案(function object 加上被保留的 environment record),後者要用第 8 段那兩個條件去對——有沒有巢狀函式,那個內層函式的參考是不是留在外層之外。
術語:retainer
4. 總結
作者刻意不從語法定義 closure,而是從「函式結束後變數為什麼會消失」開始:正常情況下只有 execution context 參考 environment record,context 一被回收,record 也一起消失。既然消失的唯一理由是沒人參考,那問題就變成「誰能當第二個參考者」。第一個候選人是巢狀函式 inner——它的 function object 天生帶著 [[Environment]] 指回定義它的 environment record。但作者立刻推翻自己:inner function object 本身沒人參考,一樣會被回收,連帶 record 也保不住。缺的那一步因此非常明確——要有一條從外層函式外面伸進來的參考,於是 return inner 並指定給全域變數,closure 就成形了:function object 加上一份已經執行完卻被保留下來的 environment record。結構有了還不夠,呼叫時引擎怎麼找到值?答案是新的 environment record 把 [[OuterEnv]] 設成 function object 的 [[Environment]],串成 scope chain,查不到就往外找。接著用 createCounter 的測驗驗證一個容易誤讀的點:被保留的 record 是在每次呼叫外層函式時才產生的,所以兩個 counter 各有各的 count,答案是 1、1、2。最後同一條參考鏈被翻到兩面:memoization 靠它把 cache 留在多次呼叫之間,而 createUserManager 也靠它把幾萬筆資料釘在記憶體裡回收不掉。結論不是 closure 好或壞,而是你要知道自己的函式抓住了周圍的什麼。
勘誤總整理
確定錯誤/已過時見仁見智(取決於版本或情境)
| 段落 | 原話(transcript 逐字) | 說明 |
|---|---|---|
| 12. 用途二的反面:意外的 closure 10:38 |
「we're not actually using any of this data in The Returned functions」 | 兩點要保留。第一,畫面上的程式碼其實有用到:retrieve 回傳 ({ ...user })、update 做 Object.assign(user, info),兩者都參考了 user,而 user 是從 userData 裡 find 出來的一個元素——沒用到的是 userData 這個大陣列本身,不是「這些資料」。第二,也更重要:規範層級的模型確實會把整份 environment record 保留下來,但 V8 實際上只把「有內層函式參考到」的變數放進 context,沒被任何內層函式提到的 userData 通常不會被 context 化,也就不會因為這兩個箭頭函式而留在記憶體裡。所以這個例子作為「closure 可能扣住大資料」的示意是對的,但按圖上的程式碼在 V8 跑,userData 未必真的洩漏。要穩定重現這個問題,得讓某個被回傳的函式真的提到 userData。 依據: ECMA-262 的 Function Environment Record 模型 vs. V8 context allocation(V8 只為被內層函式參考的變數配置 context slot;同一 scope 的所有內層函式共用一個 context,因此只要有任一內層函式提到該變數就會整個保留) |
5. 推薦三個下一步
1. 往下挖深:環境記錄與 scope chain 的規格細節
影片刻意跳過 environment record 的種類(declarative / object / global)與 creation phase 到底做了什麼,而 TDZ、hoisting、var 與 let 的差別全都落在這個缺口裡。
YouTube 搜尋:JavaScript execution context environment record Lydia Hallie JavaScript Visualized execution context temporal dead zone hoisting explained ECMAScript lexical environment spec
2. 往旁邊對照:非同步與事件迴圈裡的 closure
這支影片的 closure 全都是同步呼叫。callback、Promise、setTimeout 裡的函式同樣抓著 environment record,但存活時間由事件迴圈決定,經典的迴圈變數陷阱就出在這裡。
YouTube 搜尋:JavaScript Visualized Promise execution JavaScript event loop visualized closure in loop var let setTimeout
3. 往上應用:用 DevTools 找出真正的記憶體洩漏
影片最後只說「你可能要重構一下」,但沒示範怎麼確認自己真的有意外 closure。heap snapshot 的 retainer 樹就是把這套理論拿去驗證的地方。
YouTube 搜尋:Chrome DevTools memory leak heap snapshot find detached DOM closure retainer JavaScript memory profiling tutorial
- 相關 —JavaScript Visualized - Event Loop, Web APIs, (Micro)task Queue · 同一系列的視覺化,都從 call stack 與 execution context 出發,先看誰都行
- 接著看 ←9 JavaScript Concepts That Got Me To Senior Dev · 九個概念只點到 closure 與 scope chain,這支把同一組詞拆到記憶體層級
- 接著看 →Senior Frontend Interview 2026 🎉 | Javascript 🎯 (Mock) [Most Asked Questions] · 懂了 [[Environment]] 與 scope chain,再去看面試題怎麼考 closure
- 相關 —Reactivity in Vue 3 - How does it work? · Vue 的 effect 就是靠 closure 抓住依賴,這裡先懂它怎麼被保留
- 相關 —JavaScript Visualized - Promise Execution · 同系列視覺化,都把 execution context 拆開看內部欄位,先看誰都行
- 接著看 ←JavaScript Visualized - Execution Contexts · 共用 9 個術語;closure 在這站只是一條 environment 指標,那站專門把它講到底
- 相關 —RxJS operators and their dangerous consequences · 同樣是「沒人用了卻還活著」的記憶體洩漏,一個出在 closure,一個出在 shareReplay