這是完整的一篇超長文章,內容為javascript V8引擎的 詞法分析 語法分析 編譯 執行 優化 等完整的一個鏈條,內容詳略得當 可以按需要部分閲讀 也可以通篇仔細觀看。
依舊是無圖無碼,網文風格。我覺得,能用文字把邏輯或者概念表述清楚,一是對作者本身的能力提升有好處,二是對讀者來説 思考文字表達的內容 有助於多使用抽象思維和邏輯思維能力,構建自己的思考模式,用現在流行的説法 就是心智模型。你自己什麼都可以腦補,那不是厲害大了嘛。
上面的話不要相信,其實我就是為自己懶找的藉口。
這部分內容,能學習瞭解,當然最好,對平時的前端開發,也有好處,不瞭解,也不影響日常的工作。但是總體來説,很多開發中的問題,在這部分內容中 都可以找到根源。有些細節做了省略 有些邊界情況做了簡化表述。不過 , 準確性還是相當不錯的。依舊是力求高準確性,符合規範,貼合實現。
篇幅比較長,可以按需要閲讀,內容鏈條如下:
1識別-2流式處理-3切分-4預解析和全量解析-5解析概述-6解析具體過程.表達式的解析-7聲明的解析-8函數的解析-9變量的解析-10類的解析-11語句的解析
其中包含單個完整的知識點分散在各部分:閉包 作用域 作用域鏈/樹 暫時性死區。。。可搜索關鍵字查找。
版權聲明呢。。。碼字不易,純腦力狂暴輸出更不易
歡迎以傳播知識為目的全文轉載,謝絕片段摘錄。 謝絕搞私域流量的轉載。
一.詞法分析和語法分析
當瀏覽器從網絡下載了js文件,比如app.js,瀏覽器引擎拿到的最初形態是一串**字節流 **。
-
識別:瀏覽器根據 HTTP 響應頭,通常是
Content-Type: text/javascript; charset=utf-8將下載的字節流解碼為字符流並交給 V8。V8 在內存中存儲字符串時採用動態編碼策略:在可行的情況下優先使用單字節(Latin-1)格式存儲,只有當字符串中出現 Latin-1 範圍外的字符(如中文、Emoji)時,才會轉為雙字節(UTF-16)格式。 -
流式快速處理: 引擎並不是等整個文件下載完才開始幹活的。只要網絡傳過來一段數據,V8 的掃描器就開始工作了。 這樣可以加快啓動速度。此時的狀態就是毫無意義的字符
c,o,n,s,t,,a,,=,,1,;... -
然後的這一步叫 Tokenization 詞語切分。 負責這一步的組件就是上面提到的叫 Scanner(掃描器)。它的工作就像是一個切菜工,把滔滔不絕連綿不斷的字符串切成一個個有語法意義的最小單位,叫做 Token(記號)。看到這個詞 ,大家是不是驚覺心一縮,沒錯,就是它,它們就是以它為單位來收咱錢的。
scanner 內部是一個狀態機。它逐個讀取字符:
- 讀到
c可能是const,也可能是變量名,繼續。 - 讀到
o,n,s,t湊齊了5個娃,且下一個字符不是字母(比如是空格),確認這是一個關鍵字 const。”(防止誤判constant這種變量名) - 讀到
空格 忽略,跳過去。 - 讀到
1這是一個數字。
這樣就由原來的字節流變成了 Token 流。這是一種扁平的列表結構。
- 源碼:
const a = 1; - Token 流:
CONST(關鍵字)IDENTIFIER(值為 "a")ASSIGN(符號 "=")SMI(小整數 "1")SEMICOLON(符號 ";")
這一步,註釋和多餘的空格和換行符會被拋棄。
- 讀到
-
現在就是解析階段了
其實解析是一個總稱,它分為 全量解析 和 預解析 兩種形式。
這就是v8的懶解析機制。看到這個懶字,也差不多能明白了吧。
對於那些不是立即執行的函數(比如點擊按鈕才觸發的回調),V8 會先用預解析快速掃一遍。
檢查基本的語法錯誤(比如有沒有少寫括號),確認這是一個函數。並不會生成複雜的 AST 結構,也不建立具體的變量綁定,只進行最基礎的閉包引用檢查。御姐喜的結果是這個函數在內存裏只是一個很小的佔位符,跳過內部細節。
而只有那些立即執行函數或者頂層代碼,才會進入真正的全量解析,進行完整的 AST 構建。
那麼,問題就來了,v8怎麼判斷到底是使用預解析還是使用全量解析呢?
它的原則就是 懶惰為主 全量為輔
就是v8默認你寫的函數暫時不會執行,除非是已經顯式的通過語法告訴它,這段這行代碼 馬上就要跑 你趕快全量解析。
下面 我們稍微詳細的説一下
-
默認絕大多數函數都是預解析
v8認為js在初始運行時,僅僅只有很少很少一部分代碼 是需要馬上使用的 其他覺得大部分 都是要麼是回調 要麼是其他的暫時用不到的,所以,凡是具名函數聲明、嵌套函數,默認都是預解析。
function clickHandler() { console.log("要不要解析我"); } // 引擎認為 這是一個函數聲明 看起來還沒人調勇它 // 先不浪費時間了,只檢查一下括號匹配吧, // 把它標記為 'uncompiled',然後跳過。" -
那麼 如何才能符合它進行全量解析的條件呢
-
頂層代碼
寫在最外層 不在任何函數內 的代碼,加載完必須立即執行。
判斷依據: 只要不在
function塊裏的代碼,全是頂層代碼,必須全量解析。 -
立即執行函數
那麼這裏有個問題,就是V8 如何在還沒運行代碼時,就知道這個函數是立即調用執行函數呢?
答案就是 看括號()
當解析器掃描到一個函數關鍵字
function時,它會看一眼這個 function 之前有沒有左括號(-
沒括號
function foo() { ... } // 沒看到左括號,那你先靠邊吧, 對它預解析。 -
有括號
(function() { ... })(); // 掃描器掃到了這個左括號 // 欸,這有個左括號包着 function // 根據萬年經驗,這是個立即執行函數,馬上就要執行。 // 直接上大菜,全量解析,生成 AST -
其他的立即執行的跡象:除了括號,
!、+、-等一元運算符放在function前面,也會觸發全量解析!function() { ... }(); // 全量解析
-
-
除了這些以外, v8還有一些啓發式的規則來觸發全量解析。比如 如果是體積很小的函數,V8 有時也會直接全量解析,因為預解析再全量解析的開銷可能比直接解析還大。。。等等。
-
-
如果有嵌套函數咋辦呢
嵌套函數默認是預解析,即使外部函數進行的是全量解析,它內部定義的子函數,默認依然是預解析。只有當子函數真的被調用時,V8 才會暫停執行,去把子函數的全量解析做完 把 AST 補齊
//頂層代碼全量解析 (function outer() { var a = 1; // 內部函數 inner: // 雖然 outer 正在執行,但 inner 還沒被調用 // 引擎也不確定 inner 會不會被調用。 // 所以inner 默認預解析。 function inner() { var b = 2; } inner(); // 直到執行到這一行,引擎才會回頭去對 inner 進行全量解析 })(); -
那麼 引擎根據自己的判斷 進行全量解析或者預解析,會出錯嗎
當然會,
如果是本該預解析的 結果判斷錯了 進行了全量解析 浪費了時間和內存生成了 AST 和字節碼,結果這代碼根本沒跑。
如果是本該全量解析的又巨又大又重的函數 結果判斷錯了 進行了預解析,然後馬上下一行代碼就調用了,結果就是 白白預解析了一遍,浪費了時間,發現馬上被調用,又馬上回頭全量解析一邊 又花了時間,兩次的花費。
-
-
在上面只是講了解析階段的預解析和全量解析的不同,現在我們講解析階段的過程
V8 使用的是遞歸下降分析法。它根據js 的語法規則來匹配 Token。
它的規則類似於:當我們遇到
const,根據語法規則,後面必須跟一個變量名,然後是一個賦值號,然後是一個表達式。過程示例:
看到
const創建一個變量聲明節點。看到
a把它作為聲明的標識符。看到
=知道後面是初始值。看到
1創建一個字面量節點,掛在=的右邊。而在這個階段的同時,作用域分析也在同步進行,因為在構建 AST 的過程中,解析器必須要搞清楚變量在哪裏。
它會盤算 這個
a是全局變量,還是函數內的局部變量?如果當前函數內部引用了外層的變量,解析器會在這個階段打上標記:“要小心,這個變量被逮住了,將來可能需要上下文來分配”。
這個作用域分析比較重要,我們用稍微大點的篇幅來講講。
首先 強烈建議 不要再去用以前的 活動對象AO vo 等等的説法來思考問題。應該使用現在的詞法作用域 環境記錄 等等思考模型。
詞法作用域 (Lexical Scoping)” 的定義:作用域是由代碼書寫的位置決定的,而不是由調用位置決定的。
這説明,引擎在還沒開始執行代碼,僅僅通過“掃描”源代碼生成 AST 的階段,就已經把“誰能訪問誰”、“誰被誰逮住”這筆賬算得清清楚楚了。
一旦AST被生成,那麼至少意味着下面的情況
作用域層級被確定
AST 本身的樹狀結構,就是作用域層級的物理體現。
- AST 節點: 當解析器遇到一個
function關鍵字,它會在 AST 上生成一個FunctionLiteral節點。 - Scope 對象: 在 V8 內部,隨着 AST 的生成,解析器會同時維護一棵 “作用域樹”。
- 每進入一個函數,V8 就會創建一個新的
Scope對象。 - 這個
Scope對象會有一個指針指向它的Outer Scope父作用域。
- 每進入一個函數,V8 就會創建一個新的
- 結果: 這種“父子關係”是靜態鎖定的。無論你將來在哪裏調用這個函數,它的“父級”永遠是定義時的那個作用域。
變量引用關係被識別
這是解析器最忙碌的工作之一,叫做 變量解析。
- 聲明: 當解析器遇到
let a = 1,它會在當前 Scope 記錄:“我有了一個叫a的變量”。 - 引用: 當解析器遇到
console.log(a)時,它會生成一個 變量代理。 - 鏈接過程: 解析器會嘗試“連接”這個代理和聲明:
- 先在當前 Scope 找
a。 - 找不到?沿着 Scope Tree 往上找父作用域。
- 找到了?建立綁定。
- 一直到了全局還沒找到?標記為全局變量(或者報錯)。
- 先在當前 Scope 找
這裏要注意: 這個“找”的過程是在編譯階段完成的邏輯推導。
閉包的藍圖被預判
這一步是 V8 性能優化的關鍵,也就是作用域分析。
-
發現閉包: 解析器發現內部函數
inner引用了外部函數outer的變量x。 -
打個大標籤:
- 解析器會給
x打上一個標籤:“強制上下文分配”。 - 意思是:“雖然
x是局部變量,但因為有人跨作用域引用它,所以它不能住在普通的棧(Stack)上了... 必須搬家,住到堆(Heap)裏專門開闢的 Context(上下文對象) 中去。”
- 解析器會給
-
還沒有實例化:
-
此時內存裏沒有上下文對象,也沒有變量
x的值(那是運行時的事)。 -
AST 只是生成了一張“藍圖”,圖紙上寫着:“注意,將來運行的時候,這個
x要放在特別的地方 - Context裏,別放在棧上。”
-
- AST 節點: 當解析器遇到一個
-
現在 我們來複一下盤 重點學習解析過程
字節流---被切成有語法意義的最小單元token---成為token流---解析階段(進行預解析或者全量解析)---得到AST和作用域樹和變量引用關係 這就是我們第一部分所講的詞法分析和語法分析的內容。
因為這部分比較重要,所以我們將繼續深入的學習一下。。。反正學都學了 要學還不趁機多學點,所以 前面的內容 只是開胃菜 驚不驚喜 意不意外 .
其實,是因為在整個鏈條中,從開始到AST生成,是一個較為完整的獨立的小階段。此時,僅僅是靜態分析過程完成。
從整個流程來看, AST生成,表示物理層級確定 作用域鏈構建完成,閉包藍圖依託作用域鏈 變量路徑引用依託作用域鏈,甚至連棧和context中的位置分配都有了藍圖。 所以 重點了解這部分內容,也是獲得感滿滿了。
下面 我們來重點學習解析的過程。
上面講了解析的過程叫 遞歸下降分析法 聽起來是不是很高大上,其實 它還有個小名,叫“層層甩鍋工作法”。
-
解析器有兩大神技,這兩大神技,是它的最大倚仗
-
提前偷看 Lookahead
它處理當前token時,總是喜歡盯着下一個( 甚至下幾個),比如 當它手裏拿着const了,然後它提前偷看後面的 欸 是個 a, 那就沒錯 這把穩了,是個變量聲明。
這個神技,有個比較正規的名字 叫前瞻 lookahead。
當解析器在解析某句或某段代碼時,是解析器中的某一個解析函數在工作,很有可能是被上面層層甩鍋甩下來的,輪到這個解析函數時,很大的可能,是這句或這段代碼的解析,就屬於它的本職工作,它按照自己的解析流程判斷邏輯,來使用前瞻技能,預判下一個token是否符合它的工作邏輯需求。
-
消費 consume 當確認這個當前的 Token 沒問題,就把它“吃掉”,consume 即消費掉,同時指針移動,指向下一個token,準備處理下一個。
比如 當前指針指着const,它偷看後面的,是個a,它就確定 符合它變量聲明的崗位的判斷邏輯,於是,它就吃掉 消費掉當前指針指着的const,然後指針移動到a,重複它的偷看和消費的步驟。
-
-
簡單來説,解析過程就是:用 前瞻 提前偷看 lookahead 決策,用 消費 consume 前進,一層層把工作交給合適的解析函數,直到整段代碼被解析完成。
-
前面説 懶惰為主 全量為輔,意思是從解析結果 從解析數量上來看, 很大很大部分都是做的懶惰解析 預解析,是佔主要的部分。 而全量解析做的很少。
那麼 從解析流程的決策層面來看,從“指揮權”來看,全量解析為主。
- 全量解析負責開場,它負責做決定,它負責把控全局。沒有它,預解析根本不知道什麼時候進場工作。即 全量解析是主導流程的
- 這裏要特別注意,我們把 主解析器和全量解析 作為一個整體來講的,在v8中,主解析器和全量解析器 基本上可以劃上等號,所以 説全量解析為主導流程 ,就是説主解析器主導流程。 主解析器/全量解析 推進流程, 遇到非立即執行的代碼,就呼喚預解析器來工作。
// 全量解析 即主解析器正在幹活 構建全局AST var a = 1; // 突然遇到了一個函數聲明! function lazy() { var b = 2; console.log(b); } // 全量解析:"哎呀,是個函數聲明,估計沒人調用它,我不進去了,太費勁。" // 於是指揮 預解析去幹活 // 切換到了 預解析 // 預解析快速掃描 lazy 內部: // 1. 檢查有沒有語法錯誤?(沒有) // 2. 檢查有沒有引用外部變量?(沒有) // 3. 檢查結果:"裏面安全,是個普通函數。" // 4. 於是生成一個"佔位符節點",預解析器收工。 // 切換回到了 全量解析/主解析器 // 全量解析繼續往下 var c = 3; // 遇到了 立即執行函數 // 全量解析一看:"哎,這後面有個括號 (),馬上要跑" // 不能喊外包了,得自己來幹這一票。 // 全量解析進入函數內部構建 AST (function urgent() { var d = 4; })(); -
我們説預解析 雖然不生成AST節點,只是生成佔位符節點,但是也需要快速掃描內部。
// 對外部函數進行全量解析,對內部函數進行預解析 function father() { let dad = '爸爸'; //全量解析中,遇到內部函數,額太累,呼叫外包 預解析 // 預解析進來 開始快速掃描 son 的內部文本... function son() { console.log(dad); } } 預解析 : 它看到 console.log,不生成 AST 節點。 它看到 dad 這個標識符 判斷:son 內部聲明過 dad 嗎?(沒有)。 判斷:這是一個未解析的引用 (Unresolved Reference)。 結果: 預解析 掃描完 son 後,雖然把中間的信息扔了(不存 AST),但會給 father 的作用域留下一條極其重要的情報: 該子函數內部引用了你的 dad 變量 father函數的反應 (Context 分配) 收到預解析的情報後,father 函數此時已經在忙碌中了,它就會做出反應: 本來 dad 是準備分配在 棧 (Stack) 上的。 因為收到了預解析提供的閉包引用信息,所以 father 的作用域分析結果中,dad 被標記為 需要 Context 分配。 結果: dad 被移入堆內存的 Context 中,確保 father 死後 dad 還能活。 這裏要特別注意,這是藍圖 藍圖, 此時是靜態解析階段,所説的都是藍圖 都是畫的大餅。 關於怎麼描述 被移入堆內存的上下文中,後面會詳細講。 那麼 這個佔位符裏是什麼內容呢? 對於預解析的函數 son, AST 樹上只有一個“佔位符節點”(UncompiledFunctionEntry),在 V8 中,這個佔位表示會與一個 SharedFunctionInfo 關聯,用來保存函數的元信息(如參數、作用域、是否為閉包等),供後面真正全量解析和編譯時使用, 元信息中大致有如下內容: 沒有 AST節點:也就是沒有具體的代碼邏輯結構。 有作用域信息 (ScopeInfo): 它知道自己內部引用了哪些外部變量。 它知道自己是不是閉包。關於作用域,後面會詳細講。上面是先講佔位符裏是有這些信息的,否則無法保證閉包藍圖的完整性和準確性。
-
經過上面的鋪墊,我們現在開始AST的解析了。這部分內容是否有必要展開, 我糾結了起碼兩盞熱茶的時間,因為從瞭解的角度來説 ,上面的內容,已經足夠了,甚至在中級高級前端開發的崗位面試中,也足夠了。 但是,我又覺得具體的解析也有必要講講,畢竟都學到這塊內容了,稍微再往深處瞄那麼幾眼,也可以的。
我們以v8為例。
為了説明白,現在開始就不得不使用具體的函數名了,不過基本上這些函數名都有規律,看名字就差不多知道含義了。
ParseStatementList(語句列表解析)是真正的循環驅動者。如果不嚴格區分頂層入口的話,我們可以把它看作解析流程的主引擎。它的工作非常單純枯燥:就是開啓一個while循環,只要沒到文件結尾,就驅動 項/Item 這一級別的解析。而在循環內部,它會把每一次的處理任務甩鍋給
ParseStatementListItem(項級入口)。可能有朋友會疑問:什麼是“項(item / 條目)”這一級別?可以這樣理解:從語法上講,語句加上聲明,就構成了 項/item/條目, 但是語句和聲明 他們有很大的不同。既要區分他們,又要在一個大循環裏統一處理他們,所以有了 項 這個稱呼。
有些 聲明、模塊的
import/export、在允許位置上需要提升並且登記到作用域的函數聲明、需要做早期錯誤檢測的地方等等,就要求優先的處理 比如提前登記名稱和作用域信息、報早期錯誤,或者做預解析並留下佔位符。ParseStatementListItem()負責做項級的分流,如果檢測到是 import/export、可提升的函數聲明或其他項級必須優先處理的內容,就在此處定向甩鍋,通常是直接甩給對應的具體解析函數,如果檢測到不是需要優先處理的聲明定義,而是普通的語句,它會把該條甩鍋給ParseStatement(),就是普通的語句級解析,由語句級負責普通語句(控制流、塊、表達式語句等)的詳細解析。在解析器層面上的這兩種分流保證了 提升、模塊 規則和語句語義既能正確又便於優化實現。ParseStatementList:負責整體推動循環,偷看一眼,現在只要不是eof結束標記,不管其他是什麼內容,統統一股腦的甩鍋。 ParseStatementListItem:負責在 項級 這一層面分流, 綜合以下判斷: 當前 token + 當前的語境 + 語法規則 + 可能有的預判 分流為聲明級解析和普通語句級解析, 如果是聲明級 import、function、class、let 等,就優先處理,提前定向甩鍋,以實現提升或登記作用域。通過以上內容,我們知道了,ParseStatementListItem 具有解耦的用途,它區分了聲明和語句,但是它又不具體幹活,依舊是把它攔截的聲明項派發。
下面我們來看 ParseStatement ,通過上面的語句和聲明的分流,語句項來到了這個地方,這裏又是一個甩鍋處。ParseStatement 先使用神技 前瞻lookahead偷看token,使用類似於 if 或 switch case 的形式,嘗試匹配所有具有確定起始關鍵字或符號的語句形式(如
if、for、return、{等)。匹配上以後 對準那個匹配成功的解析函數,甩鍋下去。其他尚未識別的 則甩給表達式解析,這是因為表達式的形式有很多,而且無法根據關鍵字來識別,所以 可以説表達式解析是個兜底。 如果是被甩鍋到表達式解析,首先由表達式的賦值解析接手, 解析流程統一從 ParseAssignmentExpression 這一最低優先級規則開始。因為對於表達式解析,它和其他的解析不同,其他的可以依靠關鍵字來甩鍋,但是表達式必須依靠優先級來甩鍋。賦值解析作為低優先級的一層,它無法預知當前代碼的含義,因此它必須先無條件地將解析任務甩鍋給更高優先級的下層解析器(如三元、二元、調用等)。
等下層解析器返回了一個表達式節點後,賦值解析器再偷看後續 token。只有當後續 token 是
=時,它才將其組裝成賦值表達式,否則,它就直接將剛才下層解析器返回的結果,原封不動地向上返回。我們以一個表達式的例子來説明解析過程:
m=1+3
ParseStatement通過前瞻,匹配不到語句,甩鍋到表達式 ParseExpression(),這個也是直接轉交給ParseAssignmentExpression, 此時有5個token
-
前面説過 這個賦值解析優先級非常低,它無法預知當前token的含義,必須先甩鍋給別人,先搞出來一個東西看看。
這裏肯定有朋友會問了,賦值解析拿到m,偷看後面的 是個 = 號,不就知道了嗎?
但是,假如不是m,而是m[0] ,是m.b 甚至是m(888) (函數調用,雖然這在賦值中是非法的,但解析器得先把它解析出來,然後偷看到=號,才會知道非法)呢? 而且,解析函數的設計,是需要統一性 通用性 的,所以 它必須先甩鍋,必須得到一個確定的表達式節點,才能做決定。
-
所以 賦值解析直接派發給了三元解析ParseConditionalExpression
三元解析説 看不懂 不歸它管 依舊往下甩鍋。
-
ParseBinaryExpression
兩元解析 依舊甩鍋
-
ParseUnaryExpression
一元解析 依舊甩鍋
-
ParseLeftHandSideExpression
LHS 處理new
,(),.,[] 的解析, 依舊甩鍋 -
ParsePrimaryExpression
到了原子層,這裏是專門處理m
,1,(expr),this 的地方。這層一看 欸 是我的活呀, 然後吃掉 token
m。生成
VariableProxy(m)節點。 交回上層。 -
返回到ParseLeftHandSideExpression
這層的解析拿到m節點,偷看後面 是個 = 號,嗯 沒我的事,快走吧。繼續往上交
-
返回到ParseUnaryExpression
這層拿到m,偷看 是個=號,和我的工作沒關係,快走吧
-
返回到ParseBinaryExpression
這層拿到m,偷看 是個=號 ,我是搞兩元的,和我沒關係 ,快走吧
-
返回到ParseConditionalExpression
這層拿到m,偷看 是個=號,我是搞三元的,和我沒關係,快走吧
-
返回到ParseAssignmentExpression
這層拿到m,偷看 是個=號,哎呀呀,我就是搞賦值的,就是我的活,
然後接收m節點,吃掉=號 並且保存=號, 關鍵點來了: 此時它需要解析等號右邊的內容。雖然我們看到的是
1+3,但解析器並不知道右邊是不是還藏着另一個賦值(比如m = n = 1+3)。 為了保證賦值的右結合性(即連等賦值),它必須遞歸調用自己(ParseAssignmentExpression) 來解析右邊。第2次進入 ParseAssignmentExpression 新的一層賦值解析器啓動了。它依然遵循老規矩,先看不懂,甩鍋
-
ParseConditionalExpression
三元解析拿到1,啥東西呀,甩鍋
-
。。。一直甩到原子層
-
ParsePrimaryExpression
拿到1,哎呀,又是我的活,咔嚓 消費掉token 1,生成 Literal(1) 節點,往上交
-
返回到ParseLeftHandSideExpression
拿到Literal(1)節點,偷看 是個 + 號,快走吧
-
返回到ParseUnaryExpression
拿到Literal(1)節點,偷看 是個+號,和我的工作沒關係,快走吧
-
返回到ParseBinaryExpression
拿到Literal(1)節點,偷看 是個+號,天吶 我就是搞兩元的,我的活,
然後 接收Literal(1)節點 消費掉+號 並且保存+號,
這個時候 它要解析後面的token 3,前面講過,解析函數的設計,要兼顧到統一性和通用性,雖然本例是1+3,但是二元解析中,+號後面 依舊可能是個二院解析式,比如 3+5*9 等等,所以,本例雖然可以直接甩鍋到下面的一元解析lhs解析到原子解析,但是,從統一和通用性的角度,v8設計成了遞歸調用。
就是對於+號後面的解析,依舊是調用ParseBinaryExpression,只不過,必須要加上優先級, 比如 + 號的優先級是12, 乘法*的優先級是13, 這個優先級傳遞很簡單 就是通過函數的參數傳的。
再次調用以後,本例是3,再次甩鍋,甩到原子層,得到節點3,返回到這裏,
這第2次調用 得到3節點,它偷看一眼 後面沒了,嗯 嗯嗯 這個表達式就是一個節點3,連優先級判斷都沒用到。 它就返回上交,退出第2次調用, 回到了當前, 此時,它左手有1節點 右手有3節點,腦子裏還記得一個+號, 於是 它召喚出factory工廠方法NewBinaryOperation(op, left, right),生成了大的新的節點,這個節點 上面是+號節點 左孩子是節點1,右孩子是節點3。
後面什麼都沒了,往上交活了。
-
返回到ParseConditionalExpression
三元解析一看 這是個1+3的小AST樹,偷看後面 沒有token了, 快走吧
-
返回到ParseAssignmentExpression
賦值解析拿到這棵
1+3的小 AST 樹,偷看一眼 後面沒了, 於是第2次的調用返回,現在,自己左手是個
m,右手是個1+3,腦子裏還記得個=,全妥了。 於是它就召喚 factory 工廠方法NewAssignment(ASSIGN, m, right_node)。隨着一道金光,一個 Assignment賦值節點 誕生了 這行代碼
m=1+3的語法分析徹底完成,最終返回給最頂層的ParseStatement。 -
上面我們以一個簡單的賦值表達式m=1+3的例子 詳細講解了AST的生成過程。並通過賦值解析的遞歸調用 能瞭解連等賦值的右結合是怎麼實現的,二元運算解析中的遞歸調用,我們也能知道通過參數傳遞運算符的優先級。
解析 m = 1 + 2 * 3
- 賦值層啓動:賦值解析拿到
m,消費掉=號,並記住=。 - 開始第一次遞歸調用(賦值表達式解析):為了解析右值。
- 甩鍋環節:拿到
1,不認識,甩甩甩... 1節點被返回,返回到 二元解析(Level 0) 這裏。
- 甩鍋環節:拿到
- 二元解析(Level 0):
- 狀態:接收
1節點。 - 偷看:
+號(優先級 12)。 - 判斷:當前門檻 0,12 > 0,消費
+號,記憶+號。 - 遞歸調用:調用二元解析,門檻設為 12。
- 狀態:接收
- 第一次遞歸二元解析(Level 1)開始:
- 甩鍋環節:
2不認識,甩甩甩... 返回2節點。 - 狀態:接收
2節點。 - 偷看:
*號(優先級 13)。 - 判斷:當前門檻 12,13 > 12,可以吃! 消費
*號,記憶*號。 - 遞歸調用:調用二元解析,門檻設為 13。
- 甩鍋環節:
- 第二次遞歸二元解析(Level 2)開始:
- 甩鍋環節:
3不認識,甩甩甩... 返回3節點。 - 狀態:接收
3節點。 - 偷看:沒了(或者分號)。
- 判斷:優先級不夠。
- 返回:直接返回
3節點。
- 甩鍋環節:
- 回到第一次遞歸(Level 1):
- 組裝:接收到
3節點。左手是2,右手是3,記憶是*。 - 動作:組合成
2 * 3節點。 - 返回:把
2 * 3節點往上交。第一次遞歸結束。
- 組裝:接收到
- 回到二元解析(Level 0):
- 組裝:接收到
2 * 3節點。左手是1,右手是2 * 3,記憶是+。 - 動作:組合成
1 + (2 * 3)節點。 - 返回:往上交。直到賦值表達式。
- 組裝:接收到
- 回到賦值表達式(第一次遞歸調用處):
- 狀態:接收
1 + 2 * 3節點。 - 偷看:沒了。
- 返回:第一次賦值解析遞歸調用返回。
- 狀態:接收
- 回到最頂層賦值解析:
- 組裝:當前左手
m,右手1 + 2 * 3,記憶=。 - 動作:組合成
m = 1 + 2 * 3。解析完成。
- 組裝:當前左手
上面我們又以 m=1+2*3的例子,詳細解説了賦值解析中的遞歸調用,二元解析中的多次遞歸調用,並且在遞歸的時候,加入了優先級套餐,相信能看到這裏的朋友,對於解析的套路,已經有那麼一點點的感覺了吧。
m = 1 * 2 + 3 這個例子 是個優先級高的在前
節點 1 返上來,被二元解析攔截。偷看 是* 號 優先級13,當前0,吃掉。
記住*號, 然後開始遞歸,調用
ParseBinaryExpression(13)第一次遞歸,拿到2,不認識 甩甩甩, 節點2返上來,接收節點2, 偷看 + ,優先級12,而當前優先級13,太弱了 不搭理,帶着節點2返回,結束本次遞歸。
此時,左手節點1,右手是剛返回得節點2,記住的是*號,
組裝節點 1*2 . 然後繼續, 偷看後面 + 號, 當前優先級0,+號優先級12,
吃掉消費掉+號,記住+號, 開始第二次遞歸ParseBinaryExpression(12)
拿到3 不認識 甩甩甩, 節點3返上來 接收節點3,偷看 後面沒了。帶着節點3返回,第二次遞歸結束。 此時 左手是 1*2 節點, 右手是剛返回來的3節點,腦子記着的是+號,
金光一閃, 1*2+3 完成。
簡單描述了一下優先級高的在前的例子。
成員訪問
obj.data.list還是從賦值解析開始,看到
obj,不認識,甩甩甩,一路下去,直到原子層。 原子層生成VariableProxy(obj)節點,返回。剛返回一層,到了ParseLeftHandSideExpression。被攔截: 手裏拿着
obj節點,偷看後面是個.符號,是我的活!接收obj節點,消費掉.符號。這裏它不需要像處理
[]那樣,去調用那個沉重複雜的表達式解析器(因為[]裏甚至可以寫1+1),而是自己解析data。 因為點號後面,只允許跟一個“名字”。所以它直接自己上手,快速掃描這個名字。哪怕你寫的是obj.if或者obj.class,在這裏也被當作普通的名字處理。解析完名字,立馬打包。這種自力更生的處理方式,比把data甩鍋給原子層更快速。現在,左手是
obj節點,右手是剛解析的data,腦子記着點號,咔嚓一下,組裝成obj.data節點。注意,這裏是個循環: 組裝完後它不走,偷看後面,哎,還是個
.點號! 於是消費掉第二個.號,繼續自己解析list。 此時,它的左手變成了剛才組裝好的(obj.data)節點,右手是新拿到的list,再次組裝,生成(obj.data).list。再偷看,後面沒了,交上去。
三元表達式
ok ? 1 : 0從賦值解析開始,看到ok 不認識,甩甩甩,從原子層返回ok節點,返回到三元解析層,
拿着ok 偷看 ?號啊, 那是我的活了,接收ok,吃掉?,注意,現在就不需要記住?了,因為三元表達式是固定的語法結構,在這一函數解析的 都是固定的格式,不需再記?號。
調用ParseAssignmentExpression() ,得到條件為真時的節點,此例為節點1. 此時,左手ok 右手節點1,偷看 是:號,妥了,吃掉:號,必須是冒號,如果不是,直接報錯
SyntaxError: Unexpected token再次調用ParseAssignmentExpression(),得到條件為假時的節點,此例為節點0,
此時,左手ok 右手節點1 加上剛剛返回的節點0, 全齊了, 召喚
factory 工廠函數, NewConditional(condition, then_expr, else_expr)
生成一個 Conditional(ok, 1, 0)(三叉樹,這裏要注意,並不是左手右手的二叉了,而是有三個子節點的三叉了,即一個Conditional節點,帶3個子節點)節點,返回到賦值解析層。
a || b 這個解析時和加法差不多 只是操作符不同。
m = (1+2) * 8 這個表達式帶括號,實際也很簡單,接收m 偷看= 消費掉=,遞歸調用賦值解析,( 一路到了原子層,原子層吃掉
(,然後調用最高級的ParseExpression(注意:是重新從頭調用表達式解析,相當於開啓了一個新的獨立副本)。 然後接收1+2節點,偷看 ),欣慰,剛才吃了個(,現在成對了, 於是吃掉 ), 把1+2 節點上交。。。 後面就更簡單了。省略。add(1, 2, 3)
依舊是從原子層返回add節點,返回到ParseLeftHandSideExpression層,偷看 是 (
,接收add節點, 吃掉( , 調用ParseArguments,收集參數,依次調用ParseAssignmentExpression 收集參數,直到碰到 ),吃掉 ),返回,此時ParseLeftHandSideExpression左手add節點 右手剛才拿到的參數列表,組裝,完工。
**m[2] **
這是帶有計算屬性的成員訪問形式。 LHS 層在處理時,會把解析點號
.和中括號[的任務,統一甩給ParseMemberExpression來處理(new操作符也歸它管),而 LHS 自己負責函數調用和模板字符串的解析。簡要流程:
-
先找頭: 先解析出
m。 -
進入循環:
ParseMemberExpression啓動while循環,偷看後面。 -
處理中括號: 發現是
[,吃掉它。這裏會調用
ParseExpression(true)。這個true表示允許包含逗號,表示中括號裏可以寫完整的表達式(比如1+1或者更復雜的表達式)。 -
組裝:
ParseExpression返回節點2,吃掉],將m和2組裝起來。 -
繼續循環: 如果後面還有
[或.(比如二維數組或鏈式調用),就繼續解析、繼續包在外面組裝;如果沒有,就返回。
下面我們進入思考模式
我們説 在賦值解析的時候 要使用遞歸調用,這是沒有任何問題的,因為遞歸調用本身就可以得到右結合的目的,和連等賦值的定義是相符合的。
在二元解析的時候,我們也説使用遞歸調用,但是這就有些問題,因為遞歸調用會產生右結合,而通過使用優先級 和遇到同級操作符 則退出遞歸 由上級處理左結合以後 再次遞歸,這樣也可以達到左結合的目的。 這種方式本身也沒問題,從嵌套深度上來講,極限情況下 也不過是十多個遞歸嵌套,並不會棧溢出。 但是從橫向上來看,比如 有多個同級操作符的時候 就比較繁瑣,極其頻繁的函數調用,開銷比較大。
so, v8在具體實現二元解析的時候 採用的是 循環為主 遞歸為輔 的方式。用循環處理同級左結合,用遞歸下降處理更高優先級的子表達式
主要思路就是在while循環裏處理同優先級,高優先級的 則進到遞歸裏處理, 一個while循環裏處理同一級,高優先級的 進到遞歸裏 繼續在遞歸裏的那個while裏處理那個高優先級的同級。如此循環,所以,實際上,跟我們之前例子裏學的,全部遞歸的方式,在遞歸層次上相同,極限情況下 也不過是十多個嵌套遞歸, 但是,橫向的同級,則被壓扁成在一個while循環裏處理。
偽代碼 // 入口:解析二元表達式,傳入當前允許的最小優先級 function ParseBinaryExpression(min_precedence) { // [初始左值] 先搞定左邊的原子 (例如: 1) let x = ParseUnaryExpression(); // 開啓大循環 // 只要後面還有能吃的符號,就一直在這個循環裏轉 while (true) { let op = Peek(); // 偷看下一個符號 // 遇到這兩種情況 1是符號沒了,到頭了 2是下個符號太弱了,該上層遞歸要管的事情, // 這時,就帶着手裏積攢的 x 趕緊返回 if (!op || op.precedence <= min_precedence) { return x; } // [消費] 優先級夠格,吃掉符號 (比如 +) Consume(op); // [遞歸獲取右值] // 讓遞歸函數去拿右邊的數。 // 關鍵點:把當前 op 的優先級傳下去 // 這樣如果右邊是同級運算(如 1+2+3),遞歸函數會發現優先級不夠,只拿一個數就立馬 // 返回。 // 如果右邊是高級運算(如 1+2*3),遞歸函數會深入處理。 let y = ParseBinaryExpression(op.precedence); // [原地累加 像滾雪球] // 把左邊(x)、符號(op)、右邊(y) 組裝成新節點。 // 核心動作:把新節點賦值回 x // 現在的 x 從 "1" 變成了 "(1+2)"。 x = NewBinaryNode(op, x, y); // [循環繼續] // 代碼運行到這裏,會回到大循環開始處。 // 此時手裏拿着新的 x, (1+2),去偷看下一個符號(比如 +3 的那個 +)。 // 如果下一個符號優先級還夠,就繼續吃;不夠就由if語句退出。 } }上面是使用循環為主 遞歸為輔 實現二元解析 左結合的偽代碼。
理解偽代碼 理解思路以後,會感覺 甚至比原先的純遞歸更容易。具體的例子就不舉了。
注意 這裏要説明 金光是如何一閃的
之前我們説 召喚工廠方法,金光一閃,節點誕生, v8中的AST節點的創建,有自己的內存分配方法,它採用的是一種叫 Zone Allocation的分配方式。類似於提前圈地模式。
解析前,V8 直接向系統“圈”了一大片連續的內存,取名為 Zone。
當工廠函數
factory() --- NewAssignment(...)被調用時,它只是在自己圈好的這塊地裏,把指針往後挪一挪,劃出一小塊地給這個節點住。這個動作快到不可思議,僅僅是簡單的指針加法操作。
而當需要銷燬時,V8 不需要一個個節點去拆除,它只需要把 Zone 整個推平。一鍵清空,瞬間滿血。
所以,AST 節點的創建,是極速的指針跳動。這保證了哪怕代碼量再大,解析器的內存分配速度也快如閃電。
在表達式解析的家族裏,還有一個不得不提的重磅人物,那就是 ES6 引入的 箭頭函數
() => {}。你可能會問:“它不是函數嗎?為什麼要在表達式這裏講?” 這是因為在 V8 眼裏,箭頭函數首先是一個表達式。它通常出現在賦值號右邊(
let a = () => {})或者作為參數(func(() => {}))。它不能像function關鍵字那樣獨立成行(除非你沒寫名字且不賦值,雖然合法但沒意義)。但它讓解析器非常頭疼,因為它喜歡 偽裝。
看這行代碼:
let x = (a, b ...
當解析器讀到這裏時,它有些糊塗了。
- 如果是
let x = (a, b);—— 這是一個 分組表達式,裏面是個逗號運算。 - 如果是
let x = (a, b) => a + b;—— 這是一個 箭頭函數。
在讀到 => 這個關鍵 Token 之前,解析器根本不知道前面的 (a, b) 到底是個什麼。
這就是解析器面臨的 歧義 。
如果 V8 只有讀到 => 才知道前面是參數,那難道要先存着 Token 不解析,等看到了箭頭再回頭解析嗎?
不,V8 通常不願意回頭。 它採用了一種 “將錯就錯,後期修正” 的策略,術語叫 Cover Grammar(覆蓋語法)。
我們以
(a, b) = a + b為例,看看解析器是怎麼被騙,又是怎麼反應過來的。階段一:按表達式解析
1. 入口與誤判
解析器在掃描到左括號
(時,它此時處於ParsePrimaryExpression(基礎表達式解析)的上下文中。 此時,解析器心裏只有一種想法:“這肯定是個 分組表達式 (Parenthesized Expression),裏面包着一些運算邏輯。”2. 表達式解析模式啓動
解析器開始調用
ParseExpression來處理括號裏的內容:- 讀到
a:- 解析器認為這是在使用變量
a。 - 產物:生成一個
VariableProxy節點(變量代理,表示“我要引用 a”)。
- 解析器認為這是在使用變量
- 讀到
,:- 解析器認為這是 逗號運算符 (Comma Operator)。
- 它的作用是連接兩個表達式,並返回後者。
- 讀到
b:- 生成
VariableProxy節點(表示“我要引用 b”)。
- 生成
3. 階段性產物 當解析器吃掉右括號
)時,它手裏捧着一個 多元運算 或者叫逗號表達式。 在解析器眼裏,(a, b)目前的含義是:“先執行 a,扔掉結果;再執行 b,返回 b。” 這顯然不是我們想要的結果,但在讀到=>之前,這是唯一合法的解釋。階段二:壞了 發現箭頭
解析器剛吃完
),立刻啓動 前瞻 (Lookahead/Peek) 技能,偷看下一個 Token。- 如果後面是
+:那前面就是個逗號表達式,繼續做加法。 - 但這次,它看到了
=>!
解析器:
“哎呀!撞上箭頭了! 前面那個括號裏的根本不是什麼逗號運算,那是 箭頭函數的參數列表 (Formal Parameters)! 手裏捧着的這些
VariableProxy(變量引用),全都是廢紙,它們應該是 參數聲明 才對!”此時,解析器必須啓動緊急預案:重解釋 (Reinterpretation)。
階段三: AST 進行原地修正變身
V8 通常不會回退指針重新解析一遍(那太慢了)。它選擇直接對內存裏已有的 AST 節點修改。
1. 合法性檢查 解析器遍歷剛才那個
CommaExpression裏的每一個子節點,:- 檢查
a:你是個VariableProxy嗎?是。你的名字是合法的參數名嗎?是。 -通過。 - 檢查
b:你是個VariableProxy嗎?是。 -通過。 - 假如:假如你寫的是
(a + 1) => ...。- 解析器會發現列表裏有個
BinaryOperation(加法節點)。 - 問:“
a+1能當參數名嗎?” - 回答:不能。 -直接報錯
SyntaxError。
- 解析器會發現列表裏有個
- 在這裏還要進行其他的必須檢查,以保證它們作為參數的合法性。
2. 節點轉化 (Transformation) 這是最重要的一步。解析器不銷燬節點,而是修改節點的 性質。
- 它把
a和b的VariableProxy節點,原地轉化 為 參數聲明。 - 關鍵動作:
- 之前,
a指向的是外層作用域(試圖引用)。 - 現在,解析器把
a從外層作用域的引用列表中摘除。 - 然後,把
a作為 新聲明,登記到即將創建的 FunctionScope 裏。
- 之前,
從此,
a和b從“消費者”(引用)變成了“生產者”(聲明)。階段四:解析函數體
參數搞定了,現在處理
=>後面的a + b。1. 創建作用域 V8 調用
NewFunctionScope,創建一個新的函數作用域。- 注意:因為是箭頭函數,所以這個 Scope 被標記為
is_arrow_scope,所以它不會聲明this,也不會聲明arguments。
2. 偷看與判定 解析器偷看箭頭後面的 Token:
- 是
{嗎?不是。 - 那這是一個 Concise Body (簡寫體)。
3. 自動包裝 (Desugaring) 對於
a + b這種簡寫體,解析器並不是直接把它當表達式扔在那。 它會由工廠方法生成一個ReturnStatement節點,把a + b包在裏面。最終產物: 雖然你寫的是
(a, b) => a + b,但在 V8 的 AST 裏,它長得和下面這段代碼幾乎一模一樣:function (a, b) { return a + b; }這就是覆蓋語法 Cover Grammar :先按通用的表達式解析,一旦發現特徵(箭頭),立刻把已有的 AST 結構重組為特定語法結構。
面試官必被吊打題:為什麼箭頭函數沒有
this?很多教程説:“箭頭函數的 this 指向外層。”
這句話是對的,但在 V8 的實現裏,更準確的説法是:箭頭函數根本就不在這個作用域裏定義 this。
我們來看看 Scope 分析 階段發生了什麼:
普通函數 (
function) 的 Scope:- V8 創建
FunctionScope。 - V8 會在這個 Scope 裏專門聲明一個隱藏變量:
this。 - 當你訪問
this時,找到的就是這個專門聲明的變量(由調用方式決定值)。
箭頭函數 (
=>) 的 Scope:- V8 創建
FunctionScope。 - 關鍵點:V8 給這個 Scope 打上一個標記 ——
is_arrow_scope。 - 後果:V8 不會 在這個 Scope 裏聲明
this變量。
查找過程:
當你在箭頭函數裏寫 console.log(this):
- 解析器在當前 Scope 找
this。 - 找不到!(因為根本沒聲明)。
- 往上找:沿着
outer_scope指針去父級作用域找。 - 結果:它自然而然地就用了外層的
this。
這不是什麼特殊的“綁定機制”,這單純就是“變量查找機制”的自然結果。
因為它自己沒有,所以只能用老爸的。這就是 詞法作用域 (Lexical Scoping) 的本質。
從解析器的角度看,箭頭函數是一個 “三無” 產品,這正是它輕量的原因:
- 無
this:Scope 裏不聲明this,直接透傳外層。 - 無
arguments:Scope 裏不聲明arguments對象,也是透傳。 - 無
construct:生成的FunctionLiteral節點會被標記為“不可構造”。如果你想new它,現在炸不了你,過一會肯定炸飛你。
通過箭頭函數的學習,説明倆問題。
- 解析層面的歧義(為什麼解析器要回溯、重解釋)。
- 作用域層面的
this本質(不是綁定,而是查找)。
上面 我們已經基本上將表達式解析的比較常見的形式 從超級詳細的撕扯到簡略的梳理,講了幾個,如果能耐心的看完,相信自己也可以分析了,即使還有沒遇到的表達式形式,根據慣用的套路,也能自己搞定。
在學習這些內容時,要聯繫到在js層面編碼時,表現出的特點。這樣不僅js能掌握的牢, 底層也記得住。 比如obj.data.list的解析,主要是在LHS層裏的while大循環裏解析點後面的內容,內容是字符串的形式, 是固定的, 而m[2],解析的時候,Lhs看到是中括號裏的內容,是調用了頂層的表達式解析函數來幹活的,表達式解析可以解析的東西那可多了,而且還可能有遞歸,所以在js的編碼時,要知道這兩種的區別和性能上的差異。雖然説 現在電腦性能快到飛起,都得用石頭壓住,而且瀏覽器本身的優化也很厲害,一丟丟丟丟的性能差異完全不用擔心,但是,萬一你換工作去面試,正巧問到你這兩種的區別。。。嘿嘿嘿,你就真的可以像那些八股文裏説的那樣 吊打面試官了。想想都刺激。
-
-
-
在前面,我們瞭解了,在 項 級的解析中,它實際是個分流處,把聲明的項攔截後直接甩鍋, 把語句的項甩鍋給語句解析。而上面我們花了大篇幅講的表達式解析,是語句解析中,負責兜底的表達式解析。 所以 我們還剩下可用關鍵字匹配的語句解析 和 在項 級就被直接派發的聲明的解析。現在我們開始瞭解聲明的解析。
聲明的解析
聲明的解析不多,總結起來,就是:一類四函兩變量。
class C {} // 類
function f() {} //四種形式的function
function* g() {}
async function f() {}
async function* g() {}
let //變量
const
可能有朋友會問了:var哪兒去了? 在js規範中, var屬於 語句,不屬於聲明,即 var屬於VariableStatement 。 但是 從var的效果和語義上來説,它確實是聲明變量。
所以 從規範的角度來説, 聲明 只有這一類四函兩變量, 沒有var, 但可是, 在v8的具體實現中,let const var 這三個卻是被分到一起 作為變量定義 派發到了ParseVariableDeclarations中解析,只是在裏面解析的時候 他們有不同的處理分支。
在進行下一步學習之前 ,我們再次的總結一下:
開始解析之後,來到 項級 被分流成兩種, 一個是聲明 包括(一類四函兩變量)4種函數被髮到ParseHoistableDeclaration,類被髮到ParseClassDeclaration,變量聲明(這裏要注意,js規範var不屬於聲明,但是v8中 var也在這裏被分發了) 被髮到ParseVariableDeclarations,
還有一個是語句,語句統一被甩鍋到ParseStatement進行解析,在解析時 先按關鍵字派發,無關鍵字匹配的甩給表達式兜底。
我們首先以一個簡單的函數聲明的解析為例。
function add(x, y) {
let result = x + y;
return result;
}
初始情況:
- 當前作用域: Global Scope(全局作用域)。
- 掃描器狀態: 指針停在
function這個 token 上。
第一階段:項級分流
1. ParseStatementListItem (項級入口)
-
動作: 解析器被上層循環調用,要求解析下一項。
-
偷看 (Lookahead): 當前 Token 是
function。 -
判斷: 這是一個函數聲明。它屬於 Declaration (聲明),且屬於 HoistableDeclaration (可提升聲明)。
-
甩鍋: 這活兒不能當普通語句處理,得走“提升通道”。
在前面反覆多次提到,項級分流主要分兩種:一是語句,一是聲明。聲明則由項級分流自己派發 按照“一類四函兩變量”。此處是普通函數聲明,被項級精準派發到
ParseHoistableDeclaration。 -
調用:
ParseHoistableDeclaration。
2. ParseHoistableDeclaration (可提升聲明解析)
- 動作: 確認是
function。 - 偷看: 後面不是
*(Generator),沒有async。 - 決定: 這是一個標準的函數聲明。
- 甩鍋: 調用
ParseFunctionDeclaration。
3. ParseFunctionDeclaration (函數聲明解析)
- 消費: 吃掉
function關鍵字。 - 解析標識符: 讀到
add。 - 關鍵動作(登記名字): 解析器立刻轉頭告訴當前的 Global Scope:“老全頭,我要在你這裏預訂一個叫
add的名字。”- Global Scope 記錄:
add---- 登記為函數聲明。 - 注意: 雖然解析器現在只讀到了名字,但因為它記錄的是“函數聲明”,V8 會在後續的編譯/實例化階段,確保在任何代碼執行前,這個名字就已經指向了完整的函數體。這就實現了我們常説的“函數整體提升”。
- 所以,雖然此時只是在小本本上記了個名字(佔位),真正的函數對象創建和綁定要等到後續階段。但對解析器來説,名字有了,就可以繼續往下走了。
- Global Scope 記錄:
- 準備進入實體: 名字搞定後,剩下的
(x, y) { ... }屬於函數字面量部分。 - 甩鍋: 調用
ParseFunctionLiteral。- 這個函數是個解析函數字面量的主力。不止聲明這裏可以調用,其他地方也經常調用它去幹苦力活。
第二階段:函數體解析
這裏是重點,是最關鍵的一步,我們從外部跨入了內部。
4. ParseFunctionLiteral (函數字面量解析)
- 初始化上下文:
- 創建新作用域: V8 創建一個新的 FunctionScope。這裏,函數作用域被創建了。
- 父指針連接: 新 Scope 的
outer_scope指向 Global Scope。這裏,作用域的外部連接指針被創建了,指向父作用域。(這一步形成了作用域鏈,為以後的變量查找鋪好了路)。
- 當前狀態: 解析器現在的“當前作用域”切換為這個新的
FunctionScope,現在已經全部進入函數內部開始幹活了。 - 消費: 吃掉
(。
5. ParseFormalParameters (解析參數)
- 循環讀取函數參數:
- 讀到
x:在 FunctionScope 登記參數x。 - 讀到
,:跳過。 - 讀到
y:在 FunctionScope 登記參數y。
- 讀到
- 消費: 吃掉
)。 - AST 節點: 此時,參數列表的 AST 節點已完成。
6. ParseFunctionBody (解析函數體)
- 消費: 吃掉
{。 - 動作: 現在進入了函數體內部。這裏本質上是一個語句列表 (Statement List)。
- 開始循環: 調用
ParseStatementList。- 這裏就相當於開啓了一個小世界。
第三階段:體內的循環
現在,我們在 add 函數的內部,開始循環處理每一行代碼。
====== 第一行代碼:let result = x + y; ======
7. ParseStatementListItem (再次回到項級入口)
-
ParseStatementList開啓以後,甩鍋給項級入口,進行分流。 -
偷看: Token 是
let。 -
判斷: 這是個 LexicalDeclaration (詞法聲明)。
-
甩鍋: 調用
ParseVariableStatement。項級分流,一是語句,二是聲明。
let是變量聲明,在此處被項級直接派發到ParseVariableStatement。嗯嗯嗯,反覆的重複,加深腦內印象。
8. ParseVariableStatement (變量聲明解析)
- 消費: 吃掉
let。 - 解析標識符: 讀到
result。 - 作用域操作:
- 問自己:當前 FunctionScope 有
result嗎?(沒有)。 - 動作: 在 FunctionScope 中登記
result。 - 標記: 暫時標記為 “棧局部候選人 (Stack Local Candidate)”。
- 為什麼是候選?因為現在還不知道有沒有閉包這個老登在後面等着捕獲它。先按“住棧”處理,等最後算總賬時再決定。
- 問自己:當前 FunctionScope 有
- 偷看: 後面是
=,這表示有初始值,需要解析賦值表達式。
9. ParseAssignmentExpression (賦值解析)
- 眼熟吧,俺表達式解析又回來了。熟悉的情節也回來了。
- 左手: 拿到
result的變量代理節點。 - 消費: 吃掉
=。 - 右手(遞歸): 解析
x + y。- ParseBinaryExpression (+號):
- 讀到
xResolve:在當前 Scope 找到參數x,生成引用節點。 - 吃掉
+。 - 讀到
yResolve:在當前 Scope 找到參數y,生成引用節點。 - 組裝: 生成
BinaryOperation(+, x, y)節點。
- 讀到
- 這裏的讀到變量的時候,首先在當前的作用域找,找不到就通過指向父作用域的指針,到上層作用域裏找。
- ParseBinaryExpression (+號):
- 終極組裝:
- 生成
Assignment節點:result = (x + y)。
- 生成
- AST 掛載: 這個 Assignment 節點被 push 到函數體的 statements 列表中。
- 消費: 吃掉
;。
====== 第二行代碼:return result; ======
10. ParseStatementListItem
-
偷看: Token 是
return。 -
判斷: 這是個
ReturnStatement。 -
甩鍋: 調用
ParseReturnStatement。項級分流,這裏是語句,被甩鍋給語句解析函數,然後根據關鍵字,被甩鍋給
ParseReturnStatement。過程還記得吧?假如關鍵字匹配不到,就甩給兜底的表達式解析。繼續重複一下,加深印象。
11. ParseReturnStatement (返回語句解析)
- 消費: 吃掉
return。 - 偷看: 後面不是
;,説明有返回值。 - 甩鍋表達式: 調用
ParseExpression解析result。- 又甩給表達式解析了,繼續那一套過程。。。
- 變量的解決:
- 讀到
result。 - 查找: 在當前 FunctionScope 找到了剛剛登記的
result。 - 生成:
VariableProxy(result)節點。
- 讀到
- 組裝: 生成
ReturnStatement(result)節點。 - AST 掛載: 掛到函數體列表中。
- 消費: 吃掉
;。
第四階段:收工階段
12. ParseStatementListItem (循環繼續)
- 偷看: Token 是
}。 - 判斷: 列表結束了。
- 返回: 退出
ParseStatementList。
13. 退出函數體與作用域計算 (Scope Finalization)
-
消費: 吃掉
}。 -
作用域收尾 (Scope Finalization) —— 算總賬時刻:
現在代碼解析完了,要離開 FunctionScope 了。但是還必須做一次最終盤點。
-
檢查有無“內部函數”: 看這個
add函數裏,有沒有定義其他的子函數。add是個光桿司令,肚子裏沒有子函數。
-
決定變量命運: 逐個檢查
x,y,result。- 如果有子函數引用了它們,它們就得“被迫搬家”,被放進 堆內存 (Context) 裏,供子函數隨時訪問。
- 但在這裏,因為沒有子函數引用,這幾個變量都是清白的(沒有被捕獲)。
-
計算棧幀: 既然都不用進堆,那就全部安排在 棧 (Stack) 上。解析器計算出:運行這個函數只需要申請幾個棧上槽位就可以了。
棧分配極其廉價,函數執行完,棧指針一彈,內存瞬間回收。比進堆(Context)快得多。
-
最終結果: 這個作用域被標記為“不需要 Context”。
-
-
AST 終極打包:
- 創建一個巨大的 FunctionLiteral 節點。
- 把
Name: add掛上去。 - 把
Scope: FunctionScope掛上去。 - 把
Body: [AssignmentNode, ReturnNode]掛上去。 - 把
Length: 2(參數個數) 掛上去。
14. 此時的產物與最終包裝
- 返回:
ParseFunctionLiteral任務完成,手裏捧着剛出爐的FunctionLiteral節點(含代碼體 + 作用域),返回給上一層的ParseFunctionDeclaration。 - 關鍵打包 (The Packaging):
ParseFunctionDeclaration接過這個 Literal 節點,把它和之前解析好的名字add(VariableProxy) 綁在一起。 - 召喚工廠: 調用工廠方法,生成一個更大的 FunctionDeclaration 節點。
- 左手:名字
add。 - 右手:實體
FunctionLiteral。
- 左手:名字
- 最終掛載: 這個
FunctionDeclaration節點(而不是裸露的 Literal),被 push 到 Global AST 的 body 列表中。
在前前前前面,我們提到過變量代理的説法,前面我們又提到了變量代理節點。 那麼這個變量代理到底是個什麼東東呢?這個概念比較重要,需要稍微講一下。
聲明 (Declaration): var a = 1; 這是在造變量。引擎在作用域裏實打實地登記了一個叫 a 的東西。
代理 (Proxy): console.log(a); 這是在用變量。
解析器讀到這裏的 a 時,它心裏是沒底氣的:“我要用一個叫 a 的東東,但我現在手頭沒有它的詳細檔案(不知道它是在棧上、堆上,還是全局裏)。不管了,我先開一張‘我要找 a’的小票放在這兒。”
這張“小票”,在 AST 裏就是 VariableProxy。
那麼有朋友就會説了,讀到 a 的時候,直接去查一下不就行了嗎?為什麼還要這麼麻煩搞個代理?
原因主要有兩個:
- 是因為 JS 允許在變量定義前使用它:比如函數提升、
var提升。當它讀到一個不確定的變量時,不能報錯也不能立刻綁定,所以它只能先生成一個VariableProxy(a)放在 AST 裏面,表明這裏有個a的坑,等全部解析完了,我得過來填坑。 - 是因為解析的順序限制:解析器是從上往下讀的。舉個最簡單的例子:
console.log(a); var a = 1;。當解析器讀到第一行console.log(a)時,如果你非要它立刻、馬上就把a找出來,它去哪裏找?它可能會去外層找,結果找錯了人。因為它還沒讀到第二行,根本不知道你在後面偷偷藏了個局部變量a。所以,解析器必須先忍一手。它必須先把當前函數裏的代碼全都掃完,把該登記的變量都登記在冊(Scope構建完成),然後回頭算總賬時,才能準確地知道:哦,原來這個a指的是第二行聲明的那個兄弟,而不是外面的隔壁老王。
所以,因為上面這兩個原因,就先生成代理,等 AST 造好了,或者進入作用域分析的階段,再統一處理這些代理的坑。
我們用一個小例子來演示:
JavaScript
function order() {
return dish; // A: 使用 dish
}
var dish = '周黑鴨'; // B: 定義 dish
第一步:生成代理 解析器解析 order 函數內部:
- 讀到
return。 - 讀到
dish。 “這是個變量名。但我現在只負責造樹,不知道dish是誰。” - 動作:創建一個
VariableProxy節點。- 名字: "dish"
- 狀態: Unresolved (未解決/未找到)
- 把這個節點掛在
ReturnStatement下面。
此時 AST 的狀態: ReturnStatement - VariableProxy("dish") (手裏拿這個只有名字的小票,不知道去哪領菜)
第二步:變量解決 (Variable Resolution) —— 兑換 這一步通常發生在前面講解例子的時候的第13步, Scope Finalization(作用域收尾/算總賬) 階段,也有可能是後續的編譯階段。
V8 開始拿着這張小票(Proxy)去兑換:
- 問當前作用域 (FunctionScope):“你這裏有
dish的聲明嗎?”- 回答:沒有。
- 問父作用域 (Script/Global Scope):“你這裏有
dish的聲明嗎?”- 回答:有!我這裏有個
var dish。
- 回答:有!我這裏有個
鏈接 (Bind): V8 就會把這個 VariableProxy 節點,和一個具體的 VariableDeclaration(或者具體的檔案信息)連上紅線。
此時的狀態: VariableProxy 不再是一張空頭小票,它變成了一個指針,明確指向了外部作用域的那個 dish。
“代理”這個詞的意思是 “代表某人行事”。 在 AST 中,這個節點暫時代表了那個真實的變量。在真正的連接建立之前,它就是那個變量的魔鬼代言人。一旦連接建立,操作這個 Proxy,實際上就是在操作那個真實的變量檔案(或者説邏輯地址),因為此時還在靜態解析階段。
嗯嗯嗯。。。肯定又有朋友會問了,那鏈接綁定以後,是什麼樣子的?
樣子就是,從此以後,V8 就不會再關心它叫什麼名字(名字只是給人看的),只關心它住在哪裏。它會被標記為以下三種“住址”之一:
- 住址 A:棧 (Stack / Local)
- 含義:這是個普通局部變量,沒被閉包捕獲。
- 結果:Proxy 拿到一個 寄存器索引 (Register Index)。
- 表示:“這小子就在隔壁房間(寄存器 r0, r1...),伸手就能拿,速度最快!”
- 住址 B:上下文 (Context / Heap)
- 含義:這是個被閉包捕獲的變量,或者
with(with已經被強烈建議不要使用了)裏的變量。 - 結果:Proxy 拿到一個 上下文槽位索引 (Context Slot Index)。
- 表示:“這小子搬家了,住在堆內存的 Context 豪華大別野裏。訪問它得先拿到 Context 指針,再根據偏移量(比如第 3 個格子)去找。”
- 含義:這是個被閉包捕獲的變量,或者
- 住址 C:全局 (Global)
- 含義:這是個全局對象(window/global)上的屬性。
- 結果:Proxy 被標記為 全局訪問。
- 表示:“這是大老闆,得去查全局字典。”
上面插個隊講了一下變量代理的概念,在我們繼續學習聲明的解析之前,我們再插個隊,講一下 作用域。
能看到這裏的朋友,估計對作用域都瞭解。但是,不講作用域光講聲明,就像吃餃子不蘸醋,渾身不得勁。
前面講變量代理的時候,那張尋找變量 a 的“小票” (Proxy),現在要拿着它去兑換了。去哪裏兑換呢?就是去 作用域。
有些教程上説“作用域是變量的可訪問範圍”,這話是沒錯,但這僅僅是從變量的角度來説,並沒有從作用域本身的視角來講。
作用域是一套語法規則,它就是“地盤”。它不光規定了誰在地盤裏,還規定了這是誰的地盤。
詞法作用域 (Lexical Scope):
這句話翻譯過來就是:“出身決定命運”。 一個變量的作用域,在你寫代碼的那一刻,就由它在源代碼裏的物理位置決定了。 它的特點就是 靜態:寫了就決定了,寫完就鎖死。以後不管怎麼調用、在哪兒調用、怎麼調用,作用域永遠不變。
作用域就是一張在編譯階段就畫好的靜態地圖。
能圈地盤的,有哪些大佬呢?
- 全局 (Global):最大的地主,普天之下莫非王土。
- 模塊 (Module):每個文件一個獨立地盤,自帶防盜門,互不干擾。
- 函數 (Function):這是最老牌的地主。每寫一個
function,就圈了一塊地。函數裏的var、let、參數,都歸它管。 - 塊 (Block):這是 ES6 新晉的小地主。凡是
{ ... }包起來的(比如if、for或者直接寫的大括號),在語法上都算作“塊”。
但是,V8 在塊級作用域這裏是非常現實的。
如果大括號裏沒有 let 或 const,V8 覺得專門為你建一個 Scope 對象太浪費內存了,根本懶得搭理你。此時,它在 V8 眼裏實際上並不構成獨立作用域,變量查找直接走外層。
只有當大括號裏出現了 let 或 const 這種新貴小王子時,V8 才會真的給它發“房產證”,專門創建一個由大括號為標誌的塊級作用域 BlockScope。
注意 var:至於 var,它比較特殊。它看不上塊級這種小地盤,這種大括號根本關不住它。它會直接穿牆出去,去找外面的函數地主或者全局地主。
那麼,變量有沒有作用域呢?
準確地説:變量本身並不能擁有作用域,但是變量屬於某個作用域。
我們説 a 的作用域是函數 f,實際是在説,變量 a 處在函數 f 的作用域裏。
在 V8 內部,每個作用域都有一個清單,上面詳細記錄了:
“我這塊地盤上,住了張三、李四、還有老王...”
如果解析器在這一層沒找到人,説明這個人不住這兒,就會沿 作用域鏈 去往上找。
那麼 問題來了,
作用域鏈是怎麼形成的呢?
當一個新的作用域被創建出來的時候,新的作用域裏都有一個 outer 指針,拴在父級作用域上。
子函數的作用域裏,也有個 outer 指針拴着外部函數的作用域;
外部函數的作用域裏,也有個 outer 指針拴着全局的作用域,這就形成了一根鏈條。
肯定有朋友會有疑問了:
“什麼作用域鏈?不就是子函數指向父函數嗎?平時咱寫代碼,函數嵌套個兩三層也就頂天了,這麼短一點,也好意思叫‘鏈’?
這裏有兩點:
第一,這是由數據的組織形式決定的。 只要是通過指針一個連一個的數據結構,都叫 鏈表。這跟它長短沒關係,只要是這種結構,5釐米是鏈表,25釐米也是鏈表,特指它這種“順藤摸瓜”的連接方式。它不是數組,不能通過下標直接訪問;也不是樹或圖。哪怕它只有兩層,只要是靠指針指過去的,它就是鏈表結構。
第二,它是內存裏實實在在的物理鏈條。 一定要分清解析和執行。現在我們是在解析階段,這根鏈條在圖紙上,是藍圖。等到後續代碼真正執行的時候,在堆內存裏,真的會創建出一串串的 Context 對象,它們之間真的是通過物理指針連接起來的。 所以,它不光是邏輯上的鏈,更是物理上的鏈。
想象一下查找過程: 當要查找一個變量時:
- 先看自己家:當前作用域有嗎?木有。
- 順着繩子找爸爸:父級作用域有嗎?木有。
- 一層層往上:直到找到全局作用域。
- 找到了:皆大歡喜。
- 到頂了還沒找到:
- 如果是賦值
a=1且不是嚴格模式:那就在全局給你造一個。 - 如果是取值
b=a:哎呀,找到全局都沒有,你歇着吧,直接報錯ReferenceError。
- 如果是賦值
一定要注意: 我們現在所説的,都是在 解析階段。 這一切都是 藍圖。作用域和作用域鏈,在解析階段就鎖定了。遇到變量該怎麼找、該去哪裏找,在這一刻都已經有了藍圖。
在講完作用域鏈以後,要停下來,揪出一個披着狼皮的羊,這就是對象。
對象 Object ,它沒有作用域。
var obj = {
name: '阿祖',
say: '我是' + name // 報錯!或者是拿到全局的 name
};
為什麼,同樣是大括號,函數那裏是作用域,對象這裏卻只是一個框框,只表示一個數據結構?
可以從以下幾個方面來説:
-
語法
作用域的大括號,它裏面裝的是語句, 是動詞 是命令 比如 a=1,這裏=是賦值運算,表示一個動作,他的意思是 在這個作用域裏面,開一個槽位,把1放進去。
對象的大括號,它裏面裝的是屬性定義,屬性是描述,是名詞。比如 name:’阿祖‘ ,這裏要用冒號, 不能用=號,如果手抖用了=號,馬上出錯 SyntaxError: Unexpected token '=' 。 在對象裏,沒有變量的説法 只有 鍵 和 值 的映射關係,只可以用冒號。
-
時序
函數是有提升的, 而對象沒有,
var obj = { a: 1, b: a // 想引用上面的 a };當引擎解析時,
讀到
var obj =:好,準備創建一個變量obj。讀到
{:好,開始準備構建一個對象。讀到
a: 1:記錄屬性a值為 1。讀到
b: a:- 這裏冒號右邊的
a,是一個表達式。 - 解析器需要求出這個表達式的值,作為屬性
b的值。 - **關鍵點:解析器此時會向 **當前作用域 發出查找請求:“誰是 a?”
當前作用域是誰?
是 obj 所在的作用域(比如全局作用域),而絕不是 obj 內部!
因為此時此刻,obj 這個對象還沒生出來呢!
究極原因,是因為 對象的初始化 是一個不可分割的原子過程,要麼 就是沒有 ,要麼 就是已經構建完成,絕不會出現在構建當中可以使用的情況,除非這個原子過程已經完成了,否則 這個obj是不存在的。
所以,對象初始化是一個原子過程。在大括號閉合
}之前,這個對象在邏輯上是“不存在”的,自然無法構建起所謂的“內部引用環境”, - 這裏冒號右邊的
-
結構
在v8的世界裏,作用域和對象是完全不同的。
作用域 對應着 context
- 它是一個環境
- 就像一個棧幀或者上下文的列表
- 裏面的變量是使用索引,比如 let a 是第0號槽位,let b 是第1號槽位。
- 作用域是為了代碼執行服務的。
對象 對應着 映射 隱藏類
- 它是一個字典, 對象的定義是什麼?它的定義就很清楚的説明 屬性的無序集合。就是一個字典。
- 它是一堆鍵和值的無序的集合。
- 裏面的屬性查找,是使用哈希計算或者偏移量描述符的,還有這個隱藏類,後面我們會講到。
- 它是為了存儲數據服務的。
對象和作用域,v8分的特別清楚,找變量,走作用域,查棧幀 查context 速度快到起飛。
找屬性,走原型鏈,查map 隱藏類,稍微慢點。
肯定有朋友説,你就是個騙子, 你看,class現在都能在裏面寫 = 號了。
class Obj {
name = '阿祖'; // 這裏寫了等號
say = () => { console.log(this.name) }; // 這裏也用了變量
}
class是構造函數的語法糖,在es6以後,確實可以寫=號。
但是 可以寫=號,也是一個語法糖。引擎並不會把類裏的=號 當成變量聲明,而是把它放到constructor構造函數裏面, 改成
// 引擎偷摸的操作
function Obj() {
this.name = '阿祖'; // 變成了屬性賦值
this.say = ...
}
引擎悄悄的使用 this.name=。。。 進行了屬性賦值,而不是 var name=。。。,它使用的依舊是對象的規則,不是作用域的規則。
你在 class 裏面寫 name,如果不加 this,依然訪問不到這個屬性,還得去外層作用域找。
總結對象:
- 對象沒有牆:它只是數據的容器,不是變量的隔離區。
- 對象的大括號是騙子:不要因為長得像塊級作用域,就以為它是作用域。
- 冒號不是等號:
:是畫地圖(定義結構),=是發指令(執行賦值)。 - 目的不同:作用域是為了執行代碼,對象是為了存儲數據。V8 從底層就把它們分到了不同的“部門”。
話音未落,又有朋友大聲説 騙子 現在類裏面不止=號,什麼都能寫,還有作用域。
class Database {
static data = [];
// 靜態初始化塊
static {
try {
const content = loadFromFile(); // 可以寫邏輯呀
this.data = content;
} catch {
this.data = []; // 可以寫 try-catch呀
}
}
}
它並不是 對象屬性, 而是披着大括號外衣的函數。
雖然static寫在class裏面,但是 static{...} 並不是定義一個叫 static 的屬性(不像 name: '阿祖')。在 V8 眼中,看到 static 關鍵字後面緊跟一個 {,解析器會立馬切換模式:
“注意,這不是在列清單定義屬性,這是要執行代碼!給我開闢一個新的 類作用域 (Class Scope)”
所以,static { ... } 內部,實打實地擁有一個塊級作用域。
你在static{...}裏面 let a = 1,這個 a 就死在這個大括號裏,外面誰也看不見。這完全符合作用域的定義。
本質上,這個靜態塊相當於一個綁定了 this 的立即執行函數 ,this值為這個class構造函數本身。
// 我們的代碼
class C {
static { ...code... }
}
// V8 眼中的代碼
class C { ... }
// 馬上執行的立即執行函數
(() => {
// ...code...
// 這裏的 this 指向 C
}).call(C);
正因為它本質上是代碼執行,而不是數據描述,所以它裏面當然可以有作用域,當然可以寫語句。
這並不是對象大括號變成了作用域,
而是 ES2022 專門在 Class 定義裏挖了一個代碼執行區。
- 普通的對象字面量
{ a: 1 }:依然是數據清單,沒有作用域,不能寫語句。 - 類的靜態塊
static { a = 1 }:是邏輯代碼塊,是作用域,是 一個 VIP 執行通道。
能寫語句的地方,才可以叫作用域,只能寫鍵值對的地方, 叫字典 叫對象。
順帶着,還有個暫時性死區的概念,這也是很多八股文裏要吊打面試官的地方。
在v8中, 變量的繩命週期,大致有3個階段
創建 在作用域裏佔個坑 登記名字。
初始化 給這個坑填個初始值 undefined 也算的。
賦值 填入真正的用户數據 比如 1
var的待遇:
var 的“創建”和“初始化”是綁定在一起提升的。
當進入作用域(比如函數開始)時,V8 直接把 var a 創建出來,並且順手就給它初始化為 undefined。
所以,你哪怕在第一行就訪問 a,它雖然沒數據,但起碼是個合法的 undefined。
let const 的待遇:
它們的“創建”被提升了,但“初始化”被扣留了。
當進入作用域時,V8 確實在內存裏給 let a 佔個坑位,登記了名字,但是 V8 並沒有給它初始化 undefined,而是給它填入了一個極其特殊的警衞 TheHole。
TheHole 是 V8 內部的一個特殊對象,可以把他理解為會吹哨子的警衞。
- 暫時性死區的所處階段定義:從進入作用域(創建變量)開始,一直到代碼執行到聲明那一行(初始化變量)為止。這段時間,變量一直處於被警衞看守狀態。
- 吹哨子:在這段時間內,任何試圖讀取該變量的操作,v8一看:“哎喲,這坑裏是
TheHole?” 馬上停止執行,拋出ReferenceError: Cannot access 'a' before initialization。
暫時性死區,是暫時的,所以 關注點 一定要停留在 暫時的 這個時間點上。
被提升了,但是沒真正被賦值, 都屬於這個 暫時性 所包括的時間階段內。
so,暫時性死區 並不是變量沒有提升,而是變量被“凍結”了。
var:開局送裝備(undefined)。let/const:開局送警衞(TheHole)。警衞在變量真正初始化前一直吹哨子,阻止訪問。只有等到代碼執行流真正跑到聲明的那一行,警衞才會扔掉哨子下崗走人,換上有效的值。
這也是 V8 強迫開發者養成先聲明,後使用的好習慣的一種手段。
我們再講一個雙樹的問題,然後就繼續學習聲明的解析。
當我們説解析階段生成了AST樹的時候,大多數人,就只會想到這棵湊想語法樹。
但是在V8的解析過程中, 其實是還有一棵樹在同步生成,和AST樹互相纏繞。
這就是作用域樹。
- AST (抽象語法樹)
- 語法結構的樹。
- 它描述了代碼的 語法結構。
Block、FunctionLiteral、BinaryExpression、ReturnStatement...- 給 Ignition 解釋器看。解釋器遍歷這棵樹,生成字節碼。
- 看到
BinaryExpression--生成Add指令。 - 看到
Literal-- 生成LdaSmi指令。
- 看到
- 就好像是搭建房子的 框架結構。牆在哪、窗户在哪、承重柱在哪。
- Scope Tree (作用域樹)
- 邏輯關係的樹。
- 它描述了變量的 可見性 和 生命週期。
GlobalScope、ModuleScope、FunctionScope、BlockScope。- 給變量查看。
- 決定變量是住棧、住堆、還是住全局。
- 處理閉包的捕獲關係。
- 就類似於 描述房子中的各個部件的邏輯關係。
- 主卧的開關能控制客廳的燈嗎?(變量可見性)
- 這根水管是通向廚房還是通向市政總管道?(作用域鏈查找)
- 雙樹的糾纏
這兩棵樹雖然是分開的數據結構,但它們是 伴生 的。
-
伴生生長:
當解析器解析到一個 function 時:
- AST 層面:生成一個
FunctionLiteral節點(AST長出了一個枝丫)。 - Scope 層面:
NewFunctionScope被調用,生成一個FunctionScope對象,並且outer指針指向父級(作用域樹也長出了一個枝丫)。 - 掛載:V8 會把這個
FunctionScope掛在FunctionLiteral的身上。 - AST 節點説:“我的地盤歸這個 Scope 管。
- AST 層面:生成一個
-
連接點:VariableProxy
還記得之前説的“小票”嗎?
VariableProxy 是掛在 AST 上的節點(因為它出現在源碼裏)。
但它的 小票兑換 resolve 過程,是在 Scope Tree 上爬樓梯。
一旦 resolve 兑換成功,AST 上的這個“小票”就獲得了一個通向 Scope Tree 上某個“槽位”的鏈接。
為什麼要分兩棵樹?因為 結構 和 數據 是兩碼事。
-
if (true) { let a = 1 } -
從 AST 看:這是一個
IfStatement包着一個Block。 -
從 Scope 看:
IfStatement本身不產生作用域,但裏面的Block產生了一個BlockScope。 -
有時候 AST 很複雜(嵌套很多層括號),但 Scope 很簡單(還在同一個作用域);有時候 AST 很簡單,但 Scope 變了(比如
static塊)。 -
AST 是為了 生成代碼(怎麼做)。
-
Scope Tree 是為了 查找數據(在哪裏)。
-
解析器的工作,就是一邊搭房子AST,一邊生成Scope,並且鋪好正確的鏈接關係,確保留在 AST 裏的每一個Proxy,都能在 Scope 裏找到對應的真身。
熱愛學習的朋友可能又有疑問了: 為什麼以前説作用域鏈 現在又是作用域樹,到底是鏈還是樹?
這其實是觀察角度-視角的不同。
-
上帝的全局視角—— 它是“樹”
站在 Global 的高度往下看:
全局下面有函數 A、函數 B、函數 C。
函數 A 下面又有子函數 A1、A2。
函數 B 下面有子函數 B1。
這時候,它們的關係是開枝散葉的,所以整體結構是 作用域樹 (Scope Tree)。
-
執行時的螞蟻視角—— 它是“鏈”
當你正在執行最裏面的子函數 A1 時,你根本不關心隔壁的 A2,也不關心函數 B 和 C。
你只關心:我自己 --我爸爸(A) -- 我爺爺(Global)。
對於正在運行的代碼來説,它只看到了一條通往全局的單行道。
這條線性的路徑,就叫 作用域鏈 (Scope Chain)。
所以,説樹,是説它的整體結構,説鏈,是説它的查找路徑。
-
在第一大部分的第7小部分,我們首先講了聲明的解析,並用一個例子詳細説明了解析過程,然後,插隊講解了幾個比較重要的 而且在後續學習中需要用到的知識點,這幾個知識點,即使在平時的前端開發中,也屬於比較重要的。現在我們繼續一起學習 聲明的解析 吧。 如果對解析的流程有些忘記了朋友,可以往上翻,回看一下第一個函數的解析。
現在我們開始學習帶閉包的函數的解析
function outer() { let treasure = '大寶貝'; // 1. 聲明變量 function inner() { return treasure; // 2. 內部引用(閉包) } return inner; }-
解析外部函數
解析器進入outer函數,創建了 outerscope。
讀到 let treasure 的時候,解析器和以前一樣,進行登記。
“treasure 是個普通變量。按照 V8 的默認省錢規則,這種局部變量應該分配在 棧 (Stack) 上。因為棧最快,而且函數執行完,棧指針一彈,內存自動回收,多省心!”
於是,在 AST 的藍圖上,treasure 被暫時標記為:Stack Local(棧局部變量)。
它被分配了一個臨時的寄存器索引(比如 r0)。
歲月靜好啊。
-
解析內部函數
解析器繼續往下走,看到了 function inner。
這時候,雖然 inner 可能只是預解析,但預解析器依然是需要工作的,它快速掃描 inner 的內部代碼,目的是為了檢查有沒有語法錯誤,以及蒐集變量引用。
掃描器讀到了
return treasure。關鍵時刻來了
- 生成小票:解析器生成了一個
VariableProxy("treasure")(尋找寶藏的小票)。 - 開始兑換:
- 問
InnerScope:“你有treasure嗎?” --- 沒有。 - 順着
outer指針往上爬,問OuterScope:“你有treasure嗎?” ---有!
- 問
找到了!但是,解析器並沒有這就結束,它發現了一件事情:
這個
treasure是定義在outer裏的,但是卻被inner這個下級給引用了!而且inner可能會被返回到外面去執行!這就是 跨作用域引用。
- 生成小票:解析器生成了一個
-
強制搬家
解析器意識到有些麻煩了。
如果 treasure 依然留在 棧 上,那麼等 outer 函數執行完畢,棧幀被銷燬,treasure 就會灰飛煙滅。
等將來 inner 在外面被調用時,它想找 treasure,結果只找到一片廢墟,那程序就崩了。
於是,解析器立馬修改了
OuterScope的藍圖,下達了 “強制搬家令”:-
撕毀標籤:把
treasure身上的 Stack Local 標籤撕掉。 -
貼新標籤:換成 Context Variable(上下文變量)。
-
開闢專區:
V8 決定,在 outer 函數執行時,不能只在棧上幹活了。必須在 堆內存 (Heap) 裏專門開闢一個對象,這就叫 Context (上下文對象)。
-
分配槽位:
treasure 被分配到了這個 Context 對象裏的某個槽位(比如 Slot 0)。
此時的內存藍圖變成了這樣:
- 普通變量(如果有):依然住在棧上,用完即棄。
- 閉包變量 (
treasure):住在堆裏的 Context 對象中,雖死猶生。
-
-
建立連接
既然變量搬家了,那
inner函數怎麼知道去哪找它呢?在生成
inner的SharedFunctionInfo(這個就是在文章剛開始部分講的,預解析時,會生成的佔位符節點和一個SharedFunctionInfo相關聯,SFI中有預解析得到的元信息)時,V8 會記錄下這個重要的情報:注意:本函數是一個閉包。執行時,請務必隨身攜帶父級作用域的 Context 指針。
這就好比 inner 函數隨身帶着一把鑰匙。
不管它流浪到代碼的哪個角落,只要它想訪問 treasure,它就會拿出鑰匙,打開那個被精心保留下來的 Context 保險箱,取出裏面的值。
-
總結一下
在解析層面,閉包不僅僅是“函數套函數”,它是一次 “變量存儲位置的逃逸分析”。
- 沒有閉包時:父函數的變量都在棧上,函數退棧,變量銷燬。
- 有閉包時:解析器發現有內部函數引用了父級變量,強行把該變量從棧挪到堆 (Context)。
這就是為什麼閉包會消耗更多內存。
並不是因為函數沒銷燬,而是因為本該隨着棧幀銷燬的變量,被迫搬到了堆裏,並且必須長期養着它。
現在,再看閉包,是不是感覺看到的不再是代碼,而是 V8 內存裏那一個個被強行保留下來的 Context 小盒子。
-
我記得在前面某個地方,提到過,棧或context中怎麼分配位置, 因為還是在解析階段,都是畫大餅階段, 怎麼來分配具體位置呢?
這個是使用 相對位置 來説的,
比如, 老闆和你説 阿祖 你好好幹 等咱公司有了自己的大樓,第88層出了電梯左手第一間辦公室,就給你用。
旁邊城武眼紅了, 老闆説 城武你也好好幹,第188層出了電梯右手第一間辦公室,給你用。
阿祖和城武感動的當晚就加班到凌晨8點整。
所以,雖然還是藍圖 還在畫大餅 但是相對位置是可以確定的,類似於基址加偏移量的形式。
-
是的,現在又該無中生友了,有初學的朋友,説 ,閉包啊 就是把內部函數需要用到的外部函數的數據 都給打包封閉了。聽起來似乎也可以。 那麼,都包了什麼東西在裏面?是大包 中包 還是小包?
這個可能也不僅是初學朋友的疑惑。
那麼 問題就真的來了:到底是包了多少東西?
V8 是非常摳搜的,它堅持“小包”,但有時候會被迫用中包,甚至大包。
-
默認小包:按需打包 摳搜模式
v8在分析作用域時,會精準計算:
-
變量 A:被內部函數引用了嗎?沒有?好,留你在棧上,用完就銷燬。
-
變量 B:被引用了?好,你搬進 Context 裏去。
只捕獲用到的,絕不浪費一粒米。默認的 小包
-
-
特殊情況一:被迫連坐 中包
function factory() { let heavyData = new Array(1000000); // 這是一個超大的數據 let lightData = '小嘍囉'; function useHeavy() { // 這個閉包用了 heavyData console.log(heavyData.length); } function useLight() { // 這個閉包只用了 lightData console.log(lightData); } // 只把 useLight 返回出去了,useHeavy 根本沒返回,扔了 return useLight; } const myClosure = factory();-
掃描
useHeavy:發現它用了heavyData。---heavyData必須進 Context。 -
掃描
useLight:發現它用了lightData。---lightData必須進 Context。關鍵點來了:
同一個作用域(factory)下生成的閉包,它們共享 同一個 Context 對象。
只要有一個閉包(哪怕是沒被返回的 useHeavy)把 heavyData 拖進了 Context,那麼這個 Context 裏就實打實地存着 heavyData。
雖然只返回了 useLight,但 useLight 手裏握着的鑰匙,打開的是那個 包含了 heavyData 的 Context。
只要 useLight 還要活下去,那個 Context 就得活下去,那個超大的 heavyData 也就得活下去,無法被垃圾回收。
結論:打包的是 中包。同一個作用域下的所有閉包,共享同一個“包”。進了包以後,無法區分哪個被真的return出去,所以兄弟連坐。
-
-
特殊情況二:eval 一鍋端大包
function risk() { let a = 1; let b = 2; // ... 這裏還有 100 個變量 ... return function inner() { eval("console.log(a)"); // 沃特啊油督應? }; }解析器掃描 inner 時,看到了 eval。
瞬間捂着錢包痛哭:“這玩意兒能動態執行代碼,它可能引用 a,也可能引用 b,甚至可能引用我還沒讀到的變量... 根本無法靜態分析它到底要用誰!”
為了安全起見,V8 只能躺平了,
別分析了。把 risk 作用域裏的 所有變量,統統打包進 Context!
這時候,就不再是按需分配了,而是真正的一鍋端的大包。所有變量全部由棧轉堆,性能和內存開銷瞬間拉滿。
這也是為什麼編碼提示裏,都會提醒:不要用 eval 。
不僅是因為安全問題,更是因為它會打爆 V8 的逃逸分析優化,強制保留所有上下文。
-
-
-
上面我們花了很大篇幅講了普通函數的解析。這時候肯定有朋友問:“不是説‘一類四函兩變量’嗎?還有三種函數(異步、生成器、異步生成器)呢?”
實際上,它們用的是同一套模具。
在 V8 裏,
ParseHoistableDeclaration負責接待這四位天王。經過ParseFunctionDeclaration的簡單包裝後,處理函數字面量的入口全都指向同一個苦力:ParseFunctionLiteral。無論是
function、function*、async function還是async function*,它們在 V8 眼裏都是“穿了不同馬甲”的普通函數。解析器只需要在進門時做一次“安檢”,根據
*和async關鍵字打上不同的標籤(Flag),接下來的解析流程——查參數、開作用域、切分代碼塊——完全複用。不過,針對這三位“特權階級”,解析器確實會偷偷做三件不同的小操作:
- 關鍵字變化: 在普通函數裏,
yield和await只是普通的變量名。但在特殊函數裏,解析器會把它們識別為 操作符,生成專門的 AST 節點。 - 夾帶
.generator: 對於生成器和異步函數,解析器會偷偷在作用域裏塞一個隱形的.generator變量。 這是為了將來函數“暫停”時,能把當前的寄存器、變量值等 “案發現場” 保存在這個變量裏。 所以,這幾種函數 天然就是閉包,因為它們必須引用這個隱形的上下文。 - 休息點
Suspend: 解析器會在 AST 裏埋下Suspend(掛起) 節點。 這相當於告訴未來的解釋器:“讀到這兒別硬衝了,得停下來歇會兒,把控制權交出去。”
雖然具體解析時有不少差異,但是,有了前面我們解析普通函數的基礎,再來解析這三種“魔改版”的函數,難度並不大。 我們就不具體展開了,畢竟,函數再美,看多了也會審美疲勞啊。
所以,我們現在學習聲明中的 變量聲明。
雖然前面一直在説 兩變量,那是從規範上説的 var屬於語句, 在 V8 中,let const var 這三個變量聲明 ,是使用同一個解析函數處理的。
有一個核心函數叫
ParseVariableDeclarations。不管解析器讀到的是
var,還是let,還是const,在經過項級分流後,最終都會殊途同歸,調用這個函數ParseVariableDeclarations。下面,我們就開始變量的聲明之旅吧。
-
項級分流
地點:ParseStatementListItem
場景:解析器正在一個大括號 { ... } 或者函數體裏,逐行掃描代碼。
-
**偷看 **:看看下一個 Token 是什麼呢?
-
判斷:
- 如果看到
var? - 如果看到
let? - 如果看到
const?
- 如果看到
-
統一甩鍋:
V8 發現是這三個關鍵字之一,立馬決定:“這是聲明變量的活兒!”
它不再區分你是語句還是聲明,這裏就直接把var也包括進來了,直接把這三兄弟打包,統一調用同一個函數:ParseVariableDeclarations。
但甩鍋的時候,它給每人貼了個不同的參數:
- 遇到
var---傳參kVar - 遇到
let---傳參kLet - 遇到
const--- 傳參kConst
- 遇到
-
-
通用車間
地點:ParseVariableDeclarations
場景:這是三兄弟共用的車間。
這個函數是核心。它不僅要解析
var a = 1,還要負責解析var a = 1, b = 2這種連着寫的,還要負責解構賦值。步驟 1:消費關鍵字
解析器首先根據剛才傳進來的不同參數,調用 consume() 吃掉對應的關鍵字(var/let/const)。
步驟 2:開啓循環
因為 JS 允許 var a, b, c; 這種寫法,所以這裏開啓了一個 do...while 循環,只要看到逗號 , 就繼續。
步驟 3:解析變量名
- 解析器讀取標識符(比如
a)。 - 語法檢查:
- 如果是
let/const,且變量名叫let?--- 報錯 變量名想叫關鍵字 一邊去吧。 - 如果是嚴格模式,變量名叫
arguments或eval?--- 報錯,想在邊緣試探 也一邊去吧。
- 如果是
- 解析器讀取標識符(比如
-
分頭工作
地點:DeclareVariableName (在解析出名字後立刻調用)
場景:名字有了,現在要去Scope Tree(作用域樹) 上登記户口了。這時候,必須根據
參數 來區分待遇。
這裏是邏輯最複雜的地方,也是
var和let行為差異的根源。分支 A:手裏拿的是
kVar參數- 向上穿牆:解析器無視當前的塊級作用域
BlockScope,沿着scope--outer_scope()指針一直往上爬。 - 尋找宿主:直到撞到了一個
FunctionScope或者GlobalScope,函數作用域或全局作用域 是var的目標。 - 登記:在那個高層作用域裏,記錄下名字
a。 - 模式:標記為
VariableMode::kVar,嗯嗯嗯 這裏是內部的東東了。 - 初始化:標記為
kCreatedInitialized(創建即初始化)。意思是:“var這傢伙不用死區,直接給個undefined就能用。”
分支 B:手裏拿的是
kLet或kConst參數- 原地不動:解析器直接鎖定當前的
Scope(哪怕它只是一個if塊)。 - **查重 **:翻開當前作用域的小本本,看看有沒有重名的?
- 有?-- 報錯
SyntaxError: Identifier has already been declared。
- 有?-- 報錯
- 登記:在當前作用域記錄名字
a。 - 模式:標記為
VariableMode::kLet或VariableMode::kConst。 - 初始化:標記為
kNeedsInitialization(需要初始化)。- 這就是 TDZ 的源頭了! 這個標記意味着:在正式賦值之前,誰敢訪問這個位置,就拋錯。
- 注意點: 從這裏能看出 let和const也會提升,只不過let和const的提升是小提升,只在自己的當前作用域裏提升,提升歸提升,沒被真正賦值前,TDZ啊,被送會吹哨子的警衞看守着。
- 向上穿牆:解析器無視當前的塊級作用域
-
處理初始值
地點:回到通用車間
場景:名字登記完了,現在看有沒有賦值號 =。
步驟 1:const 的檢查
- 解析器偷看下一個 Token。
- 如果是
kConst且後面沒有=號?- 直接崩了 拋出
SyntaxError: Missing initializer in const declaration。 var和let會偷笑,因為它們允許沒有=。
- 直接崩了 拋出
步驟 2:解析賦值
- 如果看到了
=,吃掉它。 - 遞歸甩鍋:調用
ParseAssignmentExpression解析=右邊的表達式(比如1 + 2)。。。這裏這裏這裏 前面超大篇幅講過的表達式解析,看到親切嗎?
步驟 3:生成 AST 節點
這裏是 AST 物理結構的生成。
-
對於 var:
由於 var 的名字已經提升走了,這裏剩下的其實是一個 賦值操作。
V8 會生成一個 Assignment 節點(或者類似的初始化節點),掛在當前的語句列表中。
- 意思是:“名字歸上面管,但我得在這裏把值賦進去。”
- 這裏也需要注意,var的名字被提升走了,但是賦值操作還留在這裏呢,在賦值之前,var都是undefined。
-
對於 let / const:
V8 會生成一個完整的 VariableDeclaration 節點,包含名字和初始值。
而且,如果這是 const,V8 會給這個變量打上 “只讀” 的標籤。如果以後 AST 裏有別的節點想修改它,編譯階段或運行階段就會攔截報錯。
這個只讀,是指綁定的引用不可變,如果引用的是個對象,對象內部的內容還是可以改的。
-
收尾嘍
地點:循環末尾
- 逗號檢查:偷看後面是不是逗號
,?- 是 -- 吃掉逗號,回到 通用車間的步驟 3,繼續解析下一個變量。
- 否 --- 結束循環。
- 分號處理:期待一個分號
;。如果沒有,自動分號插入。 - 交貨:返回這一整條語句的 AST 節點。
- 逗號檢查:偷看後面是不是逗號
下面我們再以單個的例子來學習一下
function foo() { if (true) { var a = 1; } }-
Scope 操作:
解析器拿到 a,開始在 Scope Tree 上進行一次爬樹
它會問當前的 BlockScope(if 塊):
“你是函數作用域嗎?你是全局作用域嗎?”
“我不是。”
“好,那我繼續往上找。”
它會跳過 BlockScope,一直找到 FunctionScope(foo 函數)。
然後,調用 DeclareVariableName,把 a 登記在 FunctionScope 的花名冊上。
注意:此時 a 的位置在邏輯上已經屬於 foo 了,儘管物理代碼還在 if 裏。
-
解析器讀到
= 1。 -
AST 生成:
對於 var a = 1,V8 在 AST 層面,通常會把它拆解成兩部分:
- 聲明 (Declaration):
var a。這部分在 AST 上被標記為“可提升”。 - 賦值 (Assignment):
a = 1。
解析器會在當前位置
if塊的語句列表中,生成一個Assignment(賦值) 節點,而不是一個單純的聲明節點。 - 聲明 (Declaration):
-
Scope 樹:名字被“穿牆”提到了頂層。
-
AST 樹:原地留下了一個賦值節點
a = 1。 -
這就是為什麼
var有提升(名字上去了),但賦值沒提升(賦值節點還在原地)。
{ let b = 2; }-
動作:消費
let,讀到標識符b。 -
Scope 操作:
解析器直接鎖定當前的 BlockScope。
它不往上找,而是立刻查閲當前的花名冊:
“這裏面有叫
b的嗎?”- 如果有:重複定義,報錯 拋出
SyntaxError: Identifier 'b' has already been declared。 - 如果沒有:登記
- 如果有:重複定義,報錯 拋出
-
Scope 操作(關鍵):
在登記 b 的時候,V8 會給它打上一個特殊的 Mode:kLet。
並且在初始化標記位上,打上 kNeedsInitialization(需要初始化)。
在前面的三個變量一起講的例子裏講過了,這就是 TDZ 的物理來源。這個標記表示:“在給 b 賦值之前,任何訪問都要拋錯。”
-
解析器讀到
= 2。 -
AST 生成:
這次不像var那樣需要拆分了。
解析器直接在當前位置,生成一個 VariableDeclaration 節點。
這個節點包含:
- Proxy:變量
b的引用。 - Initializer:字面量
2。 - Mode:LET。
該節點被直接 Push 到當前
Block的語句列表中。 - Proxy:變量
-
Scope 樹:名字登記在當前塊,不可重複,標記為死區狀態。
-
AST 樹:原地生成一個完整的
VariableDeclaration節點。
還剩下const了,
const的流程和let幾乎一模一樣,只有兩個額外的檢查環節。第一,必須帶初始值
- 在解析完變量名之後,解析器會立刻偷看下一個 Token。
- 如果不是
=? - 沒有初始化,報錯 拋出
SyntaxError: Missing initializer in const declaration。 const變量出生必須帶值,這是語法層面的規定。
第二, 只讀屬性
-
Scope 操作:
在登記const的變量時,它的 Mode 被標記為 kConst。
這表示在 Scope 的記錄裏,這個變量是 Immutable 不可變 的。
如果 AST 的其他地方試圖生成一個 Assignment 節點去修改const聲明的變量,雖然解析階段可能不會立刻報錯(有時要等到運行時),但是後續一定會在寫入只讀變量的操作時,被攔截並拋錯。
- 關鍵字變化: 在普通函數裏,
-
上面講了var let const 三種變量的解析。我們繼續聲明的解析,還有一個類。
class Hero { name = '阿祖'; // 1. 實例字段 (Field) static version = '1.0'; // 2. 靜態屬性 (Static) constructor(skill) { // 3. 構造函數 this.skill = skill; } say() { // 4. 原型方法 return '我是' + this.name; } }-
環境初始化
當解析器讀到class關鍵字的時候,還沒看到內容,就必須先做三件事。
- 強制開啓嚴格模式
- 解析器將當前的
language_mode標誌位強行設置為kStrict。 - 一旦跨過
Hero {這道門檻,所有嚴格模式的規則立即生效(比如禁用with,禁用arguments和參數不再綁定等)。
- 解析器將當前的
- 創建類作用域
- V8 調用
NewClassScope,創建一個新的作用域對象。 - 户籍登記:解析器讀到標識符
Hero。它立刻在這個新的作用域裏,聲明一個名字叫Hero的變量。 - 鎖起來:這個變量被標記為
CONST(常量)。這表示在類體內部,Hero = 1這種代碼會在解析階段直接報錯。 - 目的:這是為了讓類內部的方法能引用到類本身(自引用)。
- V8 調用
- 初始化列表
- 解析器在內存裏準備了三個空的列表(List),用來分類存放即將切割下來的不同部位,像超市裏雞腿 雞翅 雞雜 分開擺盤:
instance_fields(實例字段列表):存放name = ...這種。static_fields(靜態字段列表):存放static version = ...這種。properties(方法屬性列表):存放say(),constructor這種。
- 解析器在內存裏準備了三個空的列表(List),用來分類存放即將切割下來的不同部位,像超市裏雞腿 雞翅 雞雜 分開擺盤:
- 強制開啓嚴格模式
-
開始解析
現在,解析器進入大括號
{ ... },開始掃描。name = '阿祖';—— 實例字段的解析- 識別 Key:解析器讀到
name。 - **偷看 **:往後偷看一眼,發現是
=。 - 判定:這不是方法,這是一個 Field (字段)。且沒有
static,所以是 Instance Field (實例字段)。 - 解析:
- 解析器把
=後面的'阿祖'作為一個 表達式 進行解析。 - 生成一個
Literal字符串節點。
- 解析器把
- 包裝:
- 關鍵:消費完了以後,V8 不會把
'阿祖'直接扔掉。它會創建一個 "合成函數" (Synthetic Function) 的外殼。 - 為什麼要包一層? 這是 V8 為了隔離作用域而採用的策略。字段初始化表達式裏可能會有
this,或者複雜的邏輯。通過封裝成一個獨立的函數殼,V8 確保了它和構造函數的參數(比如skill)互不干擾,這也符合 JS 規範:字段定義本來就看不見構造函數的參數。 - 劃重點:
name的值怎麼算,被封裝成了一個可以在未來執行的函數。 - 這裏需要注意,= 號後面的值,並不是一次性使用,有可能被使用很多次,雖然我們例子中是 阿祖,但是 也可能是其他包含邏輯的計算值,所以,我們需要的不是值,而是如何生成這個值的 整個邏輯, 因此 解析出來以後,給它包上一層帶獨立作用域的函數殼。
- 關鍵:消費完了以後,V8 不會把
- 歸檔:把這個合成函數扔進
instance_fields列表。
static version = '1.0';—— 靜態字段的解析-
識別:讀到
static關鍵字。 -
標記:開啓
is_static標誌位。 -
識別 Key:讀到
version。 -
偷看:看到
=。 -
判定:這是一個 Static Field (靜態字段)。
-
解析與歸檔:
- 解析
'1.0'生成字符串節點。 - 同樣包裝成一個“合成函數”。
- 扔進
static_fields列表。 - 注意:這個列表將來是要掛在
Hero構造函數對象本身上的,不是掛在this上的。
- 解析
constructor(skill) { ... }—— 核心內容的解析- 識別:讀到
constructor關鍵字。 - 判定:這是類的 核心構造函數。
- 解析函數體:
- 解析參數
skill。 - 解析代碼塊
this.skill = skill。 - 生成一個
FunctionLiteral節點。
- 解析參數
- 歸檔:雖然它是核心內容,但在 AST 組裝前,它暫時被存在一個叫
constructor_property的特殊槽位裏,等待後續的組裝。
say() { ... }—— 原型方法的解析- 識別:讀到
say,後面緊跟(。 - 判定:這是一個 Method (方法)。
- 屬性描述符生成 (Property Descriptor):
- 這是類和對象最大的不同點。V8 會盤算着
writable: trueconfigurable: trueenumerable: false(類的方法默認不可枚舉)
- HomeObject 綁定:
- 解析器會給
say函數標記一個HomeObject。這是為了如果你在say裏用了super,它知道去哪裏找父類。
- 解析器會給
- 歸檔:把生成的
say函數節點,扔進properties列表。
- 識別 Key:解析器讀到
-
進行脱糖
掃描完
},所有的配件都擺好了。馬上開始的,這就是傳説中的 脱糖 過程。類是語法糖,現在,我們要脱糖。
-
改造構造函數
- 拿出剛才解析好的
constructor函數節點。 - 定位:
- V8 尋找函數體的 起始位置。
- 如果有繼承 (
extends),位置在super()調用之後(因為super返回前this還沒出生)。 - 沒有繼承,位置就在函數體的 最前面。
- 添加:
- V8 把
instance_fields列表裏的內容拿出來(那個name = '阿祖'的合成函數)。 - 它將其轉化為賦值語句 AST:
this.name = '阿祖'。 - 它把這條語句 插入 到
constructor原本的用户代碼this.skill = skill之前。
- V8 把
此時,在 V8 的內存 AST 中,構造函數實際上變成了這樣:
// V8 內存中的構造函數(偽代碼) function Hero(skill) { // --- V8 添加的字段初始化邏輯 --- // 注意:這裏是一個隱式的 Block // 是因為這裏是由合成函數轉化的,包含了邏輯 也包含了獨立的作用域 this.name = '阿祖'; // ----------------------------- // --- 用户寫的邏輯 --- this.skill = skill; } - 拿出剛才解析好的
-
組裝 ClassLiteral
現在構造函數改造完畢,V8 開始組裝最終的
ClassLiteral節點。- 掛載構造函數:把改造後的
Hero函數放c位。 - 掛載原型方法:
- 遍歷
properties列表。 - 拿出
say。 - 生成指令:在運行時,將
say掛載到Hero.prototype上,並設置enumerable: false。
- 遍歷
- 掛載靜態字段:
- 遍歷
static_fields列表。 - 拿出
version = '1.0'。 - 生成指令:在類創建完成後,立刻執行
Hero.version = '1.0'。
- 遍歷
- 關聯作用域:把最開始創建的
ClassScope關聯到這個節點上。
- 掛載構造函數:把改造後的
-
-
完成嘍
儘管我們寫的是一個class,但是,實際的解析過程如下
- 開啓嚴格模式。
- 創建一個叫
Hero的常量環境。 - 定義一個叫
Hero的函數。- 函數體內:先執行
this.name = '阿祖'。 - 函數體內:再執行
this.skill = skill。
- 函數體內:先執行
- 定義一個叫
say的函數。- 把它掛到
Hero.prototype上,設為不可枚舉。
- 把它掛到
- 定義一個叫
version的值。- 把它直接掛到
Hero函數對象上。
- 把它直接掛到
- 返回這個
Hero函數。
你會發現,解析器最終生成的是一個表示類的
ClassLiteral,但也是僅是名字而已,其他的所有內容,已經脱糖為函數、賦值、原型掛載 這些js語法。所以,從 V8 的實現上來説,類解析的本質,就是解析器通過引入 合成函數 和 代碼植入 等手段,把現代化的語法糖,翻譯成了底層引擎能理解的函數、作用域和原型操作。
-
-
我們前面首先學習的就是語句裏的兜底表達式的解析,然後是聲明中的 函數 變量 類, 現在就還剩語句中的可以用關鍵字甩鍋的部分了。
我們回到ParseStatementListItem 的分流路口,如果來的 Token 不是 class,不是 function,也不是 var/let/const,那它極大可能就是一個普通的 語句Statement。
解析器大手一揮:“去吧,找 ParseStatement。”
ParseStatement是語句解析的總調度
場景:這是普通語句的總調度中心。
邏輯:查表分發(Lookahead Dispatch)。
解析器盯着當前 Token 的臉,看關鍵字是什麼,然後決定甩鍋給誰:
- 看到
{? - 甩給ParseBlock(代碼塊)。 - 看到
if? -甩給ParseIfStatement(條件判斷)。 - 看到
for/while/do? -甩給循環解析家族。 - 看到
return/break/continue? -甩給跳轉解析家族。 - 啥關鍵字都不是?(比如
a = 1 + 2;) -甩給ParseExpressionStatement(表達式語句)。這是兜底的,也是最常見的,也是我們花了大力氣學習過的。
代碼塊解析:
{ ... }當解析器看到
{時,它知道這是一個 Block (塊)。解析流程:
- 消費:吃掉
{。 - 遞歸:此時,彷彿又回到了世界起源。解析器會再次調用那個最最最核心的循環驅動者 ——
ParseStatementList。- 這就是為什麼代碼可以無限嵌套:塊裏套塊,套娃套娃娃。
- 消費:吃掉
}。
注意:透明作用域 (Scope Optimization)
這裏有個比較重要的地方,我們在寫代碼時,看到 {...} 就會本能地覺得:“這有一個塊級作用域”。
但在 V8 眼裏,不一定。 V8 非常摳搜,它會根據塊裏的內容決定要不要建牆--塊級作用域。
場景 A:透明的框
{ var a = 1; console.log(a); }V8 掃描這個塊,發現裏面只有 var(或者普通語句),沒有 let/const/class。
V8 會想:“欸,只有 var 這種穿牆怪?或者只是普通的計算?那我沒必要專門申請一個 BlockScope 對象浪費內存了。”
結果就是 這個 Block 在 Scope 樹上是 透明 的。AST 上雖然有 Block 節點,但它不對應任何 Scope。變量 a 直接登記在所在的函數作用域裏。
場景 B:實體的牆
{ let b = 1; }V8 掃描到了 let。
V8 拱手:“新貴小王子,必須給待遇。”
結果就是 V8 才會真的創建一個 BlockScope,把 b 關在裏面。
所以,代碼塊
{}在 AST 上肯定是個 Block 節點,但在 Scope 樹上不一定有對應的節點。這個問題,在前面我們好像已經講過兩三次了,多講一次,就當加深印象了。
條件判斷:
if當解析器看到
if時,甩鍋給ParseIfStatement。解析流程:
- 消費:吃掉
if,吃掉(。 - 條件:調用
ParseExpression解析條件(比如a > 1),拿到 Condition 節點。 - 消費:吃掉
)。 - Then 分支:調用
ParseStatement解析then的部分。 - Else 分支:
- 偷看:後面有
else嗎? - 有:吃掉
else,調用ParseStatement解析else的部分。 - 沒有:那
else部分就是空的。
- 偷看:後面有
遇到語法歧義問題匹配哪個呢?
if (a) if (b) x++; else y++;這個
else到底屬於哪個if?是屬於if(a)還是if(b)?V8使用 “貪婪匹配” 原則:
else 總是匹配最近的、還沒配對的那個 if。
所以在 AST 裏,這個 else 是掛在內層 if (b) 後面的。如果你想讓它屬於外層,必須顯式地加 {},所以 ,從寫法上減少這些歧義是最好的。
循環解析:
forwhile 和 do-while 比較簡單,我們重點講最複雜的 for 循環。
當解析器看到 for,甩鍋給 ParseForStatement。
AST 的結構:
V8 會生成一個 ForStatement 節點,它有 4 個插槽:
- Init (初始化):比如
let i = 0。 - Cond (條件):比如
i < 10。 - Next (步進):比如
i++。 - Body (循環體):比如
{ console.log(i) }。
嗯嗯嗯,這裏又有個面試官容易被吊打的地方了
就是 for 循環作用域問題,V8 在這裏做了比較複雜的處理。
如果這裏用的是 var,V8 根本不管,直接扔給外層函數作用域。
但如果是 let,V8 必須製造出 “多重作用域” 的效果。
在解析
for(let ...)時,V8 會在 AST 和 Scope 樹上構建出 兩層 甚至 N+1 層 作用域:-
循環頭作用域 (Loop Header Scope):
-
循環體作用域 (Loop Body Scope):
-
迭代作用域 (Per-Iteration Scope):
。。。。。。看起來似乎挺複雜,實際上也不是很簡單,所以我們需要仔細耐心的學習。
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); }分析這個例子
第一階段:主線程 循環階段
因為
var聲明的i沒有塊級作用域,它是一個全局變量(或函數作用域變量)。在這個內存裏,只有一個i。- 初始化:
i = 0。- 檢查
0 < 3?是的。 - 遇到
setTimeout:瀏覽器把“打印 i”這個任務記在宏任務隊列的小本本上。注意:此時不執行打印,也不存 i 的值,只是記下“回頭要找 i 打印”這件事。
- 檢查
- 步進:
i變成 1。- 檢查
1 < 3?是的。 - 遇到
setTimeout:再記一筆“回頭找 i 打印”。
- 檢查
- 步進:
i變成 2。- 檢查
2 < 3?是的。 - 遇到
setTimeout:再記一筆“回頭找 i 打印”。
- 檢查
- 步進(關鍵步驟):
i變成 3。- 檢查
3 < 3?不成立! - 循環結束。
- 檢查
重點來了: 此時循環結束了,變量
i停留在什麼值? 答案是 3。 因為它必須變成 3,條件判斷i < 3才會失敗,循環才會停止。第二階段:異步隊列回調 打印階段
現在主線程空閒了,Event Loop 開始處理剛才記在小本本上的
setTimeout任務。- 第 1 個回調運行:
console.log(i)。- 它去內存裏找
i。 - 這時候的
i是多少?是 3。 - 打印:3。
- 它去內存裏找
- 第 2 個回調運行:
console.log(i)。- 它還是去同一個內存地址找
i。 i還是 3。- 打印:3。
- 它還是去同一個內存地址找
- 第 3 個回調運行:
- 同理,打印:3。
這個例子的重點:
一:“循環到 2 就結束了,所以 i 應該是 2”
- 實際情況:循環體確實只執行到
i=2的時候。但是for循環的i++是在循環體執行之後執行的。最後一次,i從 2 變成了 3,然後判斷3 < 3失敗,才退出的。所以i的最終屍體是 3。
二:“setTimeout 會捕獲當時的 i”
- 實際情況:
var不會捕獲快照。因為var只有一個共享的i,閉包引用的是引用(地址),而不是值(快照)。等到打印的時候,大家順着地址找過去,看到的都是那個已經變成 3 的i。
我們再來看這個例子
for (var i = 0; i < 3; i++) { let x = i; setTimeout(() => console.log(x), 0); }這裏有兩個變量:
var i:公共大掛鐘- 定義位置:
for循環頭部。 - 性質:
var。 - 住址:函數作用域(或者全局)。它就像掛在牆上的唯一的一個大時鐘。不管循環跑多少次,大家都看這同一個時鐘,它的指針一直在變(0 - 1 - 2 - 3)。
let x私人的手錶:- 定義位置:循環體
{ ... }內部。 - 性質:
let。 - 住址:Block Scope(塊級作用域)。它就像是你手裏拿的記事本。每次循環,V8 都會撕一張新的紙(創建新作用域)給你。
這個例子的核心邏輯在於
let x = i;。 對於 v8來説 就是 “請把牆上那個公共時鐘(i)當前的時間,複印一份,寫在我這張新的紙(x)上。”第一輪循環 (i = 0)
- 公共時鐘
i:指向 0。 - 進入房間:V8 遇到
{,創建一個全新的 Block Scope A。 - 執行
let x = i:- V8 在 Scope A 裏創建變量
x。 - 讀取外面的
i(0)。 - 賦值:
x = 0。
- V8 在 Scope A 裏創建變量
- 閉包生成:
setTimeout裏的箭頭函數生成。- 關鍵點:它捕獲的是誰?是 Scope A 裏的
x。 - 此時,這個閉包手裏緊緊攥着 x=0 的照片。
第二輪循環 (i = 1)
- 公共時鐘
i:變成了 1(注意:i 還是那個 i,只是值變了)。 - 進入房間:V8 遇到
{,創建一個全新的 Block Scope B(和 A 沒關係)。 - 執行
let x = i:- V8 在 Scope B 裏創建變量
x。 - 讀取外面的
i(1)。 - 賦值:
x = 1。
- V8 在 Scope B 裏創建變量
- 閉包生成:
- 生成第二個箭頭函數。
- 它捕獲的是 Scope B 裏的
x。 - 這個閉包手裏攥着 x=1 的照片。
第三輪循環 (i = 2)
- 公共時鐘
i:變成了 2。 - 進入房間:創建 Block Scope C。
- 執行
let x = i:x = 2。
- 閉包生成:
- 捕獲 Scope C 裏的
x。 - 手裏攥着 x=2 的照片。
- 捕獲 Scope C 裏的
循環結束了。
- 公共變量
i:變成了 3。如果這時候有人打印i,那就是 3。 - 剛才那三個閉包(定時器回調),根本不關心
i是多少。
當 0ms 之後,定時器觸發:
- 回調 1:拿出 Scope A 裏的
x- 打印 0。 - 回調 2:拿出 Scope B 裏的
x- 打印 1。 - 回調 3:拿出 Scope C 裏的
x- 打印 2。
這個例子是利用了
let在 Block 裏的生命週期。var i負責在外面跑動,不斷變化,維持循環的進行。let x負責在裏面定格,每次循環都創建一個新的實例,把那一瞬間的i值給“固化”下來。
上面是簡單的講了一下var 和let配合的正確方式。 現在,我們回到使用let的例子
for (let i = 0; i < 3; i++) { let x = i; setTimeout(() => console.log(x), 0); }這個才是我們for循環重點的例子。
當解析器讀到
for (let i ...)時,它在 Scope Tree 上並不是簡單地掛一個 BlockScope,而是構建了一個精密的層級。第 1 層:外層作用域 (Outer Scope)
這是
for循環所在的地方(比如函數作用域)。沒有什麼特殊的。第 2 層:循環頭作用域 (Loop Header Scope)
這是關鍵層!
- 誕生時刻:解析器讀到
for (且發現後面跟着let時,立刻創建。 - 住户:循環變量
i就住在這裏。 - 職責:它包裹着整個循環,包括初始化、條件判斷、步進操作。它就像是循環的總指揮部。
第 3 層:循環體作用域 (Loop Body Scope)
- 誕生時刻:解析器讀到
{時創建。 - 住户:循環體內的變量
x住在這裏。 - 關係:它的
outer指針指向 循環頭作用域。
為了滿足“每次循環都是新
i”的變態要求,V8 會悄悄的把代碼進行重寫偽代碼 { // 1. 循環頭作用域 (Header Scope) let i = 0; // 真正的 i 聲明在這裏 // 循環開始 loop_start: if (i < 3) { // 2. [v8偷摸施法] 迭代作用域 (Iteration Scope) // V8 會在每次進入循環體前,悄悄的創建一個新作用域 // 並且把當前的 i 值,"複印" 給一個臨時變量 { let _k = i; // 影子變量,捕獲當前的 i // 3. 循環體作用域 (Body Scope) { let x = _k; // 用户寫的 x = i,實際上變成了 x = _k setTimeout(() => console.log(x), 0); } } // 步進操作 i++; goto loop_start; } }這段偽代碼很簡單,解析器在分析作用域時,識別出 for 頭部定義了 let,並且循環體內有閉包引用了這個 let。
於是,它悄悄開啓自己的魔法-迭代的作用域
所以
- 物理上:
i確實只有一個,在 Header Scope 裏,不斷++變成 0, 1, 2, 3。 - 邏輯上:每次進入大括號,V8 都會偷偷創建一個 影子作用域。
- 複印:在這個影子作用域裏,V8 會把此刻的
i的值,賦值給一個新的隱藏變量 偽代碼裏我們叫它_k。 - 捕獲:循環體裏的閉包,實際上捕獲的不是那個一直在變的
i,而是這個 永遠不會變的影子變量_k。
下面,我們再詳細的走一下流程:
步驟 1:解析頭部
for (let i = 0;- 消費:
for,(,let,i。 - Scope 操作:創建 Loop Header Scope。
- 登記:在 Header Scope 裏登記變量
i。 - AST:生成
ForStatement節點,把let i = 0掛在 Init 插槽。
步驟 2:解析條件與步進
; i < 3; i++)- 解析:在 Header Scope 的環境下解析
i < 3和i++。 - 關聯:這裏的
i指向 Header Scope 裏的i。
步驟 3:解析循環體
{ ... }- 消費:
{。 - Scope 操作:創建 Loop Body Scope。
- 連接:Body Scope 的爸爸是 Header Scope。
步驟 4:解析
let x = i- 登記:在 Body Scope 裏登記變量
x。 - 查找
i:- Body Scope 裏有
i嗎?無。 - Header Scope 裏有
i嗎?有! - 關鍵判定:解析器發現
i是 Header Scope 裏的let變量,而且正在被內部作用域引用。 - 打個標記:解析器給
i打上 "需按迭代拷貝 (Copy on Iteration)" 的標籤。
- Body Scope 裏有
步驟 5:解析閉包
setTimeout(...)- 閉包引用了
x。 x引用了i(實際上是那個影子的i)。- 解析器確認:這不僅是個閉包,還是個 Loop 裏的閉包。必須強制把這些變量分配到 堆內存 (Context) 中,不能留在棧上。
這個for講起來很費勁的吧
是因為表面上只聲明瞭一個
i,但實際上(AST/Scope) V8 構建了 Header Scope(放真正的
i)和 Body Scope(放循環體)。運行的時候 V8 通過 影子變量拷貝技術,在每一輪循環裏都生成了一個新的、只屬於這一輪的
i的副本。閉包鎖死的是這個副本,而不是外面那個一直在變的本體。我們也甩個鍋,甩給規範:
為什麼
for (let ...)比for (var ...)複雜?
因為規範要求對let循環變量實現 per-iteration(每次迭代)語義:表面上你只寫了一個i,但每輪迭代要表現為一個新的綁定副本,以便閉包捕獲到的是該輪的快照。var沒有塊級綁定(它是函數/全局作用域的共享綁定),因此不會產生快照效果。跳轉語句:
returnreturn
、break、continue 的解析邏輯都很直白:“吃掉關鍵字 --檢查分號”。但
ParseReturnStatement有一個巨大的坑,叫做 ASI (自動分號插入)。看這段
return true;解析器讀到
return後,它的動作是這樣的:- 偷看:偷看下一個 Token。
- 發現:哎喲,是一個 換行符 (LineTerminator)。
- 判定:根據 JS 語法規則,
return後面不能跟換行符。既然你換行了,我就當你寫完了。 - 插入:V8 強行在這裏插入一個分號
;。 - 結果:代碼變成了
return;(返回undefined)。下面的true;變成了永遠執行不到的廢話。
這就是為什麼要強調的:
return的值千萬別換行寫!兜底:表達式語句
這個就不用講了,都講的頭暈了。
原本是想全部寫完以後再發的,但是現在解析篇寫完就已經三萬五千多字了,篇幅太大,不知道發文章有沒有單篇字數限制,就一篇一篇的發吧。
至此解析篇 的內容全部結束。
- 看到
靜態的旅程結束了。 接下來,是一個新的開始-------------Ignition 解釋器篇。
本文首發於: 掘金社區
同步發表於: csdn
博客園
碼字雖不易 知識脈絡的梳理更是不易 ,但是知識的傳播更重要,
歡迎轉載,請保持全文完整。
謝絕片段摘錄。
參考資料:
https://github.com/v8/v8
https://v8.dev/blog
https://v8.dev/blog/scanner
https://v8.dev/blog/preparser
https://tc39.es/ecma262/