JavaScript closure 的底層機制:function object、environment record 與 scope chain

文字解析 · 1 支影片 · 產生於 2026-10-01 00:54

🎧 語音解析📝 筆記✍️ 練習

🎧 語音解析 21m41s · TTS 講解與作者原聲交錯;點章節可跳。
邊聽邊看逐句講稿,點任一句從那裡開始
章節(13)

1. Outline

  1. 起點 · 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 好不好」,而是「你知不知道自己的函式抓住了周圍的哪些東西」。

推理鏈:每段留給下一段的線索

每一列是一段,箭頭後的字是這段留給下一段的線索。點段落標題跳到該段。

  1. 開場:closure 到底是什麼 → 留給下一段的問題是:既然一切要從引擎的記憶體結構談起,那就得先知道——每呼叫一次函式,引擎到底建立了什麼、又在函式結束時拆掉了什麼?
  2. 複習:執行環境與環境記錄 → 既然 execution context 只是持有 environment record 的參考,那下一段就要問:函式結束、context 被彈出 call stack 之後,那份 record 會怎麼樣?
  3. 正常情況:變數為什麼會消失 → 既然變數消失的唯一理由是「沒人參考那份 environment record」,那下一段就要找出:誰可以合法地成為第二個參考者?
  4. 第一步:巢狀函式帶著 environment 屬性 → 第一條參考建立好了,但作者立刻把問題丟回來:這條線的另一端 inner function object,自己又是被誰參考著的?
  5. 光有引用還不夠 → 缺的那一步因此變得非常明確:必須有一條從 outer 外面(例如全域)伸進來的參考,把 inner function object 釘在可達的範圍內——下一段要做的就是這件事。
  6. 把 inner 回傳出去:closure 成形 → closure 的結構成形了,但作者馬上追問一個結構圖回答不了的問題:這樣一份被保留的 environment record,在我們真的呼叫 innerFunc 的時候,引擎是怎麼從 inner 內部找到裡面的 count 的?
  7. 呼叫 closure:outer env 與 scope chain → 定義與用途都齊了,但「這條鏈是呼叫外層函式時才建立的」這個事實還沒被強調——下一段先把整套機制收成一句話,才好用它來檢驗。
  8. 總結 closure 的兩個條件 → 定義給完了,但定義最容易在一個地方被誤讀:那份被保留的 record 到底是什麼時候產生的?下一段作者用一個小測驗把這個問題逼出來。
  9. 小測驗:兩個 counter → 兩個 closure 已經各自成形,但真正要驗證的還沒發生:接連呼叫 counter1()、counter2()、counter1() 時,這三次呼叫各自沿著哪一條 [[OuterEnv]] 走?
  10. 解答:為什麼是 1、1、2 → 機制到這裡完全講完了,剩下的問題只有一個:這種「跨呼叫保留狀態」的能力,在真實的程式碼裡拿來做什麼?
  11. 用途一:memoization 的快取 → 「被 closure 釘住、永遠不會自己縮小」這句話對 cache 來說是功能,但同一句話換一個被釘住的東西就變成問題——下一段作者就把它翻到反面。
  12. 用途二的反面:意外的 closure → 同一條參考鏈既是 memoization 的功能,也是這裡的洩漏,所以真正的結論不會是「closure 好」或「closure 壞」——下一段作者要把它收束成一個開發者該有的習慣。
  13. 收尾:closure 與效能意識

3. 逐段說明

JavaScript Visualized - Closures

1. 開場:closure 到底是什麼 0:00–0:18

作者說明這支影片要回答的問題:closure 在幕後究竟是什麼。並先提醒觀眾,這支影片會假設你已經懂 execution context 與 environment record,建議先看前一支影片。

承上 前情提要裡被直接拿來當地基的是作者上一支影片的兩個概念:execution context 與 environment record。她開場就點名「我會假設你有這些基本知識」,等於宣告這支影片不從語法談 closure,而是從引擎的記憶體結構談起。

推理因為 closure 是那種「每天都在用、被問到卻講不清楚」的東西,所以作者把題目定義成 behind the scenes——不是問 closure 怎麼寫,而是問它在引擎裡實際上是什麼東西。也因為這個題目只能用記憶體結構回答,她必須先劃出前提,把不打算重講的部分明確排除掉。

AI 補充這裡值得先說清楚兩件事,後面每一步都建立在上面。第一,這支影片走的是規格層級(ECMAScript 規範)的模型,講的是 environment record、[[Environment]] 這些引擎內部的東西,不是 MDN 那種「函式記得它出生的地方」的比喻;比喻能讓你會用,模型才能讓你預測記憶體行為。第二,作者選的敘事順序是「先建立正常情況,再問怎麼打破它」,所以接下來會有一段看起來跟 closure 無關的複習——那不是離題,那是對照組。如果你只想要一句話的答案,可以先記著:closure 不是一個語法功能,而是一個「該被回收的東西沒被回收」的狀態。

closure

閉包

一個函式物件,加上一份本來該被回收、卻因為被它參考而保留下來的變數環境。

多數教材說「closure 是能記住外部變數的函式」,這是結果不是機制。從引擎的角度看,closure 不是一種特別的函式,而是一組參考關係造成的狀態:某個函式物件還活著,它又指著某個已結束函式的變數環境,於是那份環境跟著活下來。理解成「狀態」而不是「功能」有兩個好處:你會知道 closure 為什麼會佔記憶體,也會知道為什麼同一個函式呼叫兩次會得到兩個互不相干的 closure。這支影片整段推導,就是把這句話裡的每個零件一個一個裝上去。

相關術語: environment record (保留的對象)、garbage collection (對抗的機制)

出處:第 1 段「開場:closure 到底是什麼」

execution context

執行環境

引擎每次要執行一段程式碼(全域或一次函式呼叫)時建立的執行狀態容器。

它記錄「現在在執行誰、執行到哪、用哪一份變數環境」。全域程式碼有一個 global execution context,每呼叫一次函式就多一個 function execution context;它們疊在 call stack 上,函式回傳就被彈掉。要特別分清楚:execution context 是「這一次執行」的容器,函式本身(function object)是另一個獨立存在的物件,前者會消失,後者可能還活著——closure 能成立正是靠這個不對稱。

相關術語: environment record (持有其參考)、call stack (被推入的地方)

出處:第 1 段「開場:closure 到底是什麼」

environment record

環境記錄

實際存放某個 context 裡所有變數、參數、函式宣告的那份記憶體結構。

規範裡把「作用域」實作成一條 environment record 的鏈。每個 execution context 都有一份自己的 environment record,變數的值真正住在裡面;execution context 只是持有它的參考。這個分工是整支影片的關鍵:因為值住在 environment record 而不是 execution context 裡,所以只要有別人也參考著這份 record,execution context 消失了,值還在。

相關術語: execution context (被其持有)、closure (被保留的部分)

出處:第 1 段「開場:closure 到底是什麼」

留給下一段 留給下一段的問題是:既然一切要從引擎的記憶體結構談起,那就得先知道——每呼叫一次函式,引擎到底建立了什麼、又在函式結束時拆掉了什麼?

2. 複習:執行環境與環境記錄 0:18–0:59

每呼叫一次函式,就會產生一個新的 function execution context 和一個新的 function environment record。environment record 管理該 context 裡的識別字綁定;creation phase 先配置記憶體,execution phase 才把 context 推上 call stack 執行。

畫面上是 environment record 裡的變數綁定與記憶體配置示意,這是後面所有推論的底圖,沒看到這張圖就不知道『綁定』長什麼樣
0:45 · 畫面上是 environment record 裡的變數綁定與記憶體配置示意,這是後面所有推論的底圖,沒看到這張圖就不知道『綁定』長什麼樣
承上 上一段留下的問題是「每呼叫一次函式,引擎建立了什麼」。這段就是那份前提:答案是一個 function execution context 加一個 function environment record,成雙成對地產生。

推理因為後面要證明「某個東西不該活著卻活著」,所以作者必須先讓觀眾看清楚這個東西平常是怎麼生出來的。她刻意壓縮這段,只留三個會在後面被用到的事實:一次呼叫產生一對 context 與 record、record 管識別字綁定、creation phase 先配置記憶體 execution phase 才執行。

AI 補充畫面上那張圖值得多看兩眼,它是後面所有推論的底圖。outer Execution Context 裡包著 LexicalEnvironment,裡面才是 outer Environment Record,record 裡有一格 count,值寫著 uninitialized——這正是 creation phase 的樣子:格子已經開好,值還沒放進去。注意「先配置再執行」不是細節,它解釋了為什麼 let 宣告的變數在賦值前存取會拋 TDZ 錯誤:格子在(所以引擎知道有這個名字),值不在(所以不能用)。另外補一件作者沒明說但等一下會變得很重要的事:environment record 是被 execution context「參考」的,不是被它「包含」的——圖上畫成包含關係只是視覺上的方便。一旦你把它看成參考,就會自然想到一個問題:參考可以有很多個嗎?

identifier binding

識別字綁定

把一個名字(變數、參數、函式宣告)對應到一塊記憶體的那筆紀錄。

environment record 管理的就是一堆 identifier binding。畫面上 count 那一格加上底下的值,就是一筆 binding。用「綁定」而不是「變數」講有個好處:它強調名字與儲存空間是兩回事,所以同一個名字在不同的 record 裡可以是完全不同的兩塊記憶體——後面兩個 counter 各自有一份 count,靠的就是這件事。

相關術語: environment record (存放於其中)

出處:第 2 段「複習:執行環境與環境記錄」

creation phase

建立階段

execution context 建立時先掃過程式碼、替所有綁定配置記憶體的階段。

這是 hoisting 的真正來源:函式宣告在這時就整個備妥,var 被開好格子並填 undefined,let / const 也被開好格子但標成 uninitialized。所以「提升」提升的其實不是程式碼位置,而是綁定被建立的時機。畫面上 count 顯示 uninitialized,就是這個階段的快照。

相關術語: execution phase (接續於它)、identifier binding (為其配置空間)

出處:第 2 段「複習:執行環境與環境記錄」

execution phase

執行階段

context 被推上 call stack、程式碼一行行實際跑起來、值被填進綁定的階段。

creation phase 開格子,execution phase 填值,兩者是同一次函式呼叫的前後兩半。之所以要把它們拆開講,是因為後面的測驗會直接跳過 creation phase 說「直接看 execution phase,count 被初始化成 0」——如果沒先分清楚,會誤以為 count 是在函式被定義時就變成 0 的,其實是每次呼叫時才被填一次。

相關術語: creation phase (承接其配置)、call stack (在此被推入)

出處:第 2 段「複習:執行環境與環境記錄」

call stack

呼叫堆疊

以後進先出方式堆放所有 execution context 的結構,決定「現在正在執行誰」。

函式被呼叫就把它的 execution context 推上去,回傳就彈掉。這個「彈掉」是整支影片的起點:彈掉的是 execution context,不是函式物件,也不必然是 environment record。很多人把 call stack 想成「變數存放的地方」,其實堆疊上放的是執行狀態,變數住在 environment record 裡——這個區分決定了 closure 能不能成立。

相關術語: execution context (堆放的內容)

出處:第 2 段「複習:執行環境與環境記錄」

留給下一段 既然 execution context 只是持有 environment record 的參考,那下一段就要問:函式結束、context 被彈出 call stack 之後,那份 record 會怎麼樣?

3. 正常情況:變數為什麼會消失 0:59–1:37

正常情況下只有 function execution context 持有 environment record 的參考。context 被回收後,environment record 沒人參考,也一起被回收,裡面的變數就存取不到了。作者強調這很合理:outer 執行完就不該再留著 count,留著只是浪費記憶體。

作者在這裡把 execution context 和 environment record 一起劃掉/淡出,這個『被回收』的動畫就是 closure 要對抗的預設行為
1:17 · 作者在這裡把 execution context 和 environment record 一起劃掉/淡出,這個『被回收』的動畫就是 closure 要對抗的預設行為
承上 上一段問到 context 被彈出之後 record 會怎麼樣。這段給答案:正常情況下只有 execution context 參考著 environment record,所以 context 一被回收,record 沒人參考,也一起被回收。

推理因為作者要推導的是「例外」,所以她必須先把「常態」講到讓人覺得理所當然。她甚至多加一句價值判斷——outer 執行完就不會再用到 count,留著只是浪費記憶體——把回收說成合理的、應該的,這樣後面「讓它不被回收」才會顯得需要理由、需要條件,而不是隨便就能做到。

AI 補充畫面上這段動畫其實藏了一個很重要的細節:被打散成星塵飛走的是 outer Execution Context 和它的 environment record,但右邊那個 outer Function Object 完好無缺地留著。為什麼?因為全域的 environment record 裡有一筆 outer 綁定指著它,它有人參考,就不會被回收。這正是垃圾回收的判準:不是「執行完了就死」,而是「沒人參考才死」。作者這裡講的「只有 execution context 持有 record 的參考」是一句條件句,重點在那個「只有」——她其實已經在暗示破解方法了:只要多一個人參考它就好。整支影片接下來要做的事,就是想辦法製造出第二個參考者。

術語:reference

garbage collection

垃圾回收

引擎自動釋放「程式再也碰不到」的記憶體的機制。

JavaScript 的垃圾回收器不是靠計時或靠函式有沒有結束來決定回收,而是從一組根(global 物件、call stack 上的東西)出發往外走,走得到的留下,走不到的釋放。所以「函式執行完了」本身從來不是回收的理由,「沒有任何路徑能走到它」才是。影片後半段的記憶體警告能成立,也是因為這條規則反過來也成立:只要還有一條路走得到,再大的資料也不會被釋放。

相關術語: reference (以其判定存活)、environment record (回收的對象)

出處:第 3 段「正常情況:變數為什麼會消失」

reference

參考/引用

一個值指向記憶體中某個物件的連線,讓程式能經由它碰到那個物件。

在這支影片裡,「參考」是唯一的貨幣:所有圖上的箭頭都是參考,所有生死判斷都只看有沒有箭頭指進來。作者刻意不說「變數被複製進 closure」,因為根本沒有複製——closure 拿到的是同一份 environment record 的參考,所以兩個 closure 若指到同一份 record,看到的就是同一個變數,而不是各自的副本。

相關術語: garbage collection (決定其存活)

出處:第 3 段「正常情況:變數為什麼會消失」

留給下一段 既然變數消失的唯一理由是「沒人參考那份 environment record」,那下一段就要找出:誰可以合法地成為第二個參考者?

4. 第一步:巢狀函式帶著 environment 屬性 1:37–2:16

要保住 environment record,第一步是寫一個巢狀函式 inner。inner 的 function object 有一個內部屬性 environment,存的是它被定義時所在的 environment record 的參考,也就是 outer 的 function environment record。

這張圖畫出 inner function object 的 environment 屬性指向 outer environment record 的那條箭頭,是 closure 的第一條連線
2:06 · 這張圖畫出 inner function object 的 environment 屬性指向 outer environment record 的那條箭頭,是 closure 的第一條連線
承上 上一段的結論是「只要多一個人參考那份 environment record,它就不會被回收」。這段找出第一個候選人:定義在 outer 裡面的巢狀函式 inner,它的 function object 天生就帶著一條指回去的參考。

推理因為需要的是一個「本來就會指向 outer 那份 record」的東西,所以作者不去發明機制,而是指出引擎既有的一個內部屬性:每個 function object 建立時,都會把「我被定義在哪一份 environment record 裡」記進 [[Environment]]。這條線是免費的、宣告函式就有,作者只是把它挖出來給你看。

AI 補充看圖上的兩條箭頭,方向不一樣,別看混了。白色那條是 outer environment record 裡的 inner 綁定指向 inner Function Object(「outer 裡有一個叫 inner 的變數,值是這個函式」);紫色那條是 inner Function Object 的 [[Environment]] 指回 outer Environment Record(「這個函式是在那份環境裡被定義的」)。兩條線方向相反,構成一個環——這件事很關鍵,因為互相參考的環在垃圾回收裡不會互相救活對方,現代引擎用的是可達性而不是參考計數,所以整個環只要沒有外面的人指進來,還是會被整包回收。作者接下來就要說這件事。另外補一句她一句話帶過的重點:[[Environment]] 記的是「被定義的地方」,不是「被呼叫的地方」,這就是 JavaScript 採用語彙作用域而不是動態作用域的實作依據——你在讀程式碼時看到的巢狀結構,就是引擎最後串出來的那條鏈。

nested function

巢狀函式

定義在另一個函式主體裡面的函式。

巢狀是 closure 的必要條件之一:只有被定義在某個函式裡面,新的 function object 的 [[Environment]] 才會指到那個函式的 environment record。反過來說,寫在最外層的函式,它的 [[Environment]] 指的是 global environment record,那份 record 本來就永遠活著,所以不會產生「保留住已結束函式的環境」這種效果,也就不是一般在談的 closure。

相關術語: [[Environment]] (由巢狀決定)

出處:第 4 段「第一步:巢狀函式帶著 environment 屬性」

function object

函式物件

函式在記憶體裡的實體:一個帶有可呼叫能力與內部屬性的物件。

在 JavaScript 裡函式是值,所以它是一個物件,可以被指定給變數、當參數傳、被回傳。圖上它有 [[Call]](呼叫時要跑的程式碼)和 [[Environment]](出生地)兩個內部槽。要分清楚三個常被混為一談的東西:function object 是函式本身、execution context 是「某一次呼叫」、environment record 是那次呼叫的變數。同一個 function object 被呼叫十次,會產生十個 execution context 和十份 environment record,但 function object 從頭到尾只有一個。

相關術語: [[Environment]] (其內部屬性)、execution context (呼叫時產生)

出處:第 4 段「第一步:巢狀函式帶著 environment 屬性」

[[Environment]]

環境屬性(函式物件的內部槽)

function object 上的內部屬性,存著「這個函式被定義時所在的那份 environment record」的參考。

雙中括號代表這是規範定義的內部槽,程式碼碰不到它,只有引擎能用。它在 function object 建立的當下就被寫死,之後不會改變——不管這個函式後來被傳到哪裡、被誰呼叫,它記得的永遠是出生地。closure 之所以能「記住」變數,唯一的原因就是這個槽一直指著那份 record,讓垃圾回收器走得到它。

相關術語: function object (存在於其上)、environment record (指向的對象)

出處:第 4 段「第一步:巢狀函式帶著 environment 屬性」

留給下一段 第一條參考建立好了,但作者立刻把問題丟回來:這條線的另一端 inner function object,自己又是被誰參考著的?

5. 光有引用還不夠 2:16–2:44

現在 inner function object 與 outer environment record 之間有了參考,但這還不夠:outer 執行完後沒有任何人參考 inner function object,它會被回收;inner 一被回收,environment record 又沒人參考,也跟著被回收。

承上 上一段結束在一個反問:inner function object 自己被誰參考著?這段就是那個答案,而且是壞消息——沒有人。

推理因為上一段建立的參考鏈只在 outer 的內部繞圈,所以作者接著親手推翻自己剛提出的方案:outer 一結束,程式碼裡沒有任何地方碰得到 inner function object,它被回收;它一被回收,environment record 又回到沒人參考的狀態,也跟著被回收。她甚至加了一句「毫不留情」,強調引擎不會因為你們互相指著就手下留情。

AI 補充這是整支影片推導上最關鍵的一步,而它之所以成立,靠的是一個上一段圖上就看得到、但沒被點名的原則:可達性。inner 指著 record、record 裡的 inner 綁定又指著 inner,兩者互相參考,如果引擎用的是「數一數有幾個人指著我」的參考計數法,這個環會永遠活著,成為記憶體洩漏——早期 IE 的 DOM 循環參考就是這樣漏的。現代引擎改用標記清除:從 global 物件與 call stack 這些根出發,走得到才留下。outer 結束後,從根出發沒有任何一條路走得進這個環,於是整個環一起被清掉。這正好回答了「為什麼還要多做一步」:問題從來不是「有沒有人指著它」,而是「從外面走不走得到它」。

reachability

可達性

從一組根(全域物件、call stack 上的資料)出發,能否經由參考走到某個物件。

現代 JavaScript 引擎判斷垃圾的標準就是可達性,不是參考計數。差別在互相參考的情況:A 指 B、B 指 A,參考計數會認為兩者都還有人要,可達性則問「從根走不走得到 A 或 B」,走不到就整組回收。這解釋了為什麼上一段那條 [[Environment]] 的線本身救不了任何人,也解釋了 closure 為什麼一定要「把參考交到外面去」才會成立。反過來看,記憶體洩漏的定義也跟著清楚了:不是東西太大,而是你不再需要它,卻還留著一條從根走得到它的路。

相關術語: garbage collection (其判定標準)、reference (行走的路徑)

出處:第 5 段「光有引用還不夠」

留給下一段 缺的那一步因此變得非常明確:必須有一條從 outer 外面(例如全域)伸進來的參考,把 inner function object 釘在可達的範圍內——下一段要做的就是這件事。

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。

程式碼畫面:return inner 而不是 inner(),再指定給外層變數——這一行是整支影片的關鍵操作,沒看到程式碼會誤解成回傳呼叫結果
3:16 · 程式碼畫面:return inner 而不是 inner(),再指定給外層變數——這一行是整支影片的關鍵操作,沒看到程式碼會誤解成回傳呼叫結果
closure 成形的完整結構圖:outer 的 execution context 已消失、environment record 卻被保留,這張圖就是 closure 的定義
4:00 · closure 成形的完整結構圖:outer 的 execution context 已消失、environment record 卻被保留,這張圖就是 closure 的定義
承上 上一段指出缺的是「一條從 outer 外面伸進來、把 inner function object 釘住的參考」。這段就把它接上:從 outer 回傳 inner,指定給全域的 innerFunc。

推理因為需要的參考必須來自 outer 外面,而 JavaScript 裡函式是值,所以作者用最直接的方式製造它——把 inner 當作回傳值送出去,讓外面的變數接住。她特別停下來強調「我們回傳的是宣告本身,不是呼叫它」,因為這一個字之差(inner 和 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

function declaration

函式宣告

以 function 名稱() {} 形式寫下的函式定義,本身是一個值,不是一次呼叫。

作者強調 return inner 不是 return inner(),差別在於前者把 function object 這個值交出去,後者是先執行再把執行結果交出去。這是很多人第一次寫 closure 時踩的坑:一旦寫成 inner(),外面接到的是一個數字,inner function object 立刻沒人參考,整條鏈斷掉,closure 也就不存在了。順帶一提,函式宣告會在 creation phase 就被完整建立,所以在 outer 裡即使 return inner 寫在 function inner 前面也能運作。

相關術語: function object (求值得到它)

出處:第 6 段「把 inner 回傳出去:closure 成形」

retained environment record

被保留的環境記錄

所屬函式已經執行完畢、execution context 也已銷毀,卻因為仍被某個 function object 參考而沒有被回收的 environment record。

這是作者給 closure 下定義時用的說法,也是整支影片最精準的一句話:closure = function object + retained environment record。「被保留」三個字同時說明了兩件事——它本來應該死(所屬函式已結束),以及它為什麼沒死(有人從外面參考得到)。之所以值得單獨當一個術語記,是因為它把 closure 的成本也一併講明白了:被保留的是一整份 record,不是你用到的那個變數。

相關術語: closure (其組成之一)、reachability (保留的原因)

出處:第 6 段「把 inner 回傳出去:closure 成形」

留給下一段 closure 的結構成形了,但作者馬上追問一個結構圖回答不了的問題:這樣一份被保留的 environment record,在我們真的呼叫 innerFunc 的時候,引擎是怎麼從 inner 內部找到裡面的 count 的?

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 裡找到它。

outer env 把兩個 environment record 串成鏈的示意圖,scope chain 這個抽象詞在這裡第一次有具體形狀
4:50 · outer env 把兩個 environment record 串成鏈的示意圖,scope chain 這個抽象詞在這裡第一次有具體形狀
動畫演示 count 的查找路徑:先找 inner 的 record 找不到,再沿 outer env 找到被保留的 record,這條路徑是 closure 能用的原因
5:45 · 動畫演示 count 的查找路徑:先找 inner 的 record 找不到,再沿 outer env 找到被保留的 record,這條路徑是 closure 能用的原因
承上 上一段結束在「結構有了,那查值的時候引擎怎麼走」。這段給出另一半機制:呼叫 closure 時新的 environment record 會把 [[OuterEnv]] 設成 function object 的 [[Environment]],於是那條路真的被走出來。

推理因為前面只證明了「那份 record 沒被回收」,還沒證明「我們碰得到它」,所以作者必須補上查找機制。她的接法很漂亮:呼叫 inner 會產生一份全新的 inner environment record,而這份新 record 的 [[OuterEnv]] 不是憑空決定的,它抄自被呼叫的那個 function object 的 [[Environment]]——也就是說,第 4 段那條在定義時就寫死的線,到了呼叫時才被兌現成查找路徑。

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 從此就記著這個值。

[[OuterEnv]]

外層環境屬性(環境記錄的內部槽)

environment record 上的內部屬性,指向查找識別字時下一個該去問的 environment record。

它和 [[Environment]] 常被搞混,其實分工很清楚:[[Environment]] 長在 function object 上,記的是「我在哪裡被定義」;[[OuterEnv]] 長在 environment record 上,記的是「查不到時往哪裡找」。兩者的關係是:呼叫某個 function object 時,新產生的 environment record 的 [[OuterEnv]] 直接複製自那個 function object 的 [[Environment]]。所以定義時建立的關係,會在每一次呼叫時被重新兌現成一條查找路徑。global environment record 的 [[OuterEnv]] 是 null,那就是鏈的盡頭。

相關術語: [[Environment]] (值抄自它)、scope chain (串成它)

出處:第 7 段「呼叫 closure:outer env 與 scope chain」

scope chain

作用域鏈

由 [[OuterEnv]] 一層層串起來的 environment record 串列,識別字就沿著它由內往外查找。

作用域不是一個抽象的規則,而是這條實際存在的鏈:在目前的 record 找不到某個名字,就跳到 [[OuterEnv]] 指的下一份 record,一路找到 global record 為止,都沒有就丟 ReferenceError。理解成鏈之後,很多行為就不需要死記了——內層可以遮蔽外層的同名變數(因為先找到就停)、外層看不到內層(因為鏈是單向的)、closure 能用已結束函式的變數(因為那份 record 還在鏈上)。

相關術語: [[OuterEnv]] (由其串接)、lexical scope (它的實作)

出處:第 7 段「呼叫 closure:outer env 與 scope chain」

lexical scope

語彙作用域

一個名字看得到哪些變數,由它在原始碼中被寫在哪裡決定,而不是由誰呼叫它決定。

「lexical」指的就是「原始碼文字上的位置」。因為 [[Environment]] 在函式物件建立時就被寫死成「我被定義的那份 record」,而每次呼叫又把它抄進 [[OuterEnv]],所以不論這個函式被傳到多遠、被誰呼叫,它的查找路徑永遠是寫程式時看到的那個巢狀結構。它的對照組是動態作用域(查找路徑由呼叫者決定),JavaScript 只有 this 的行為比較接近那種風格,這也是為什麼 this 和 closure 常被放在一起比較——變數靠語彙決定,this 靠呼叫方式決定。

相關術語: scope chain (由它決定形狀)、[[Environment]] (它的實作依據)

出處:第 7 段「呼叫 closure:outer env 與 scope chain」

留給下一段 定義與用途都齊了,但「這條鏈是呼叫外層函式時才建立的」這個事實還沒被強調——下一段先把整套機制收成一句話,才好用它來檢驗。

8. 總結 closure 的兩個條件 5:54–6:22

closure 是 function object 加上透過 environment 屬性被保留的 environment record;呼叫它時這個被保留的環境是 scope chain 的一部分。發生條件:有巢狀函式,而且內層函式的參考被留在外層函式之外。

承上 上一段說要先把整套機制收成一句話,才好拿去檢驗。這段就是那句話: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

free variable

自由變數

在函式裡被使用、但不是它自己的參數或區域變數的變數。

closure 的傳統定義是「函式 + 它的自由變數環境」。在這支影片的例子裡,inner 用到的 count 就是 inner 的自由變數:inner 裡沒有 count 的綁定,所以它必須沿著 scope chain 去外面找。這個詞的價值在於它給了你一個快速判斷法——看一個函式用到哪些不屬於自己的名字,就知道它會把哪一層環境黏住。要注意 JavaScript 的實作是「整份 environment record 一起保留」,所以自由變數只是「你用到的」,被保留的往往比它多。

相關術語: scope chain (沿其查找)、closure (其定義要素)

出處:第 8 段「總結 closure 的兩個條件」

first-class function

一級函式

函式可以像一般的值一樣被指定給變數、當作參數傳遞、當作回傳值。

這是 closure 得以存在的語言層前提:如果函式不能被回傳,就不可能把內層函式的參考留到外層函式之外,第二個條件永遠不會成立。所以 closure 幾乎總是跟一級函式一起出現在同一批語言裡。反過來說,這也解釋了為什麼 closure 在 JavaScript 特別常見——回呼、事件監聽、Promise 的 then,全都是把函式當值傳來傳去,每一次都可能把某份環境留下來。

相關術語: function object (它的體現)、closure (它的前提)

出處:第 8 段「總結 closure 的兩個條件」

留給下一段 定義給完了,但定義最容易在一個地方被誤讀:那份被保留的 record 到底是什麼時候產生的?下一段作者用一個小測驗把這個問題逼出來。

9. 小測驗:兩個 counter 6:22–7:46

作者出一題:createCounter 被呼叫兩次,counter1、counter2 各呼叫幾次,會印出什麼?答案是 1、1、2。他先逐步演示兩次呼叫各自產生新的 execution context 與全新的 environment record,count 都從 0 開始,回傳 increment 後各自形成一個 closure。

測驗的程式碼畫面,不看程式碼就無法自己作答
6:52 · 測驗的程式碼畫面,不看程式碼就無法自己作答
兩個 closure 的 environment 屬性指向兩個不同 environment record 的對照圖,這是答案的關鍵
7:38 · 兩個 closure 的 environment 屬性指向兩個不同 environment record 的對照圖,這是答案的關鍵
承上 上一段留下的問題是「那份被保留的 record 什麼時候產生」。作者不直接回答,而是出一題 createCounter 呼叫兩次的測驗,讓你先用自己的理解算一次。

推理因為前面的定義都是用單一個 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

factory function

工廠函式

被呼叫時產生並回傳一個新物件或新函式的函式,每次呼叫的產物彼此獨立。

createCounter 就是典型的工廠函式:它每被呼叫一次,就配好一份新的 environment record(新的 count)並回傳一個綁著那份 record 的函式。這是 closure 最常見的實用形態——用「呼叫一次工廠 = 生出一份獨立狀態」取代 class 與 this。跟 class 相比,工廠函式做出來的狀態是真正外部拿不到的,因為那份 record 沒有任何語法能從外面存取,只有被回傳的那些函式碰得到。

相關術語: closure (產出它)、environment record (每次呼叫新配)

出處:第 9 段「小測驗:兩個 counter」

留給下一段 兩個 closure 已經各自成形,但真正要驗證的還沒發生:接連呼叫 counter1()、counter2()、counter1() 時,這三次呼叫各自沿著哪一條 [[OuterEnv]] 走?

10. 解答:為什麼是 1、1、2 7:46–8:45

兩個 closure 指向不同的 environment record——外層函式每呼叫一次就產生一個新的 record,也就產生一個新的 closure。counter1 第一次印 1,counter2 第一次也印 1(各自的 count 都從 0 起算),counter1 第二次沿著同一個 record 拿到 1 再加一,印 2。

counter1 第二次呼叫時沿 outer env 找回第一個 record、count 從 1 變 2 的動畫,直接解釋答案為何不是 1、1、1
8:38 · counter1 第二次呼叫時沿 outer env 找回第一個 record、count 從 1 變 2 的動畫,直接解釋答案為何不是 1、1、1
承上 上一段的問題是那三次呼叫各自沿著哪一條 [[OuterEnv]] 走。答案:counter1 的兩次都走向第一份 createCounter environment record,counter2 走向第二份,所以印出 1、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

encapsulation

封裝

把狀態藏在外部無法直接存取的地方,只透過指定的函式與它互動。

counter 例子裡的 count 就是被封裝的狀態:它住在 createCounter 的 environment record 裡,全域完全沒有任何名字指得到它,唯一的入口是被回傳的 increment。這是 JavaScript 在 class 私有欄位(#field)出現之前最主流的私有狀態做法,也是所謂模組模式的核心。跟用底線命名的「約定式私有」不同,closure 提供的是真正的存取隔離——不是別人不該碰,是別人碰不到。

相關術語: closure (實作手段)、factory function (常見載體)

出處:第 10 段「解答:為什麼是 1、1、2」

留給下一段 機制到這裡完全講完了,剩下的問題只有一個:這種「跨呼叫保留狀態」的能力,在真實的程式碼裡拿來做什麼?

11. 用途一:memoization 的快取 8:45–9:58

closure 擅長在多次呼叫之間保留狀態,可省記憶體、減少昂貴呼叫。memoize 裡的 cache 物件透過回傳函式的 environment 屬性被保留下來;每次呼叫回傳的函式,新的 environment record 以 memoize 的 record 為 outer env,因此仍能存取 cache,命中就直接回傳、沒命中才呼叫昂貴函式。

memoize 的程式碼與 cache 被 closure 保留的結構圖並排,說明快取存在哪裡
9:36 · memoize 的程式碼與 cache 被 closure 保留的結構圖並排,說明快取存在哪裡
承上 上一段結束在「跨呼叫保留狀態能拿來做什麼」。作者給的第一個答案是 memoization:把一份 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

memoization

記憶化

把函式的計算結果依參數存起來,之後同樣參數直接回傳舊結果,不再重算。

它是用記憶體換時間的典型手法,前提是同樣的輸入一定得到同樣的輸出。實作上一定要有一個「活得比單次呼叫久」的地方放結果,而 closure 正好提供了這種容器,所以兩者幾乎總是一起出現。要注意它跟一般意義的「快取」的差別:memoization 特指針對函式參數做的結果快取,而且通常不設過期時間,所以用在會變的資料上就是 bug 的來源。React 的 useMemo、Vue 的 computed 都是這個想法加上失效條件的版本。

相關術語: cache (使用它)、pure function (適用前提)

出處:第 11 段「用途一:memoization 的快取」

cache

快取

暫存已算過或已取得的結果,讓下次同樣的請求可以直接拿現成的。

影片裡的 cache 是一個普通物件,鍵是參數、值是結果。它之所以能跨呼叫存活,唯一的原因是被 closure 保留的那份 environment record 抓著;換句話說,這個 cache 的生命週期等於 memoizedFactorial 這個變數的生命週期。這也帶出快取設計最基本的兩個問題:什麼時候失效、以及最多能長多大。影片沒有處理這兩題,實務上通常會加上容量上限(LRU)或過期時間。

相關術語: retained environment record (存活於其中)

出處:第 11 段「用途一:memoization 的快取」

pure function

純函式

同樣的輸入永遠得到同樣的輸出,而且不對外界產生副作用的函式。

memoization 只在純函式上是安全的:不純的函式若被快取,第二次呼叫既拿到可能過期的結果,也不會再執行它的副作用(例如寫檔、發請求、改全域狀態)。影片裡拿 factorial 當例子並不是隨便選的——階乘是最標準的純函式。判斷可不可以 memoize,實務上就問兩句:它讀不讀外部會變的狀態?它做不做外部看得到的事?兩題都是否,才適合。

相關術語: memoization (它的前提)

出處:第 11 段「用途一:memoization 的快取」

留給下一段 「被 closure 釘住、永遠不會自己縮小」這句話對 cache 來說是功能,但同一句話換一個被釘住的東西就變成問題——下一段作者就把它翻到反面。

12. 用途二的反面:意外的 closure 9:58–10:55

closure 也可能不小心把大量資料留在記憶體裡。createUserManager 抓了幾萬筆 user data,回傳的 retrieve/update 兩個箭頭函式的 environment 屬性指向這個 environment record,即使它們根本沒用到那些資料,整包資料仍被留在記憶體,這時就該重構。

createUserManager 的 environment record 裡塞著整包 user data、被兩個回傳函式釘住的示意圖,是這段警告的具體畫面
10:32 · createUserManager 的 environment record 裡塞著整包 user data、被兩個回傳函式釘住的示意圖,是這段警告的具體畫面
承上 上一段結尾指出「被 closure 釘住、不會自己縮小」是 cache 的功能。這段把同一句話套到不想留的東西上:createUserManager 抓了幾萬筆 user data,回傳的函式把整份 environment record 一起釘住。

推理因為 closure 的成本和它的好處來自完全同一條參考鏈,所以作者不需要換一套機制來講風險,只要換一個被保留的內容就好。她挑的例子刻意做得無辜——retrieve 和 update 是兩個看起來很小的箭頭函式,卻讓一個非常大的物件永遠留在記憶體裡。

AI 補充632 秒那張圖把代價畫得很直白:右邊 createUserManager Environment Record 裡的 userData 被畫成一整面紅框的 user 方格牆,而左邊兩個小小的 update 和 retrieve function object,各自用 [[Environment]] 一條線把整面牆釘住。這裡最值得記住的是「顆粒度」問題:被保留的單位是整份 environment record,不是你用到的那幾個變數,所以函式再小也可能扣住整個作用域。另外補一個作者沒提、但同源的常見情境:把函式註冊成事件監聽器或 setInterval 回呼,只要沒有解除註冊,那條參考就一直從根走得到,該函式扣住的整份環境也就一直在——這是前端記憶體洩漏最典型的來源,而它跟這個例子是同一個機制。至於怎麼改:把要留的東西縮到最小(例如只把 user.id 或那筆 user 帶進回傳的函式,不要讓大陣列跟它們待在同一層),或多包一層函式,把大資料放在一個沒有任何回傳函式參考得到的作用域裡。

術語:module pattern

arrow function

箭頭函式

以 => 撰寫的函式語法,沒有自己的 this、arguments,但一樣是 function object。

作者特地說「它們是箭頭函式,但那不重要」,這句話值得展開:在 closure 這件事上箭頭函式跟一般函式完全一樣,一樣有 [[Environment]],一樣會扣住定義處的 environment record。箭頭函式真正不同的地方在 this——它不建立自己的 this,而是沿著 scope chain 往外拿,所以你可以說它把「this 也交給語彙作用域決定」。換句話說,箭頭函式讓 this 的行為變得跟變數一樣是語彙的,但它對變數捕獲的行為並沒有任何特殊之處。

相關術語: function object (一種)、lexical scope (this 也依它)

出處:第 12 段「用途二的反面:意外的 closure」

memory leak

記憶體洩漏

程式已經不再需要某塊記憶體,卻仍留著一條從根走得到它的參考,使它無法被回收。

在有垃圾回收的語言裡,洩漏不是「忘了釋放」,而是「忘了斷開參考」——判準永遠是可達性。closure 造成的洩漏特別隱蔽,因為那條參考是引擎自動建立的 [[Environment]],程式碼裡看不到。典型症狀是記憶體用量隨著操作單調上升而不回落;用瀏覽器 DevTools 的 Memory 面板拍快照,看某個大物件的保留路徑上出現 context 或 closure 字樣,通常就是這一類。

相關術語: reachability (洩漏的判準)、retained environment record (常見成因)

出處:第 12 段「用途二的反面:意外的 closure」

module pattern

模組模式

用一個函式包住私有狀態,回傳一個由多個函式組成的物件當作對外介面。

createUserManager 回傳 { retrieve, update } 就是這個模式。它成立的原因在前面已經鋪好:同一次外層呼叫回傳的多個函式,[[Environment]] 指的是同一份 environment record,所以它們天然共用同一份私有狀態。ES Modules 普及之前,這是 JavaScript 主要的封裝手段。它的代價也正是這一段的主題——共用的是整份 record,所以那份 record 裡不小心留下的大東西,會被所有介面函式一起扣住。

相關術語: encapsulation (實作它)、factory function (同一形態)

出處:第 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,因此只要有任一內層函式提到該變數就會整個保留)
留給下一段 同一條參考鏈既是 memoization 的功能,也是這裡的洩漏,所以真正的結論不會是「closure 好」或「closure 壞」——下一段作者要把它收束成一個開發者該有的習慣。

13. 收尾:closure 與效能意識 10:55–11:33

作者收尾:重點是理解函式如何與周圍的 environment record 互動,這對 app 效能很有幫助;平時要檢查有沒有意外的 closure 抓著大量資料。最後再複述一次 closure 的定義。

承上 上一段的結論是同一條參考鏈既能當快取也能造成洩漏。這段把它收成作者要留給觀眾的那個習慣:重點不是 closure 好不好,而是你知不知道自己的函式抓住了周圍的哪些東西。

推理因為前面兩個用途剛好是同一機制的正反兩面,所以作者不再加新知識,而是把整支影片的價值定位講明:理解函式如何與周圍的 environment record 互動,能實際幫到 app 的效能。她最後再複述一次 closure 的定義,讓機制與習慣一起被記住。

AI 補充把這支影片能帶走的東西整理成一組可以直接用的判斷。第一,看到巢狀函式被送到外面(回傳、放進陣列、註冊成監聽器、丟給計時器),就知道那一層的整份環境會跟著活下來,活多久等於外面那個參考活多久。第二,被保留的是整份 environment record,不是你用到的那幾個變數,所以要縮小成本就得縮小那一層作用域裡放的東西,而不是縮小函式。第三,要驗證而不是猜:DevTools 的 Memory 面板拍一張 heap snapshot,找到那個大物件,看 Retainers 面板上的保留路徑,如果路徑上出現 context 或 closure 之類的節點,就是被某個函式的環境扣住了。第四,把「closure 是什麼」和「這裡有沒有 closure」當成兩個不同的問題:前者已經有標準答案(function object 加上被保留的 environment record),後者要用第 8 段那兩個條件去對——有沒有巢狀函式,那個內層函式的參考是不是留在外層之外。

術語:retainer

heap snapshot

堆積快照

把某一瞬間記憶體中所有物件與它們之間的參考關係整份記錄下來的除錯工具輸出。

瀏覽器 DevTools 的 Memory 面板可以拍快照,也可以比較兩張快照之間新增了哪些物件。查記憶體洩漏的標準做法是:操作前拍一張、重複操作幾次、操作後再拍一張,比較差異,看該被釋放的東西還在不在。因為判準是可達性,快照能告訴你的不只是「什麼還在」,還有「為什麼還在」——那就是保留路徑。

相關術語: memory leak (用來診斷)、retainer (在其中查看)

出處:第 13 段「收尾:closure 與效能意識」

retainer

保留者

在 heap snapshot 中,那些參考著某個物件、使它無法被回收的其他物件。

DevTools 會把從根到目標物件的參考路徑列出來,這條路徑就是「它為什麼還活著」的完整答案。closure 造成的保留在這裡很好認:路徑上會出現標成 context 或 closure 的節點,代表某個函式的環境正抓著它。看懂 retainer 之後,這支影片講的模型就從理論變成可操作的工具——你在圖上看到的每一條箭頭,在真實工具裡都找得到對應。

相關術語: reachability (路徑的證據)、heap snapshot (呈現於其中)

出處:第 13 段「收尾:closure 與效能意識」

留給下一段 總結收束。

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 執行模型