用JavaScript帶你體驗V8引擎解析字元串過程

来源:https://www.cnblogs.com/QH-Jimmy/archive/2019/07/10/11160712.html
-Advertisement-
Play Games

AST模塊其實要寫的話,100篇都寫不完,我將一些簡單知識點翻譯成JavaScript代碼來進行講解(v8內部的複雜性永遠都能超出我的意料,現在看到萬行的源碼都已經沒感覺了),如果誰想看C++源碼,就去翻我前面的流水賬。 先寫幾個結論。 案例中,單個詞法'Hello'屬於原始字元串,由AstRawS ...


AST模塊其實要寫的話,100篇都寫不完,我將一些簡單知識點翻譯成JavaScript代碼來進行講解(v8內部的複雜性永遠都能超出我的意料,現在看到萬行的源碼都已經沒感覺了),如果誰想看C++源碼,就去翻我前面的流水賬。

先寫幾個結論。

  1. 抽象語法樹內部有嚴格的分類,比如繼承於AstNode的語句Statement、表達式Expression、聲明Declaration等等,當判定對應詞法的類型,會有一個工廠類專門生成對應類型的描述類。
  2. v8內部有一個名為string_table_的hashmap緩存了所有字元串,轉換抽象語法樹時,每遇到一個字元串,會根據其特征換算為一個hash值,插入到hashmap中。在之後如果遇到了hash值一致的字元串,會優先從裡面取出來進行比對,一致的話就不會生成新字元串類。
  3. 抽象語法樹解析的判定優先順序依次為Declaration(let a = 1)、Statement(if(true) {})、Expression("a" + "b"),其中還有一個非常特殊的語法類型是goto,即label語法,我只能說儘量不要用這個東西,v8為其專門寫了特殊的解析,非常複雜。
  4. 每一個大類型(例如Statement)也會有非常詳細的子類型,比如if、while、return等等,當前解析詞法不匹配對應類型,會進行降級解析。
  5. 緩存字元串時,會分為三種情況處理,長度為1的單字元、長度為2-10的且值小於2^32 - 2的純數字字元串、其他字元串,僅僅影響生成hash值方式,純數字字元串會轉換成數值再計算hash。

案例中,單個詞法'Hello'屬於原始字元串,由AstRawString類進行管理。而整個待編譯字元串"'Hello' + ' World'"中,加號左右的空格會被忽略,解析後分為三段,即字元串、加號、字元串。由於這段代碼以字元串開頭,被判定為一個字面量(literal),在依次解析後發現了加號與另外一個字元串後結束,所以被判定是一個'普通二元運算表達式',在expression中的標記分別是normal、binary operation、literal。

 

這裡用JavaScript模擬一遍"'Hello + World'"的解析過程,完整的解析後面有人看再說。命名和邏輯儘量還原C++源碼,有些類存在多層繼承就不搞了,枚舉用數組代替,部分地方的語法與調用可能會看起來有些奇怪,指針以及模版元那些就沒辦法了。

首先我們需要兩個映射表,如下。

const kMaxAscii = 127;
const UnicodeToAsciiMapping = [];
for(let i = 0;i < kMaxAscii;i ++) {
  UnicodeToAsciiMapping.push(String.fromCharCode(i));
}
/**
 * 源碼確實是一個超長的三元表達式
 * Token是一個枚舉 這裡直接用字元串代替了
 * 因為太多了 只保留幾個看看
 */
const TokenToAsciiMapping = (c) => {
  return c === '(' ? 'Token::LPAREN' : 
  c == ')' ? 'Token::RPAREN' :
  // ...很多很多
  c == '"' ? 'Token::STRING' :
  c == '\'' ? 'Token::STRING' :
  // ...很多很多
  'Token::ILLEGAL'
};
const UnicodeToToken = UnicodeToAsciiMapping.map(v => TokenToAsciiMapping(v));

一個map負責對Unicode與Ascii做映射,一個map負責對Unicode與Token類型的映射,這裡v8利用數組下標來快速定位字元類型。

v8內部是對字元串做逐字解析,我們需要一個Stream類來管理和處理,實現一下。

class Stream {
  constructor(source_string) {
    /**
     * buffer_不會在構造函數中初始化
     * 但為了模擬v8這裡暫時保存源字元串
     */
    this.source_string = source_string;
    /**
     * 作為容器存儲字元
     */
    this.buffer_ = [];
    /**
     * 三個指針分別代表當前解析進度
     */
    this.buffer_start_ = 0
    this.buffer_cursor_ = 0
    this.buffer_end_ = 0
  }
  ReadBlockChecked() {
    return this.ReadBlock();
  }
  ReadBlock() {
    this.buffer_ = this.source_string.split('').map(v => UnicodeToAsciiMapping.indexOf(v));
    this.buffer_end_ = this.buffer_.length;
    /**
     * 這裡的返回與源碼不同 涉及gc 不做展開
     */
    return this.buffer_.length;
  }
  /**
   * 返回當前字元 並前進一格
   */
  Advance() {
    let tmp = this.peek();
    this.buffer_cursor_++;
    return tmp;
  }
  /**
   * 返回當前字元
   * 同時會做初始化
   */
  peek() {
    if(this.buffer_cursor_ < this.buffer_end_) {
      return this.buffer_[this.buffer_cursor_];
    } else if(this.ReadBlockChecked()) {
      return this.buffer_[this.buffer_cursor_];
    } else {
      return null;
    }
  }
}

有了這個類,就能對字元串逐字解析,但是還是需要一個機器來啟動這個步驟,機器叫scanner。在實現掃描機器之前,我們還需要實現詞法類,也就是如何描述單個詞法。這個類在v8中叫TokenDesc,屬於Ast中最基礎的單元。

class TokenDesc {
  constructor() {
    /**
     * 源碼中是一個結構體
     * 除了標記起始、結束位置還有若幹方法
     */
    this.location =  {
      beg_pos: 0,
      end_pos: 0,
    };
    /**
     * 負責管理字元串
     * 還有一個名為raw_literal_chars的同類型屬性負責儲存源字元串
     */
    this.literal_chars = new LiteralBuffer();
    /**
     * Token類型
     */
    this.token = null;
    /**
     * 處理小整數
     */
    this.smi_value = 0;
    this.after_line_terminator = false;
  }
}

裡面的屬性基本上還原了v8源碼,Location做了簡化,另外literal_chars負責專門處理字元串,後面會給出實現。

token則標記了該詞法的類型,類型判斷可見上面的第二個映射表,根據不同的類型有不同的case處理。

smi_value則管理小整數類型的詞法,可以去看jjc對於這個的介紹,我這裡就不展開了。

有了詞法類,再來實現掃描器scanner。

class Scanner {
  constructor(source_string) {
    this.source_ = new stream(source_string);
    /**
     * 當前字元的Unicode編碼
     * 如果為null代表解析完成
     */
    this.c0_ = null;
    /**
     * 其實v8有三個詞法描述類
     * token_storage_是一個數組 裡面裝著那個三個類 這裡就不用了
     * 為了方便就弄一個
     */
    this.TokenDesc = new TokenDesc();
    this.token_storage_ = [];
  }
  /**
   * 源碼有current_、next_、next_next_三個標記 這裡搞一個
   */
  next() {
    return this.TokenDesc;
  }
  Initialize() {
    this.Init();
    this.next().after_line_terminator = true;
    this.Scan();
  }
  Init() {
    this.Advance();
    // 後面會有一些詞法描述類對token_storage_的映射 這裡跳過
  }
  Advance() {
    this.c0_ = this.source_.Advance();
  }
  /**
   * 這裡有函數重載 JS就直接用預設參數模擬了
   */
  Scan(next = this.TokenDesc) {
    next.token = this.ScanSingleToken();
    next.location.end_pos = this.source_.buffer_cursor_ - 1;
  }
  /**
   * 單個詞法的解析
   */
  ScanSingleToken() {
    let token = null;
    do {
      this.next().location.beg_pos = this.source_.buffer_cursor_;
      if(this.c0_ < kMaxAscii) {
        token = UnicodeToToken[this.c0_];
        switch(token) {
          case 'Token::LPAREN':
          /**
           * 有很多其他的case
           * 因為只講字元串
           * 這裡就不實現這個方法了
           */
            return this.Select(token);
          case 'Token::STRING':
            return this.ScanString();
          // ...
        } 
      }
      /**
       * 源碼中這裡處理一些特殊情況 不展開了
       */
    } while(token === 'Token::WHITESPACE')
    return token;
  }
}

這個類比較大,簡化了不少地方,核心當然是解析。在源碼中,對scanner類調用初始化的Initialize時就會對第一個詞法進行解析,如同我重寫的那個邏輯,最後對字元串的處理方法就是那個ScanString。

在這裡暫時沒有將ScanString的實現給出來,主要是在這個方法關聯著另外一個類,即之前TokenDesc類中的literal_chars。

所以先把管理字元串的類實現,再來看對字元串的最終解析。

const Latin1_kMaxChar = 255;
// constexpr int kOneByteSize = kCharSize = sizeof(char);
const kOneByteSize = 1;
class LiteralBuffer {
  constructor() {
    /**
     * 源碼中是一個Vector容器
     * 有對應擴容演算法
     */
    this.backing_store_ = [];
    this.position_ = 0;
    /**
     * 當字元串中有字元的Unicode值大於255
     * 判定為雙位元組類型 這裡先不處理這種
     */
    this.is_one_byte_ = null;
  }
  /**
   * 啟動這個時預設字元串為單位元組
   */
  start() {
    this.position_ = 0;
    this.is_one_byte_ = true;
  }
  /**
   * 只關心單位元組字元 所以那兩個方法不給出實現了
   */
  AddChar(code_unit) {
    if(this.is_one_byte_) {
      if(code_unit <= Latin1_kMaxChar) {
        return this.AddOneByteChar(code_unit);
      }
      this.ConvertToTwoByte();
    }
    this.AddTwoByteChar(code_unit);
  }
  AddOneByteChar(one_byte_char) {
    /**
     * 擴容演算法簡述就是以64為基準 每次擴容*4
     * 當所需容器大於(1024 * 1024) / 3時 寫死為2 * 1024 * 1024
     */
    if (this.position_ >= this.backing_store_.length) this.ExpandBuffer();
    this.backing_store_[this.position_] = one_byte_char;
    this.position_ += kOneByteSize;
  }
}

其實這個類本身比較簡單,只是用了一個容器來裝字元,必要時進行擴容,單雙位元組不關心的話也就沒什麼了。

有了這個類,就能對字元串進行完整的解析,來實現scanner類的ScanString方法吧。

class Scanner {
  // ...
  ScanString() {
    // 保存當前字元串的標記符號 ' 或 "
    let quote = this.c0_;
    this.next().literal_chars.Start();
    while(true) {
      this.AdvanceUntil();
      /**
       * 特殊符號直接前進一格
       */
      while(this.c0_ === '\\') {
        this.Advance();
      }
      /**
       * 遇到結束的標記代表解析結束
       */
      if (this.c0_ === quote) {
        this.Advance();
        return 'Token::STRING';
      }
      this.AddLiteralChar(this.c0_);
    }
  }
  AddLiteralChar(c) {
    this.next().literal_chars.AddChar(c);
  }
}

可以看到,除去那個AdvanceUntil方法,其實還是正常的逐字遍歷字元,當遇到同一個標記時,就代表字元串解析結束。

但是這個AdvanceUtil方法確實比較有意思,簡述就是快速檢測字元串的結尾位置並完成掃描,順利的話跑完這個方法就結束了整個ScanString。其參數是一個函數,負責檢查當前字元是否可能是字元串結束標誌。C++源碼中用的是匿名函數,看起來比較難受,這裡用JS重寫一遍,如下。

class Scanner {
  // ...
  /**
   * 這裡相對源碼有改動
   * 1、實際調用的是source_上的方法 並把返回值給了c0_
   * 2、判斷函數在這裡寫實現
   */
  AdvanceUntil() {
    /**
     * 這裡需要實現std標準庫中一個方法
     * 實際上是三個參數 且前兩個參數為迭代器 為了方便暫時就不完美實現了
     */
    const find_if = (arr, start, end, callback) => {
      let tarArr = arr.slice(start, end);
      let tarIdx = tarArr.findIndex(v => callback(v));
      return tarIdx === -1 ? end : tarIdx;
    }
    const callback = (c0) => {
      /**
       * 代表當前字元可能是一個結束符 這裡簡化了判斷 源碼如下
       * uint8_t char_flags = character_scan_flags[c0];
       * if (MayTerminateString(char_flags)) return true;
       */
      if(["\'", "\""].includes(UnicodeToAsciiMapping[c0])) return true;
      this.AddLiteralChar(c0);
      return false;
    }
    /**
     * 在字元串中尋找第一個字元結尾標記的位置
     * 例如'、"等等
     */
    let next_cursor_pos = find_if(this.source_.buffer_, this.source_.buffer_cursor_, this.source_.buffer_end_, callback);
    if(next_cursor_pos === this.source_.buffer_end_) {
      this.source_.buffer_cursor_ = this.source_.buffer_end_;
      this.c0_ = null;
    } else {
      this.source_.buffer_cursor_ = next_cursor_pos + 1;
      this.c0_ = this.source_.buffer_[next_cursor_pos + 1];
    }
  }
}

這裡其實也對字元串進行了遍歷,但只是粗糙的掃描,在一般情況下,這個方法走完字元串就遍歷完畢,但是偶爾也會有特殊情況,比如說"ab'c'd"、"abc\"d"。當遇到特殊情況,這裡只能將前面的字元add後,交給外部繼續處理。

裡面其實還有一個映射表,叫character_scan_flag,也是對單個字元的類型判定,屬於一種可能性分類。比如遍歷到一個字元z,這裡就會給一個標記kCannotBeKeyword,代表這個詞法不可能是一個關鍵詞,在某些情況可以快速跳過一些流程。同理,在遇到'、"字元時,會被判斷可能是一個字元串的結尾標記,這裡就用上了。這個映射表比較複雜,前面我就沒搞出來。

至此,一個字元串的詞法就算是解析完了,最後會返回一個類型的Token::STRING的標記,作為詞法描述類型。當然,這個單獨的詞法實際上沒有任何意義,單獨拿出來會被忽略。但是如果與運算符ADD和另外一個字元串連起來,會進化成一個二元運算表達式,這些東西都是後面的事了。

給一個測試結果,執行的時候要註釋掉一些方法,因為沒有給實現。

let scanner = new Scanner(source_code);
scanner.Initialize();
console.log(scanner)

結果如圖.

其中TokenDesc會被包裝成更高層的類最後進入抽象語法樹,這些是後話了。字元串的存儲方式、hash表等等後面有空再說吧。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 示例代碼托管在: "http://www.github.com/dashnowords/blogs" 博客園地址: "《大史住在大前端》原創博文目錄" 華為雲社區地址: "【你要的前端打怪升級指南】" [TOC] 一.問題描述 最近向一些同事推薦了網頁中實現流程圖繪製的工具庫 ,Community版 ...
  • 什麼是模態框 簡單來說,模態框就是在原有的界面上新增一個視窗, 以保證在不刷新網頁的前提下和後臺完成交互。 廢話不多說,先來看效果圖 如圖,我們明顯可以看到網頁上面新增了一個視窗,這也就是常見的模態框樣式 模態框結構 由上圖看出,彈出的最上面一層和原本明顯不在一層,模態框具體可分為三分: 1、文本層 ...
  • 對於前端而言,圖標的發展可謂日新月異。從img標簽,到雪碧圖,再到字體圖標,svg,甚至svg也有了類似於雪碧圖的方案svg-sprite-loader。今天咱們先聊聊怎麼使用字體圖標和svg圖標。 ...
  • 很多人應該像我一樣,對於webpack的require.context都是一知半解吧。網上很多關於require.context的使用案例,但是我沒找到可以幫助我理解這個知識點的,於是也決定自己來探索一下,下麵以網上流行的svg圖標方案為例說明。對了,本文的重點是require.context,並不... ...
  • 1、export 在介面名字與模塊內部的變數之間建立了一一對應的關係,export輸出的介面,與其模塊內對應的變數值是動態綁定的,即通過暴露的介面可以取到模塊內與之對應綁定變數的實時的值。 commonjs的規範完全不同,commonjs輸出的是值的緩存,不存在動態的更新。 export的寫法,除了 ...
  • 摘要: 搞懂Vue響應式原理! 作者: "浪里行舟" 原文: "深入淺出Vue響應式原理" "Fundebug" 經授權轉載,版權歸原作者所有。 前言 Vue 最獨特的特性之一,是其非侵入性的響應式系統。數據模型僅僅是普通的 JavaScript 對象。而當你修改它們時,視圖會進行更新。這使得狀態管 ...
  • 在日期格式化時遇到的問題,日期格式化方法在最下麵 如果在中國時區 formatDate('2019-07-09') 結果是 ‘2019-07-09’ 如果 在夏威夷時區 utc-10:00 或者別的時區 formatDate('2019-07-09') 結果是 ‘2019-07-08’ 時區不同導致 ...
  • 1.通過require的方式來引入css,我們來看具體的方法,首先需要安裝css-loader, style-loader(安裝style-loader的目的是為了在html中以style的方式嵌入css)。 cnpm install css-loader --save-dev cnpm insta ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...