50 行代碼的 HTML 編譯器

虛擬 DOM 幾乎已是現代 JS 框架的標配了。那麼該怎樣將 HTML 字符串編譯爲虛擬 DOM 呢?這樣的編譯器並非什麼黑科技,這裏只用了不到 50 行 JS 就實現了一個。html

Demo

HTML Toy Parser Demo 中,能夠將輸入的 HTML 字符串編譯成虛擬 DOM 並渲染在頁面上。這個玩具項目的源碼在 Github 上。node

做爲一個玩具編譯器,它還不能支持一些常見的 HTML 格式,如相似 <h2>123<small>456</small></h2> 這樣將值和標籤混合的寫法。不過,這個玩具是能完善地解析多個並列標籤或深層嵌套標籤的。下面分享一下如何從頭開始搭建出這樣一個簡單的編譯器。python

編譯器 101

編譯器和解釋器不一樣的地方在於,編譯器是將一種編程語言的代碼編譯爲另外一種(例如將高級語言編譯爲機器語言),而解釋器則是將一種編程語言的代碼逐條解釋執行(例如執行各類腳本語言)。編譯器並不須要執行編譯獲得的代碼(如 gcc xxx.c 之後是經過 OS 來執行編譯獲得的 x86 機器碼)而解釋器是直接執行語言代碼(如各類腳本語言都須要經過諸如 python xxx.pynode xxx.js 的方式來執行)。git

因此,將 HTML 字符串轉換爲 DOM 對象的程序就是一個編譯器(雖然十分簡陋)。按照經典的教科書,通常一個完整的編譯過程由三步組成:詞法分析、語法分析和語義分析。這三個流程各對應一個模塊:詞法分析器、語法分析器和語義計算模塊。github

<p>123</p> 這段字符串爲例,對它的編譯過程,首先始於相似【分詞】操做的詞法分析。這個過程就是輸入一段字符串,輸出 <p> / 123 / </p> 三個詞法 Token 的過程。這些 Token 都有各自的屬性(或類型),好比 <p> 是一個開始標籤、而 </p> 是一個結束標籤等。正則表達式

詞法分析器輸入的這些 Token 被輸入語法分析器中進行語法分析。語法分析,其實就是將輸入的一連串 Token 數組構建爲一棵抽象語法樹(AST)的過程。好比,相似 <h2><small>123</small></h2> 這樣嵌套的標籤,解析成語法樹後,<small> 就是 <h2> 的子節點。而相似 <div>123</div> <a>456</a> 這樣並列的標籤則是語法樹中的兄弟節點。構建好這棵語法樹後,就能夠進行語義計算了。算法

最後的語義計算過程就是遍歷語法樹的過程。例如在遍歷一棵虛擬 DOM 語法樹的過程當中,能夠將每一個語法樹上的節點都渲染爲真實的 DOM 節點,從而將虛擬 DOM 綁定到真實 DOM,這樣就實現了完整的從 HTML 字符串編譯到 DOM 元素的流程。編程

詞法分析

這裏的詞法分析器 Lexer 就是一個切分 HTML 字符串的工具。在最簡化的情景下,HTML 字符串所包含的內容能夠分爲這三種:數組

  • 起始標籤,如 <body> / <div> / <span>app

  • 標籤內容,如 123 / abc/ !@#$%

  • 結束標籤,如 </body> / </div> / </span>

一個學術上嚴謹的詞法分析器,須要用有限狀態機來將文本切分紅以上的三種類型。這裏爲了簡單起見,使用了用正則表達式來切分文本。算法很簡單:

  1. 從字符串開頭開始,首先匹配一個結束標籤 Token

  2. 若是沒有匹配到結束標籤,那麼從字符串開頭開始匹配一個開始標籤 Token

  3. 若是仍是沒有匹配到開始標籤,那麼匹配一段標籤值 Token

  4. 每次匹配到一個 Token,都記錄下這個 Token 的類型和文本

  5. 將 Token 的 HTML 字符串去除掉,回到步驟 1 直到切完字符串爲止

詞法分析完成後,所得到的 Token 數組內容大體以下:

tokens = [
    { type: 'TagOpen', val: '<p>' },
    { type: 'Value', val: 'hello' },
    { type: 'TagClose', val: '</p>' },
    { type: 'TagOpen', val: '<div>' },
    { type: 'TagOpen', val: '<h2>' },
    { type: 'TagOpen', val: '<small>' },
    { type: 'Value', val: 'world' },
    { type: 'TagClose', val: '</small>' }
    // ...
]

語法分析

語法分析是將上面獲得的 tokens 數組構造爲一棵語法樹的過程,實現語法分析器 Parser 也是實現簡單編譯器時的難點。Parser 的算法有自頂向下(LL)和自底向上(LR)之分,對比討論暫且略過,下面介紹這個簡單編譯器的 Parser 實現:

首先,詞法分析中獲得的 Tokens 所獲得的 TagOpen / Value / TagClose 這三種類型,在語法樹中的位置是有區別的。例如,只有 Value 能成爲葉子節點,而 TagOpenTagClose 這兩種類型只能用來包裹出一個 HTML 標籤 Tag 類型。而一個或多個 Tag 類型又可以組成 Tags 類型。而一棵語法樹的根節點則是一個只有一個 Tags 子節點的 Html 類型。

如今咱們有了五種類型:即 TagOpen / Value / TagClose / Tag / Tags。這五種類型中,前三種是從詞法分析直接獲得的,稱他們爲【終止符】,然後兩種爲構建語法樹過程當中的 「抽象」 類型,稱它們爲【非終止符

這個 Parser 採用了最簡單的遞歸降低算法來解析 Tokens 數組。遞歸降低的過程是這樣的:

  1. 首先從語法樹頂部的根節點開始,向前【匹配非終止符】。每一個【匹配非終止符】的過程,都是調用一個函數的過程。例如匹配 Tag 須要調用 tag() 函數,匹配 Tags 須要調用 tags() 函數等

  2. 每一個非終止符的函數中,都按照這個非終止符的語法結構,依次匹配各類終止符或非終止符。例如 tag() 函數須要依次匹配 TagOpen - Value - TagClose 三個終止符,或者 TagOpen - Tag - TagClose 這樣兩個終止符和一個非終止符。若是在 tag() 函數中遇到了又須要匹配 Tag 的狀況(這就是 HTML 標籤嵌套的情形)時,就須要再次調用 tag() 函數來向下匹配一個新的 Tag,這也就是所謂的遞歸降低了。

  3. 當全部的 Token 都被吃入並匹配後,完成匹配。

教科書級的代碼示例是這樣的(可是這不是僞代碼,是可以實際執行語法分析的):

// 簡化的 parser.js

// tokens 爲輸入的詞法 Token 數組
// currIndex 爲當前語法分析過程所匹配到的下標,只會逐個向前遞增,不回退
// lookahead 爲當前語法分析遇到的 Token,即 tokens[currIndex]
var tokens, currIndex, lookahead

// 返回下一個 token 並將下標前移一位
function nextToken() {
  return tokens[++currIndex]
}

// 按照所需匹配的終止符類型,匹配下一個終止符
// 若下一個終止符和須要匹配的類型不一直,則說明代碼中存在語法錯誤
// 如在解析 <a> 123 <a> 這三個 Token 時,最後須要 match('TagClose')
// 但此時最後一個 Token 類型爲 TagOpen,這時就會拋出語法錯誤
function match(terminalType) {
  if (lookahead && terminalType === lookahead.type) lookahead = nextToken()
  else throw 'SyntaxError'
}

// LL 中的函數均是用於匹配非終止符的函數
// 若是有更復雜的非終止符,在此添加它們所對應的函數便可
const LL = {
  // 匹配 Html 類型非終止符的函數
  html() {
    // 當存在 lookahead 時,不停向前匹配 Tag 標籤
    while (lookahead) LL.tag()
    // 當完成對全部 Token 的匹配後,lookahead 爲越界的 undefined
    // 這時退出循環,在此結束語法分析過程
    console.log('parse complete!')
  },
  // 匹配 Tag 類型非終止符的函數
  tag() {
    // HTML 標籤的第一個 Token 必定是 TagOpen 類型
    match('TagOpen')
    // 匹配完成 TagOpen 後,可能須要匹配一個嵌套的標籤
    // 也可能須要匹配一個標籤的 Value
    // 這時候就須要經過向前看符號 lookahead 來判斷怎樣匹配
    // 若須要匹配嵌套的標籤,那麼下一個符號必然是 TagOpen 類型
    lookahead.type == 'TagOpen' ? LL.tag() : match('Value')
    // 最後匹配一個結束標籤,即 TagClose 類型的 Token
    match('TagClose')
    // 執行到這裏時,就完成了對一個 HTML 標籤的語法解析
    console.log('tag matched')
  }
}

export default {
  parse(inputTokens) {
    // 初始化各變量
    tokens = inputTokens, currIndex = 0, lookahead = tokens[currIndex]
    // 開始語法分析,目標是將 Tokens 解析爲一整個 HTML 類型
    LL.html()
  }
}

語義分析

上面的語法分析過程當中,並無顯式構建一棵語法樹的代碼。實際上,語法樹是在 LL 中各個匹配非終止符的函數的互相調用中,隱式地構建出來的。要將這棵語法樹轉換爲虛擬 DOM,只須要在 tag()html() 等互相調用的函數中傳入參數便可。

例如將 tag() 函數簽名修改成以下的形式,便可實現

tag(currNode) {
  match('TagOpen')
  // 在遇到嵌套標籤的狀況時,遞歸向下解析
  if (lookahead.type == 'TagOpen') {
    // 將當前節點做爲參數,調用 tags 匹配掉嵌套的標籤
    // 將會返回掛載完成了全部子節點的當前節點
    currNode = NT.tags(currNode)
  } else {
    // 當前標籤是一個葉子節點,這時直接修改當前節點的值
    // 這時 lookahead 指向的已是一個 Value 類型的 Token 了
    currNode.val = lookahead.val
    // 匹配掉這個 Value 類型,
    match('Value')
    // 這時的 lookahead 指向 TagClose 類型
  }
  match('TagClose')
  // 最後返回計算完成的節點給上層
  return currNode
}

因此,這種語法分析方式下,語義計算的完整代碼實際上耦合在了語法分析器中。最後 html() 函數返回的結果,就是一棵虛擬 DOM 語法樹了。

要將得到的虛擬 DOM 渲染爲真實 DOM,是很是容易的。只須要深度遍歷這棵虛擬 DOM 樹,將每一個節點經過 API 插入 DOM 中便可:

// generator.js
function renderNode(target, nodes) {
  // nodes 由調用者傳入,是調用者的所有子節點
  nodes.forEach(node => {
    // trim 用於修剪標籤的首尾文本,例如將 <p> 剪爲 p
    // 而後生成一個全新的 DOM 節點 newNode
    let newNode = document.createElement(trim(node.type))
    
    // node.val 不存在時,說明當前節點不是子節點
    // 此時傳入 node 的子節點遞歸調用本身,深度優先遍歷樹
    if (!node.val) newNode = renderNode(newNode, node.children)
    
    // node.val 存在時,說明當前 node 是葉子節點
    // 此時 node.val 就是當前 DOM 元素的 innerHTML
    else newNode.innerHTML = node.val
    
    // 將新生成的節點掛載到 DOM 上
    target.appendChild(newNode)
  })
  // 向調用者返回掛載後的元素
  return target
}

TODO

上面的一套流程走完後,實際上就實現了從 HTML 字符串到虛擬 DOM 再到真實 DOM 的流程了。因爲虛擬 DOM 的抽象性,所以能夠在 HTML 字符串中經過模板語法來綁定若干變量,而後在這些變量改變後,修改虛擬 DOM 對應的位置,並將虛擬 DOM 的相應部分從新渲染到真實 DOM,從而減小手動從新繪製 DOM 的冗餘代碼,並經過儘可能少地重繪 DOM 來提升性能。

固然了,這個編譯器的語法分析部分採用的是教科書中最簡單的遞歸降低算法,遞歸的方式在不少時候性能都不是最好的。若是但願語法分析可以有儘量高的性能,那麼表驅動的 LR 分析能夠作到這一點。不過 LR 分析中構造分析表的過程是至關複雜的,在此並無殺雞用牛刀的必要。

最後,這個玩具級的編譯器能支持的文法其實至關有限,只是 HTML 的一個子集而已。但願它可以爲編寫其它更有趣的 Parser 提供一些啓發吧。

相關文章
相關標籤/搜索