博客 / 詳情

返回

V8引擎 精品漫遊指南 -解析篇 語法解析 AST 作用域 閉包 字節碼 優化 一文通關

這是完整的一篇超長文章,內容為javascript V8引擎的 詞法分析 語法分析 編譯 執行 優化 等完整的一個鏈條,內容詳略得當 可以按需要部分閲讀 也可以通篇仔細觀看。

依舊是無圖無碼,網文風格。我覺得,能用文字把邏輯或者概念表述清楚,一是對作者本身的能力提升有好處,二是對讀者來説 思考文字表達的內容 有助於多使用抽象思維和邏輯思維能力,構建自己的思考模式,用現在流行的説法 就是心智模型。你自己什麼都可以腦補,那不是厲害大了嘛。

上面的話不要相信,其實我就是為自己懶找的藉口。

這部分內容,能學習瞭解,當然最好,對平時的前端開發,也有好處,不瞭解,也不影響日常的工作。但是總體來説,很多開發中的問題,在這部分內容中 都可以找到根源。有些細節做了省略 有些邊界情況做了簡化表述。不過 , 準確性還是相當不錯的。依舊是力求高準確性,符合規範,貼合實現。

篇幅比較長,可以按需要閲讀,內容鏈條如下:

1識別-2流式處理-3切分-4預解析和全量解析-5解析概述-6解析具體過程.表達式的解析-7聲明的解析-8函數的解析-9變量的解析-10類的解析-11語句的解析

其中包含單個完整的知識點分散在各部分:閉包 作用域 作用域鏈/樹 暫時性死區。。。可搜索關鍵字查找。

版權聲明呢。。。碼字不易,純腦力狂暴輸出更不易

歡迎以傳播知識為目的全文轉載,謝絕片段摘錄。 謝絕搞私域流量的轉載。

一.詞法分析和語法分析

當瀏覽器從網絡下載了js文件,比如app.js,瀏覽器引擎拿到的最初形態是一串**字節流 **。

  1. 識別:瀏覽器根據 HTTP 響應頭,通常是 Content-Type: text/javascript; charset=utf-8 將下載的字節流解碼為字符流並交給 V8。V8 在內存中存儲字符串時採用動態編碼策略:在可行的情況下優先使用單字節(Latin-1)格式存儲,只有當字符串中出現 Latin-1 範圍外的字符(如中文、Emoji)時,才會轉為雙字節(UTF-16)格式。

  2. 流式快速處理: 引擎並不是等整個文件下載完才開始幹活的。只要網絡傳過來一段數據,V8 的掃描器就開始工作了。 這樣可以加快啓動速度。此時的狀態就是毫無意義的字符 c, o, n, s, t, , a, , =, , 1, ; ...

  3. 然後的這一步叫 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 (符號 ";")

    這一步,註釋和多餘的空格和換行符會被拋棄。

  4. 現在就是解析階段了

    其實解析是一個總稱,它分為 全量解析 和 預解析 兩種形式。

    這就是v8的懶解析機制。看到這個懶字,也差不多能明白了吧。

    對於那些不是立即執行的函數(比如點擊按鈕才觸發的回調),V8 會先用預解析快速掃一遍。

    檢查基本的語法錯誤(比如有沒有少寫括號),確認這是一個函數。並不會生成複雜的 AST 結構,也不建立具體的變量綁定,只進行最基礎的閉包引用檢查。御姐喜的結果是這個函數在內存裏只是一個很小的佔位符,跳過內部細節。

    而只有那些立即執行函數或者頂層代碼,才會進入真正的全量解析,進行完整的 AST 構建。

    那麼,問題就來了,v8怎麼判斷到底是使用預解析還是使用全量解析呢?

    它的原則就是 懶惰為主 全量為輔

    就是v8默認你寫的函數暫時不會執行,除非是已經顯式的通過語法告訴它,這段這行代碼 馬上就要跑 你趕快全量解析。

    下面 我們稍微詳細的説一下

    • 默認絕大多數函數都是預解析

      v8認為js在初始運行時,僅僅只有很少很少一部分代碼 是需要馬上使用的 其他覺得大部分 都是要麼是回調 要麼是其他的暫時用不到的,所以,凡是具名函數聲明、嵌套函數,默認都是預解析。

      function clickHandler() {
        console.log("要不要解析我");
      }
      // 引擎認為 這是一個函數聲明  看起來還沒人調勇它
      // 先不浪費時間了,只檢查一下括號匹配吧,
      // 把它標記為 'uncompiled',然後跳過。"
      
    • 那麼 如何才能符合它進行全量解析的條件呢

      1. 頂層代碼

        寫在最外層 不在任何函數內 的代碼,加載完必須立即執行。

        判斷依據: 只要不在 function 塊裏的代碼,全是頂層代碼,必須全量解析。

      2. 立即執行函數

        那麼這裏有個問題,就是V8 如何在還沒運行代碼時,就知道這個函數是立即調用執行函數呢?

        答案就是 看括號()

        當解析器掃描到一個函數關鍵字 function 時,它會看一眼這個 function 之前有沒有左括號 (

        • 沒括號

          function foo() { ... }
          // 沒看到左括號,那你先靠邊吧, 對它預解析。
          
        • 有括號

          (function() { ... })();
          // 掃描器掃到了這個左括號
          // 欸,這有個左括號包着 function
          // 根據萬年經驗,這是個立即執行函數,馬上就要執行。
          // 直接上大菜,全量解析,生成 AST
          
        • 其他的立即執行的跡象:除了括號,!+- 等一元運算符放在 function 前面,也會觸發全量解析

          !function() { ... }(); // 全量解析
          
      3. 除了這些以外, v8還有一些啓發式的規則來觸發全量解析。比如 如果是體積很小的函數,V8 有時也會直接全量解析,因為預解析再全量解析的開銷可能比直接解析還大。。。等等。

    • 如果有嵌套函數咋辦呢

      嵌套函數默認是預解析,即使外部函數進行的是全量解析,它內部定義的子函數,默認依然是預解析。只有當子函數真的被調用時,V8 才會暫停執行,去把子函數的全量解析做完 把 AST 補齊

      //頂層代碼全量解析
      (function outer() {
        var a = 1;
      
        // 內部函數 inner:
        // 雖然 outer 正在執行,但 inner 還沒被調用
        // 引擎也不確定 inner 會不會被調用。
        // 所以inner 默認預解析。
        function inner() {
          var b = 2;
        }
      
        inner(); // 直到執行到這一行,引擎才會回頭去對 inner 進行全量解析
      })();
      
    • 那麼 引擎根據自己的判斷 進行全量解析或者預解析,會出錯嗎

      當然會,

      如果是本該預解析的 結果判斷錯了 進行了全量解析 浪費了時間和內存生成了 AST 和字節碼,結果這代碼根本沒跑。

      如果是本該全量解析的又巨又大又重的函數 結果判斷錯了 進行了預解析,然後馬上下一行代碼就調用了,結果就是 白白預解析了一遍,浪費了時間,發現馬上被調用,又馬上回頭全量解析一邊 又花了時間,兩次的花費。

  5. 在上面只是講了解析階段的預解析和全量解析的不同,現在我們講解析階段的過程

    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父作用域。
    • 結果: 這種“父子關係”是靜態鎖定的。無論你將來在哪裏調用這個函數,它的“父級”永遠是定義時的那個作用域。

    變量引用關係被識別

    這是解析器最忙碌的工作之一,叫做 變量解析

    • 聲明: 當解析器遇到 let a = 1,它會在當前 Scope 記錄:“我有了一個叫 a 的變量”。
    • 引用: 當解析器遇到 console.log(a) 時,它會生成一個 變量代理
    • 鏈接過程: 解析器會嘗試“連接”這個代理和聲明:
      1. 先在當前 Scope 找 a
      2. 找不到?沿着 Scope Tree 往上找父作用域。
      3. 找到了?建立綁定。
      4. 一直到了全局還沒找到?標記為全局變量(或者報錯)。

    這裏要注意: 這個“找”的過程是在編譯階段完成的邏輯推導。

    閉包的藍圖被預判

    這一步是 V8 性能優化的關鍵,也就是作用域分析。

    • 發現閉包: 解析器發現內部函數 inner 引用了外部函數 outer 的變量 x

    • 打個大標籤:

      • 解析器會給 x 打上一個標籤:“強制上下文分配”
      • 意思是:“雖然 x 是局部變量,但因為有人跨作用域引用它,所以它不能住在普通的棧(Stack)上了... 必須搬家,住到堆(Heap)裏專門開闢的 Context(上下文對象) 中去。”
    • 還沒有實例化:

      • 此時內存裏沒有上下文對象,也沒有變量 x 的值(那是運行時的事)。

      • AST 只是生成了一張“藍圖”,圖紙上寫着:“注意,將來運行的時候,這個 x 要放在特別的地方 - Context裏,別放在棧上。”

  6. 現在 我們來複一下盤 重點學習解析過程

    字節流---被切成有語法意義的最小單元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 的形式,嘗試匹配所有具有確定起始關鍵字或符號的語句形式(如 ifforreturn{ 等)。匹配上以後 對準那個匹配成功的解析函數,甩鍋下去。其他尚未識別的 則甩給表達式解析,這是因為表達式的形式有很多,而且無法根據關鍵字來識別,所以 可以説表達式解析是個兜底。 如果是被甩鍋到表達式解析,首先由表達式的賦值解析接手, 解析流程統一從 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

      1. 賦值層啓動:賦值解析拿到 m,消費掉 = 號,並記住 =
      2. 開始第一次遞歸調用(賦值表達式解析):為了解析右值。
        • 甩鍋環節:拿到 1,不認識,甩甩甩...
        • 1 節點被返回,返回到 二元解析(Level 0) 這裏。
      3. 二元解析(Level 0)
        • 狀態:接收 1 節點。
        • 偷看+ 號(優先級 12)。
        • 判斷:當前門檻 0,12 > 0,消費 + 號,記憶 + 號。
        • 遞歸調用:調用二元解析,門檻設為 12。
      4. 第一次遞歸二元解析(Level 1)開始
        • 甩鍋環節2 不認識,甩甩甩... 返回 2 節點。
        • 狀態:接收 2 節點。
        • 偷看* 號(優先級 13)。
        • 判斷:當前門檻 12,13 > 12,可以吃! 消費 * 號,記憶 * 號。
        • 遞歸調用:調用二元解析,門檻設為 13。
      5. 第二次遞歸二元解析(Level 2)開始
        • 甩鍋環節3 不認識,甩甩甩... 返回 3 節點。
        • 狀態:接收 3 節點。
        • 偷看:沒了(或者分號)。
        • 判斷:優先級不夠。
        • 返回:直接返回 3 節點。
      6. 回到第一次遞歸(Level 1)
        • 組裝:接收到 3 節點。左手是 2,右手是 3,記憶是 *
        • 動作:組合成 2 * 3 節點。
        • 返回:把 2 * 3 節點往上交。第一次遞歸結束。
      7. 回到二元解析(Level 0)
        • 組裝:接收到 2 * 3 節點。左手是 1,右手是 2 * 3,記憶是 +
        • 動作:組合成 1 + (2 * 3) 節點。
        • 返回:往上交。直到賦值表達式。
      8. 回到賦值表達式(第一次遞歸調用處)
        • 狀態:接收 1 + 2 * 3 節點。
        • 偷看:沒了。
        • 返回:第一次賦值解析遞歸調用返回。
      9. 回到最頂層賦值解析
        • 組裝:當前左手 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 自己負責函數調用和模板字符串的解析。

      簡要流程:

      1. 先找頭: 先解析出 m

      2. 進入循環: ParseMemberExpression 啓動 while 循環,偷看後面。

      3. 處理中括號: 發現是 [,吃掉它。

        這裏會調用 ParseExpression(true)。這個 true 表示允許包含逗號,表示中括號裏可以寫完整的表達式(比如 1+1或者更復雜的表達式)。

      4. 組裝: ParseExpression 返回節點 2,吃掉 ],將 m2 組裝起來。

      5. 繼續循環: 如果後面還有 [.(比如二維數組或鏈式調用),就繼續解析、繼續包在外面組裝;如果沒有,就返回。

      下面我們進入思考模式

      我們説 在賦值解析的時候 要使用遞歸調用,這是沒有任何問題的,因為遞歸調用本身就可以得到右結合的目的,和連等賦值的定義是相符合的。

      在二元解析的時候,我們也説使用遞歸調用,但是這就有些問題,因為遞歸調用會產生右結合,而通過使用優先級 和遇到同級操作符 則退出遞歸 由上級處理左結合以後 再次遞歸,這樣也可以達到左結合的目的。 這種方式本身也沒問題,從嵌套深度上來講,極限情況下 也不過是十多個遞歸嵌套,並不會棧溢出。 但是從橫向上來看,比如 有多個同級操作符的時候 就比較繁瑣,極其頻繁的函數調用,開銷比較大。

      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) 這是最重要的一步。解析器不銷燬節點,而是修改節點的 性質

      • 它把 abVariableProxy 節點,原地轉化參數聲明
      • 關鍵動作
        • 之前,a 指向的是外層作用域(試圖引用)。
        • 現在,解析器把 a 從外層作用域的引用列表中摘除
        • 然後,把 a 作為 新聲明,登記到即將創建的 FunctionScope 裏。

      從此,ab 從“消費者”(引用)變成了“生產者”(聲明)。

      階段四:解析函數體

      參數搞定了,現在處理 => 後面的 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):

      1. 解析器在當前 Scope 找 this
      2. 找不到!(因為根本沒聲明)。
      3. 往上找:沿着 outer_scope 指針去父級作用域找。
      4. 結果:它自然而然地就用了外層的 this

      這不是什麼特殊的“綁定機制”,這單純就是“變量查找機制”的自然結果。

      因為它自己沒有,所以只能用老爸的。這就是 詞法作用域 (Lexical Scoping) 的本質。

      從解析器的角度看,箭頭函數是一個 “三無” 產品,這正是它輕量的原因:

      1. this:Scope 裏不聲明 this,直接透傳外層。
      2. arguments:Scope 裏不聲明 arguments 對象,也是透傳。
      3. construct:生成的 FunctionLiteral 節點會被標記為“不可構造”。如果你想 new 它,現在炸不了你,過一會肯定炸飛你。

      通過箭頭函數的學習,説明倆問題。

      1. 解析層面的歧義(為什麼解析器要回溯、重解釋)。
      2. 作用域層面的 this 本質(不是綁定,而是查找)。

      上面 我們已經基本上將表達式解析的比較常見的形式 從超級詳細的撕扯到簡略的梳理,講了幾個,如果能耐心的看完,相信自己也可以分析了,即使還有沒遇到的表達式形式,根據慣用的套路,也能自己搞定。

      在學習這些內容時,要聯繫到在js層面編碼時,表現出的特點。這樣不僅js能掌握的牢, 底層也記得住。 比如obj.data.list的解析,主要是在LHS層裏的while大循環裏解析點後面的內容,內容是字符串的形式, 是固定的, 而m[2],解析的時候,Lhs看到是中括號裏的內容,是調用了頂層的表達式解析函數來幹活的,表達式解析可以解析的東西那可多了,而且還可能有遞歸,所以在js的編碼時,要知道這兩種的區別和性能上的差異。雖然説 現在電腦性能快到飛起,都得用石頭壓住,而且瀏覽器本身的優化也很厲害,一丟丟丟丟的性能差異完全不用擔心,但是,萬一你換工作去面試,正巧問到你這兩種的區別。。。嘿嘿嘿,你就真的可以像那些八股文裏説的那樣 吊打面試官了。想想都刺激。

  7. 在前面,我們瞭解了,在 項 級的解析中,它實際是個分流處,把聲明的項攔截後直接甩鍋, 把語句的項甩鍋給語句解析。而上面我們花了大篇幅講的表達式解析,是語句解析中,負責兜底的表達式解析。 所以 我們還剩下可用關鍵字匹配的語句解析 和 在項 級就被直接派發的聲明的解析。現在我們開始瞭解聲明的解析。

聲明的解析

聲明的解析不多,總結起來,就是:一類四函兩變量

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 會在後續的編譯/實例化階段,確保在任何代碼執行前,這個名字就已經指向了完整的函數體。這就實現了我們常説的“函數整體提升”。
    • 所以,雖然此時只是在小本本上記了個名字(佔位),真正的函數對象創建和綁定要等到後續階段。但對解析器來説,名字有了,就可以繼續往下走了。
  • 準備進入實體: 名字搞定後,剩下的 (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)”
      • 為什麼是候選?因為現在還不知道有沒有閉包這個老登在後面等着捕獲它。先按“住棧”處理,等最後算總賬時再決定。
  • 偷看: 後面是 =,這表示有初始值,需要解析賦值表達式。

9. ParseAssignmentExpression (賦值解析)

  • 眼熟吧,俺表達式解析又回來了。熟悉的情節也回來了。
  • 左手: 拿到 result 的變量代理節點。
  • 消費: 吃掉 =
  • 右手(遞歸): 解析 x + y
    • ParseBinaryExpression (+號):
      • 讀到 x Resolve:在當前 Scope 找到參數 x,生成引用節點。
      • 吃掉 +
      • 讀到 y Resolve:在當前 Scope 找到參數 y,生成引用節點。
      • 組裝: 生成 BinaryOperation(+, x, y) 節點。
    • 這裏的讀到變量的時候,首先在當前的作用域找,找不到就通過指向父作用域的指針,到上層作用域裏找。
  • 終極組裝:
    • 生成 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 的時候,直接去查一下不就行了嗎?為什麼還要這麼麻煩搞個代理?

原因主要有兩個:

  1. 是因為 JS 允許在變量定義前使用它:比如函數提升、var 提升。當它讀到一個不確定的變量時,不能報錯也不能立刻綁定,所以它只能先生成一個 VariableProxy(a) 放在 AST 裏面,表明這裏有個 a 的坑,等全部解析完了,我得過來填坑。
  2. 是因為解析的順序限制:解析器是從上往下讀的。舉個最簡單的例子: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 函數內部:

  1. 讀到 return
  2. 讀到 dish。 “這是個變量名。但我現在只負責造樹,不知道 dish 是誰。”
  3. 動作:創建一個 VariableProxy 節點。
    • 名字: "dish"
    • 狀態: Unresolved (未解決/未找到)
  4. 把這個節點掛在 ReturnStatement 下面。

此時 AST 的狀態: ReturnStatement - VariableProxy("dish") (手裏拿這個只有名字的小票,不知道去哪領菜)

第二步:變量解決 (Variable Resolution) —— 兑換 這一步通常發生在前面講解例子的時候的第13步, Scope Finalization(作用域收尾/算總賬) 階段,也有可能是後續的編譯階段。

V8 開始拿着這張小票(Proxy)去兑換:

  1. 問當前作用域 (FunctionScope):“你這裏有 dish 的聲明嗎?”
    • 回答:沒有。
  2. 問父作用域 (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,就圈了一塊地。函數裏的 varlet、參數,都歸它管。
  • 塊 (Block):這是 ES6 新晉的小地主。凡是 { ... } 包起來的(比如 iffor 或者直接寫的大括號),在語法上都算作“塊”。

但是,V8 在塊級作用域這裏是非常現實的。

如果大括號裏沒有 letconst,V8 覺得專門為你建一個 Scope 對象太浪費內存了,根本懶得搭理你。此時,它在 V8 眼裏實際上並不構成獨立作用域,變量查找直接走外層。

只有當大括號裏出現了 letconst 這種新貴小王子時,V8 才會真的給它發“房產證”,專門創建一個由大括號為標誌的塊級作用域 BlockScope

注意 var:至於 var,它比較特殊。它看不上塊級這種小地盤,這種大括號根本關不住它。它會直接穿牆出去,去找外面的函數地主或者全局地主。

那麼,變量有沒有作用域呢?

準確地説:變量本身並不能擁有作用域,但是變量屬於某個作用域。

我們説 a 的作用域是函數 f,實際是在説,變量 a 處在函數 f 的作用域裏。

在 V8 內部,每個作用域都有一個清單,上面詳細記錄了:

“我這塊地盤上,住了張三、李四、還有老王...”

如果解析器在這一層沒找到人,説明這個人不住這兒,就會沿 作用域鏈 去往上找。

那麼 問題來了,

作用域鏈是怎麼形成的呢?

當一個新的作用域被創建出來的時候,新的作用域裏都有一個 outer 指針,拴在父級作用域上。

子函數的作用域裏,也有個 outer 指針拴着外部函數的作用域;

外部函數的作用域裏,也有個 outer 指針拴着全局的作用域,這就形成了一根鏈條

肯定有朋友會有疑問了:

“什麼作用域鏈?不就是子函數指向父函數嗎?平時咱寫代碼,函數嵌套個兩三層也就頂天了,這麼短一點,也好意思叫‘鏈’?

這裏有兩點:

第一,這是由數據的組織形式決定的。 只要是通過指針一個連一個的數據結構,都叫 鏈表。這跟它長短沒關係,只要是這種結構,5釐米是鏈表,25釐米也是鏈表,特指它這種“順藤摸瓜”的連接方式。它不是數組,不能通過下標直接訪問;也不是樹或圖。哪怕它只有兩層,只要是靠指針指過去的,它就是鏈表結構。

第二,它是內存裏實實在在的物理鏈條。 一定要分清解析和執行。現在我們是在解析階段,這根鏈條在圖紙上,是藍圖。等到後續代碼真正執行的時候,在堆內存裏,真的會創建出一串串的 Context 對象,它們之間真的是通過物理指針連接起來的。 所以,它不光是邏輯上的鏈,更是物理上的鏈。

想象一下查找過程: 當要查找一個變量時:

  1. 先看自己家:當前作用域有嗎?木有。
  2. 順着繩子找爸爸:父級作用域有嗎?木有。
  3. 一層層往上:直到找到全局作用域。
    • 找到了:皆大歡喜。
    • 到頂了還沒找到
      • 如果是賦值 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樹互相纏繞。

這就是作用域樹。

  1. AST (抽象語法樹)
  • 語法結構的樹。
  • 它描述了代碼的 語法結構
  • BlockFunctionLiteralBinaryExpressionReturnStatement...
  • 給 Ignition 解釋器看。解釋器遍歷這棵樹,生成字節碼。
    • 看到 BinaryExpression --生成 Add 指令。
    • 看到 Literal -- 生成 LdaSmi 指令。
  • 就好像是搭建房子的 框架結構。牆在哪、窗户在哪、承重柱在哪。
  1. Scope Tree (作用域樹)
  • 邏輯關係的樹。
  • 它描述了變量的 可見性生命週期
  • GlobalScopeModuleScopeFunctionScopeBlockScope
  • 給變量查看。
    • 決定變量是住棧、住堆、還是住全局。
    • 處理閉包的捕獲關係。
  • 就類似於 描述房子中的各個部件的邏輯關係。
    • 主卧的開關能控制客廳的燈嗎?(變量可見性)
    • 這根水管是通向廚房還是通向市政總管道?(作用域鏈查找)
  1. 雙樹的糾纏

這兩棵樹雖然是分開的數據結構,但它們是 伴生 的。

  • 伴生生長:

    當解析器解析到一個 function 時:

    1. AST 層面:生成一個 FunctionLiteral 節點(AST長出了一個枝丫)。
    2. Scope 層面NewFunctionScope 被調用,生成一個 FunctionScope 對象,並且 outer 指針指向父級(作用域樹也長出了一個枝丫)。
    3. 掛載:V8 會把這個 FunctionScope 掛在 FunctionLiteral 的身上。
    4. AST 節點説:“我的地盤歸這個 Scope 管。
  • 連接點: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)。

所以,説樹,是説它的整體結構,説鏈,是説它的查找路徑。

  1. 在第一大部分的第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

      關鍵時刻來了

      1. 生成小票:解析器生成了一個 VariableProxy("treasure")(尋找寶藏的小票)。
      2. 開始兑換
        • InnerScope:“你有 treasure 嗎?” --- 沒有
        • 順着 outer 指針往上爬,問 OuterScope:“你有 treasure 嗎?” ---有!

      找到了!但是,解析器並沒有這就結束,它發現了一件事情:

      這個 treasure 是定義在 outer 裏的,但是卻被 inner 這個下級給引用了!而且 inner 可能會被返回到外面去執行!

      這就是 跨作用域引用

    • 強制搬家

      解析器意識到有些麻煩了。

      如果 treasure 依然留在 棧 上,那麼等 outer 函數執行完畢,棧幀被銷燬,treasure 就會灰飛煙滅。

      等將來 inner 在外面被調用時,它想找 treasure,結果只找到一片廢墟,那程序就崩了。

      於是,解析器立馬修改了 OuterScope 的藍圖,下達了 “強制搬家令”

      1. 撕毀標籤:把 treasure 身上的 Stack Local 標籤撕掉。

      2. 貼新標籤:換成 Context Variable(上下文變量)

      3. 開闢專區:

        V8 決定,在 outer 函數執行時,不能只在棧上幹活了。必須在 堆內存 (Heap) 裏專門開闢一個對象,這就叫 Context (上下文對象)。

      4. 分配槽位:

        treasure 被分配到了這個 Context 對象裏的某個槽位(比如 Slot 0)。

      此時的內存藍圖變成了這樣:

      • 普通變量(如果有):依然住在棧上,用完即棄。
      • 閉包變量 (treasure):住在堆裏的 Context 對象中,雖死猶生。
    • 建立連接

      既然變量搬家了,那 inner 函數怎麼知道去哪找它呢?

      在生成 innerSharedFunctionInfo(這個就是在文章剛開始部分講的,預解析時,會生成的佔位符節點和一個SharedFunctionInfo相關聯,SFI中有預解析得到的元信息)時,V8 會記錄下這個重要的情報:

      注意:本函數是一個閉包。執行時,請務必隨身攜帶父級作用域的 Context 指針

      這就好比 inner 函數隨身帶着一把鑰匙。

      不管它流浪到代碼的哪個角落,只要它想訪問 treasure,它就會拿出鑰匙,打開那個被精心保留下來的 Context 保險箱,取出裏面的值。

    • 總結一下

      在解析層面,閉包不僅僅是“函數套函數”,它是一次 “變量存儲位置的逃逸分析”

      1. 沒有閉包時:父函數的變量都在上,函數退棧,變量銷燬。
      2. 有閉包時:解析器發現有內部函數引用了父級變量,強行把該變量從挪到堆 (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();
        
        1. 掃描 useHeavy:發現它用了 heavyData。--- heavyData 必須進 Context。

        2. 掃描 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 的逃逸分析優化,強制保留所有上下文。

  2. 上面我們花了很大篇幅講了普通函數的解析。這時候肯定有朋友問:“不是説‘一類四函兩變量’嗎?還有三種函數(異步、生成器、異步生成器)呢?”

    實際上,它們用的是同一套模具。

    在 V8 裏,ParseHoistableDeclaration 負責接待這四位天王。經過 ParseFunctionDeclaration 的簡單包裝後,處理函數字面量的入口全都指向同一個苦力:ParseFunctionLiteral

    無論是 functionfunction*async function 還是 async function*,它們在 V8 眼裏都是“穿了不同馬甲”的普通函數。

    解析器只需要在進門時做一次“安檢”,根據 *async 關鍵字打上不同的標籤(Flag),接下來的解析流程——查參數、開作用域、切分代碼塊——完全複用

    不過,針對這三位“特權階級”,解析器確實會偷偷做三件不同的小操作:

    1. 關鍵字變化: 在普通函數裏,yieldawait 只是普通的變量名。但在特殊函數裏,解析器會把它們識別為 操作符,生成專門的 AST 節點。
    2. 夾帶 .generator: 對於生成器和異步函數,解析器會偷偷在作用域裏塞一個隱形的 .generator 變量。 這是為了將來函數“暫停”時,能把當前的寄存器、變量值等 “案發現場” 保存在這個變量裏。 所以,這幾種函數 天然就是閉包,因為它們必須引用這個隱形的上下文。
    3. 休息點 Suspend: 解析器會在 AST 裏埋下 Suspend (掛起) 節點。 這相當於告訴未來的解釋器:“讀到這兒別硬衝了,得停下來歇會兒,把控制權交出去。”

    雖然具體解析時有不少差異,但是,有了前面我們解析普通函數的基礎,再來解析這三種“魔改版”的函數,難度並不大。 我們就不具體展開了,畢竟,函數再美,看多了也會審美疲勞啊。

    所以,我們現在學習聲明中的 變量聲明。

    雖然前面一直在説 兩變量,那是從規範上説的 var屬於語句, 在 V8 中,let const var 這三個變量聲明 ,是使用同一個解析函數處理的。

    有一個核心函數叫 ParseVariableDeclarations

    不管解析器讀到的是 var,還是 let,還是 const,在經過項級分流後,最終都會殊途同歸,調用這個函數ParseVariableDeclarations。

    下面,我們就開始變量的聲明之旅吧。

    • 項級分流

      地點:ParseStatementListItem

      場景:解析器正在一個大括號 { ... } 或者函數體裏,逐行掃描代碼。

      1. **偷看 **:看看下一個 Token 是什麼呢?

      2. 判斷

        • 如果看到 var
        • 如果看到 let
        • 如果看到 const
      3. 統一甩鍋:

        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?--- 報錯 變量名想叫關鍵字 一邊去吧。
        • 如果是嚴格模式,變量名叫 argumentseval?--- 報錯,想在邊緣試探 也一邊去吧。
    • 分頭工作

      地點:DeclareVariableName (在解析出名字後立刻調用)

      場景:名字有了,現在要去Scope Tree(作用域樹) 上登記户口了。這時候,必須根據

      參數 來區分待遇。

      這裏是邏輯最複雜的地方,也是 varlet 行為差異的根源

      分支 A:手裏拿的是 kVar 參數

      1. 向上穿牆:解析器無視當前的塊級作用域 BlockScope,沿着 scope--outer_scope() 指針一直往上爬。
      2. 尋找宿主:直到撞到了一個 FunctionScope 或者 GlobalScope,函數作用域或全局作用域 是var的目標。
      3. 登記:在那個高層作用域裏,記錄下名字 a
      4. 模式:標記為 VariableMode::kVar,嗯嗯嗯 這裏是內部的東東了。
      5. 初始化:標記為 kCreatedInitialized(創建即初始化)。意思是:“var這傢伙不用死區,直接給個 undefined 就能用。”

      分支 B:手裏拿的是 kLetkConst 參數

      1. 原地不動:解析器直接鎖定當前的 Scope(哪怕它只是一個 if 塊)。
      2. **查重 **:翻開當前作用域的小本本,看看有沒有重名的?
        • 有?-- 報錯 SyntaxError: Identifier has already been declared
      3. 登記:在當前作用域記錄名字 a
      4. 模式:標記為 VariableMode::kLetVariableMode::kConst
      5. 初始化:標記為 kNeedsInitialization(需要初始化)。
        • 這就是 TDZ 的源頭了! 這個標記意味着:在正式賦值之前,誰敢訪問這個位置,就拋錯。
      6. 注意點: 從這裏能看出 let和const也會提升,只不過let和const的提升是小提升,只在自己的當前作用域裏提升,提升歸提升,沒被真正賦值前,TDZ啊,被送會吹哨子的警衞看守着。
    • 處理初始值

      地點:回到通用車間

      場景:名字登記完了,現在看有沒有賦值號 =。

      步驟 1:const 的檢查

      • 解析器偷看下一個 Token。
      • 如果是 kConst 且後面沒有 = 號?
        • 直接崩了 拋出 SyntaxError: Missing initializer in const declaration
        • varlet 會偷笑,因為它們允許沒有 =

      步驟 2:解析賦值

      • 如果看到了 =,吃掉它。
      • 遞歸甩鍋:調用 ParseAssignmentExpression 解析 = 右邊的表達式(比如 1 + 2)。。。這裏這裏這裏 前面超大篇幅講過的表達式解析,看到親切嗎?

      步驟 3:生成 AST 節點

      這裏是 AST 物理結構的生成。

      • 對於 var:

        由於 var 的名字已經提升走了,這裏剩下的其實是一個 賦值操作。

        V8 會生成一個 Assignment 節點(或者類似的初始化節點),掛在當前的語句列表中。

        • 意思是:“名字歸上面管,但我得在這裏把值賦進去。”
        • 這裏也需要注意,var的名字被提升走了,但是賦值操作還留在這裏呢,在賦值之前,var都是undefined。
      • 對於 let / const:

        V8 會生成一個完整的 VariableDeclaration 節點,包含名字和初始值。

        而且,如果這是 const,V8 會給這個變量打上 “只讀” 的標籤。如果以後 AST 裏有別的節點想修改它,編譯階段或運行階段就會攔截報錯。

        這個只讀,是指綁定的引用不可變,如果引用的是個對象,對象內部的內容還是可以改的。

    • 收尾嘍

      地點:循環末尾

      1. 逗號檢查:偷看後面是不是逗號 ,
        • 是 -- 吃掉逗號,回到 通用車間的步驟 3,繼續解析下一個變量。
        • 否 --- 結束循環。
      2. 分號處理:期待一個分號 ;。如果沒有,自動分號插入。
      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 層面,通常會把它拆解成兩部分:

      1. 聲明 (Declaration)var a。這部分在 AST 上被標記為“可提升”。
      2. 賦值 (Assignment)a = 1

      解析器會在當前位置if 塊的語句列表中,生成一個 Assignment (賦值) 節點,而不是一個單純的聲明節點。

    • 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 的語句列表中。

    • Scope 樹:名字登記在當前塊,不可重複,標記為死區狀態。

    • AST 樹:原地生成一個完整的 VariableDeclaration 節點。

    還剩下const了const 的流程和 let 幾乎一模一樣,只有兩個額外的檢查環節。

    第一必須帶初始值

    • 在解析完變量名之後,解析器會立刻偷看下一個 Token。
    • 如果不是 =
    • 沒有初始化,報錯 拋出 SyntaxError: Missing initializer in const declaration
    • const 變量出生必須帶值,這是語法層面的規定。

    第二, 只讀屬性

    • Scope 操作:

      在登記const的變量時,它的 Mode 被標記為 kConst。

      這表示在 Scope 的記錄裏,這個變量是 Immutable 不可變 的。

      如果 AST 的其他地方試圖生成一個 Assignment 節點去修改const聲明的變量,雖然解析階段可能不會立刻報錯(有時要等到運行時),但是後續一定會在寫入只讀變量的操作時,被攔截並拋錯。

  3. 上面講了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關鍵字的時候,還沒看到內容,就必須先做三件事。

      1. 強制開啓嚴格模式
        • 解析器將當前的 language_mode 標誌位強行設置為 kStrict
        • 一旦跨過 Hero { 這道門檻,所有嚴格模式的規則立即生效(比如禁用 with,禁用arguments 和參數不再綁定等)。
      2. 創建類作用域
        • V8 調用 NewClassScope,創建一個新的作用域對象。
        • 户籍登記:解析器讀到標識符 Hero。它立刻在這個新的作用域裏,聲明一個名字叫 Hero 的變量。
        • 鎖起來:這個變量被標記為 CONST(常量)。這表示在類體內部,Hero = 1 這種代碼會在解析階段直接報錯。
        • 目的:這是為了讓類內部的方法能引用到類本身(自引用)。
      3. 初始化列表
        • 解析器在內存裏準備了三個空的列表(List),用來分類存放即將切割下來的不同部位,像超市裏雞腿 雞翅 雞雜 分開擺盤:
          • instance_fields (實例字段列表):存放 name = ... 這種。
          • static_fields (靜態字段列表):存放 static version = ... 這種。
          • properties (方法屬性列表):存放 say(), constructor 這種。
    • 開始解析

      現在,解析器進入大括號 { ... },開始掃描。

      name = '阿祖'; —— 實例字段的解析

      1. 識別 Key:解析器讀到 name
      2. **偷看 **:往後偷看一眼,發現是 =
      3. 判定:這不是方法,這是一個 Field (字段)。且沒有 static,所以是 Instance Field (實例字段)
      4. 解析
        • 解析器把 = 後面的 '阿祖' 作為一個 表達式 進行解析。
        • 生成一個 Literal 字符串節點。
      5. 包裝
        • 關鍵:消費完了以後,V8 不會把 '阿祖' 直接扔掉。它會創建一個 "合成函數" (Synthetic Function) 的外殼。
        • 為什麼要包一層? 這是 V8 為了隔離作用域而採用的策略。字段初始化表達式裏可能會有 this,或者複雜的邏輯。通過封裝成一個獨立的函數殼,V8 確保了它和構造函數的參數(比如 skill)互不干擾,這也符合 JS 規範:字段定義本來就看不見構造函數的參數。
        • 劃重點name 的值怎麼算,被封裝成了一個可以在未來執行的函數。
        • 這裏需要注意,= 號後面的值,並不是一次性使用,有可能被使用很多次,雖然我們例子中是 阿祖,但是 也可能是其他包含邏輯的計算值,所以,我們需要的不是值,而是如何生成這個值的 整個邏輯, 因此 解析出來以後,給它包上一層帶獨立作用域的函數殼。
      6. 歸檔:把這個合成函數扔進 instance_fields 列表。

      static version = '1.0'; —— 靜態字段的解析

      1. 識別:讀到 static 關鍵字。

      2. 標記:開啓 is_static 標誌位。

      3. 識別 Key:讀到 version

      4. 偷看:看到 =

      5. 判定:這是一個 Static Field (靜態字段)

      6. 解析與歸檔

        • 解析 '1.0' 生成字符串節點。
        • 同樣包裝成一個“合成函數”。
        • 扔進 static_fields 列表。
        • 注意:這個列表將來是要掛在 Hero 構造函數對象本身上的,不是掛在 this 上的。

      constructor(skill) { ... } —— 核心內容的解析

      1. 識別:讀到 constructor 關鍵字。
      2. 判定:這是類的 核心構造函數
      3. 解析函數體
        • 解析參數 skill
        • 解析代碼塊 this.skill = skill
        • 生成一個 FunctionLiteral 節點。
      4. 歸檔:雖然它是核心內容,但在 AST 組裝前,它暫時被存在一個叫 constructor_property 的特殊槽位裏,等待後續的組裝。

      say() { ... } —— 原型方法的解析

      1. 識別:讀到 say,後面緊跟 (
      2. 判定:這是一個 Method (方法)
      3. 屬性描述符生成 (Property Descriptor)
        • 這是類和對象最大的不同點。V8 會盤算着
        • writable: true
        • configurable: true
        • enumerable: false (類的方法默認不可枚舉)
      4. HomeObject 綁定
        • 解析器會給 say 函數標記一個 HomeObject。這是為了如果你在 say 裏用了 super,它知道去哪裏找父類。
      5. 歸檔:把生成的 say 函數節點,扔進 properties 列表。
    • 進行脱糖

      掃描完 },所有的配件都擺好了。馬上開始的,這就是傳説中的 脱糖 過程。

      類是語法糖,現在,我們要脱糖。

      • 改造構造函數

        1. 拿出剛才解析好的 constructor 函數節點。
        2. 定位
          • V8 尋找函數體的 起始位置
          • 如果有繼承 (extends),位置在 super() 調用之後(因為 super 返回前 this 還沒出生)。
          • 沒有繼承,位置就在函數體的 最前面
        3. 添加
          • V8 把 instance_fields 列表裏的內容拿出來(那個 name = '阿祖' 的合成函數)。
          • 它將其轉化為賦值語句 AST:this.name = '阿祖'
          • 它把這條語句 插入constructor 原本的用户代碼 this.skill = skill 之前。

        此時,在 V8 的內存 AST 中,構造函數實際上變成了這樣:

        // V8 內存中的構造函數(偽代碼)
        function Hero(skill) {
          // --- V8 添加的字段初始化邏輯 ---
          // 注意:這裏是一個隱式的 Block
          // 是因為這裏是由合成函數轉化的,包含了邏輯  也包含了獨立的作用域
          this.name = '阿祖'; 
          // -----------------------------
        
          // --- 用户寫的邏輯 ---
          this.skill = skill;
        }
        
      • 組裝 ClassLiteral

        現在構造函數改造完畢,V8 開始組裝最終的 ClassLiteral 節點。

        1. 掛載構造函數:把改造後的 Hero 函數放c位。
        2. 掛載原型方法
          • 遍歷 properties 列表。
          • 拿出 say
          • 生成指令:在運行時,將 say 掛載到 Hero.prototype 上,並設置 enumerable: false
        3. 掛載靜態字段
          • 遍歷 static_fields 列表。
          • 拿出 version = '1.0'
          • 生成指令:在類創建完成後,立刻執行 Hero.version = '1.0'
        4. 關聯作用域:把最開始創建的 ClassScope 關聯到這個節點上。
    • 完成嘍

      儘管我們寫的是一個class,但是,實際的解析過程如下

      1. 開啓嚴格模式。
      2. 創建一個叫 Hero 的常量環境。
      3. 定義一個叫 Hero 的函數。
        • 函數體內:先執行 this.name = '阿祖'
        • 函數體內:再執行 this.skill = skill
      4. 定義一個叫 say 的函數。
        • 把它掛到 Hero.prototype 上,設為不可枚舉。
      5. 定義一個叫 version 的值。
        • 把它直接掛到 Hero 函數對象上。
      6. 返回這個 Hero 函數。

      你會發現,解析器最終生成的是一個表示類的 ClassLiteral,但也是僅是名字而已,其他的所有內容,已經脱糖為函數、賦值、原型掛載 這些js語法。

      所以,從 V8 的實現上來説,類解析的本質,就是解析器通過引入 合成函數代碼植入 等手段,把現代化的語法糖,翻譯成了底層引擎能理解的函數、作用域和原型操作。

  4. 我們前面首先學習的就是語句裏的兜底表達式的解析,然後是聲明中的 函數 變量 類, 現在就還剩語句中的可以用關鍵字甩鍋的部分了。

    我們回到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 (塊)

    解析流程:

    1. 消費:吃掉 {
    2. 遞歸:此時,彷彿又回到了世界起源。解析器會再次調用那個最最最核心的循環驅動者 —— ParseStatementList
      • 這就是為什麼代碼可以無限嵌套:塊裏套塊,套娃套娃娃。
    3. 消費:吃掉 }

    注意:透明作用域 (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

    解析流程:

    1. 消費:吃掉 if,吃掉 (
    2. 條件:調用 ParseExpression 解析條件(比如 a > 1),拿到 Condition 節點。
    3. 消費:吃掉 )
    4. Then 分支:調用 ParseStatement 解析 then 的部分。
    5. 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) 後面的。如果你想讓它屬於外層,必須顯式地加 {},所以 ,從寫法上減少這些歧義是最好的。

    循環解析:for

    while 和 do-while 比較簡單,我們重點講最複雜的 for 循環。

    當解析器看到 for,甩鍋給 ParseForStatement。

    AST 的結構:

    V8 會生成一個 ForStatement 節點,它有 4 個插槽:

    1. Init (初始化):比如 let i = 0
    2. Cond (條件):比如 i < 10
    3. Next (步進):比如 i++
    4. Body (循環體):比如 { console.log(i) }

    嗯嗯嗯,這裏又有個面試官容易被吊打的地方了

    就是 for 循環作用域問題,V8 在這裏做了比較複雜的處理。

    如果這裏用的是 var,V8 根本不管,直接扔給外層函數作用域。

    但如果是 let,V8 必須製造出 “多重作用域” 的效果。

    在解析 for(let ...) 時,V8 會在 AST 和 Scope 樹上構建出 兩層 甚至 N+1 層 作用域:

    1. 循環頭作用域 (Loop Header Scope)

    2. 循環體作用域 (Loop Body Scope)

    3. 迭代作用域 (Per-Iteration Scope)

    。。。。。。看起來似乎挺複雜,實際上也不是很簡單,所以我們需要仔細耐心的學習。

    for (var i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 0);
    }
    

    分析這個例子

    第一階段:主線程 循環階段

    因為 var 聲明的 i 沒有塊級作用域,它是一個全局變量(或函數作用域變量)。在這個內存裏,只有一個 i

    1. 初始化i = 0
      • 檢查 0 < 3?是的。
      • 遇到 setTimeout:瀏覽器把“打印 i”這個任務記在宏任務隊列的小本本上。注意:此時不執行打印,也不存 i 的值,只是記下“回頭要找 i 打印”這件事。
    2. 步進i 變成 1
      • 檢查 1 < 3?是的。
      • 遇到 setTimeout:再記一筆“回頭找 i 打印”。
    3. 步進i 變成 2
      • 檢查 2 < 3?是的。
      • 遇到 setTimeout:再記一筆“回頭找 i 打印”。
    4. 步進(關鍵步驟)i 變成 3
      • 檢查 3 < 3不成立!
      • 循環結束

    重點來了: 此時循環結束了,變量 i 停留在什麼值? 答案是 3。 因為它必須變成 3,條件判斷 i < 3 才會失敗,循環才會停止。

    第二階段:異步隊列回調 打印階段

    現在主線程空閒了,Event Loop 開始處理剛才記在小本本上的 setTimeout 任務。

    1. 第 1 個回調運行console.log(i)
      • 它去內存裏找 i
      • 這時候的 i 是多少?是 3
      • 打印:3
    2. 第 2 個回調運行console.log(i)
      • 它還是去同一個內存地址找 i
      • i 還是 3
      • 打印: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)

    1. 公共時鐘 i:指向 0。
    2. 進入房間:V8 遇到 {,創建一個全新的 Block Scope A
    3. 執行 let x = i
      • V8 在 Scope A 裏創建變量 x
      • 讀取外面的 i (0)。
      • 賦值x = 0
    4. 閉包生成
      • setTimeout 裏的箭頭函數生成。
      • 關鍵點:它捕獲的是誰?是 Scope A 裏的 x
      • 此時,這個閉包手裏緊緊攥着 x=0 的照片。

    第二輪循環 (i = 1)

    1. 公共時鐘 i:變成了 1(注意:i 還是那個 i,只是值變了)。
    2. 進入房間:V8 遇到 {,創建一個全新的 Block Scope B(和 A 沒關係)。
    3. 執行 let x = i
      • V8 在 Scope B 裏創建變量 x
      • 讀取外面的 i (1)。
      • 賦值x = 1
    4. 閉包生成
      • 生成第二個箭頭函數。
      • 它捕獲的是 Scope B 裏的 x
      • 這個閉包手裏攥着 x=1 的照片。

    第三輪循環 (i = 2)

    1. 公共時鐘 i:變成了 2。
    2. 進入房間:創建 Block Scope C
    3. 執行 let x = i
      • x = 2
    4. 閉包生成
      • 捕獲 Scope C 裏的 x
      • 手裏攥着 x=2 的照片。

    循環結束了。

    • 公共變量 i:變成了 3。如果這時候有人打印 i,那就是 3。
    • 剛才那三個閉包(定時器回調),根本不關心 i 是多少。

    當 0ms 之後,定時器觸發:

    1. 回調 1:拿出 Scope A 裏的 x - 打印 0
    2. 回調 2:拿出 Scope B 裏的 x - 打印 1
    3. 回調 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。

    於是,它悄悄開啓自己的魔法-迭代的作用域

    所以

    1. 物理上i 確實只有一個,在 Header Scope 裏,不斷 ++ 變成 0, 1, 2, 3。
    2. 邏輯上:每次進入大括號,V8 都會偷偷創建一個 影子作用域
    3. 複印:在這個影子作用域裏,V8 會把此刻的 i 的值,賦值給一個新的隱藏變量 偽代碼裏我們叫它 _k
    4. 捕獲:循環體裏的閉包,實際上捕獲的不是那個一直在變的 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 < 3i++
    • 關聯:這裏的 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)" 的標籤。

    步驟 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 沒有塊級綁定(它是函數/全局作用域的共享綁定),因此不會產生快照效果。

    跳轉語句:return

    returnbreakcontinue 的解析邏輯都很直白:“吃掉關鍵字 --檢查分號”。

    ParseReturnStatement 有一個巨大的坑,叫做 ASI (自動分號插入)

    看這段

    return
    true;
    

    解析器讀到 return 後,它的動作是這樣的:

    1. 偷看:偷看下一個 Token。
    2. 發現:哎喲,是一個 換行符 (LineTerminator)
    3. 判定:根據 JS 語法規則,return 後面不能跟換行符。既然你換行了,我就當你寫完了。
    4. 插入:V8 強行在這裏插入一個分號 ;
    5. 結果:代碼變成了 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/

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.