《8分鐘學會 Vue.js 原理》:一、template 字串編譯為抽象語法樹 AST

帥到被人砍 發表於 2022-04-12
Vue

《8分鐘學會 Vue.js 原理》:一、template 字串編譯為抽象語法樹 AST

Vue.js 並沒有什麼神祕的魔法,模板渲染、虛擬 DOM diff,也都是一行行程式碼基於 API 實現的。

本文將用幾分鐘時間,分章節講清楚 Vue.js 2.0 的 <template>渲染為 HTML DOM 的原理。

有任何疑問,歡迎通過評論交流~~

本節目標

將 Vue.js 的字串模板template編譯為抽象語法樹 AST;

完整示例:DEMO -《8分鐘學會 Vue.js 原理》:一、template 字串編譯為抽象語法樹 AST - JSBin

3670fe62c282c35ae6c293bb85c8f6c

極其簡單的核心邏輯

實現「字串模板<template>編譯為 render() 函式」的核心邏輯極其簡單,只有 2 部分:

  1. String.prototype.match()

首先用.match()方法,提取字串中的關鍵詞,例如標籤名div,Mustache標籤對應的變數msg等。

用 1 行程式碼就能說明白:

'<div>{{msg}}</div>'.match(/\{\{((?:.|\r?\n)+?)\}\}/)
// ["{{msg}}", "msg"]

這個示例用/\{\{((?:.|\r?\n)+?)\}\}/ 正規表示式,提取了字串'<div>{{msg}}</div>'中的Mustache標籤

暫時不用理解該正則的含義,會用即可。

如果想要理解正則,可以試一試正則線上除錯工具:Vue.js 開始標籤正則,能夠視覺化的檢查上述正規表示式

獲得了"msg"標籤對應變數的名稱,我們就能在後續拼接出渲染DOM所需要的_vm.msg

即從我們宣告的例項new Vue({data() {return {msg: 'hi'}}})中提取出msg: 'hi',渲染為 DOM 節點。

  1. 遍歷字串並刪除已遍歷結果

其次,因為<template>本質上是一段有大量HTML標籤的字串,通常內容較長,為了不遺漏地獲取到其中的所有標籤、屬性,我們需要遍歷。

實現方式也很簡單,用while(html)迴圈,不斷的html.match()提取模板中的資訊。(html變數即template字串)

每提取一段,再用html = html.substring(n)刪除掉n個已經遍歷過的字元。

直到html字串為空,表示我們已經遍歷、提取了全部的template

html = `<div`   // 僅遍歷一遍,提取開始標籤

const advance = (n) => {
    html = html.substring(n)
}

while (html) {
    match = html.match(/^<([a-zA-Z_]*)/)
    // ["<div", "div"]
    if (match) {
        advance(match[0].length)
        // html = '' 跳出迴圈         
    }
}

理解了這2部分邏輯,就理解了字串模板template編譯為 render() 函式的原理,就是這麼簡單!

具體步驟

0. 基於class語法封裝

我們用 JS 的class語法對程式碼進行簡單的封裝、模組化,具體來說就是宣告 3 個類:

// 將 Vue 例項的字串模板 template 編譯為 AST
class HTMLParser {}

// 基於 AST 生成渲染函式;
class VueCompiler {
    HTMLParser = new HTMLParser()
}

// 基於渲染函式生成虛擬節點和真實 DOM
class Vue {
    compiler = new VueCompiler()
}

問:為什麼要生成 AST?直接把template字串模板編譯為真實 DOM 有什麼問題?

答:沒有問題,技術上也可以實現,
Vue.js 以及眾多編譯器都採用 AST 做為編譯的中間狀態,個人理解是為了編譯過程中做「轉化」(Transform),
例如v-if屬性轉化為 JS 的if-else判斷,有了 AST 做為中間狀態,有助於更便捷的實現v-ifif-else

1. 開始遍歷template字串模板

基於我們上述提到的while()html = html.substring(n)

我們可以實現一套一邊解析模板字串、一邊刪除已解析部分,直到全部解析完成的邏輯。

很簡單,只有幾行程式碼,

我們為class HTMLParser增加一個parseHTML(html, options)方法:

parseHTML(html, options) {
  const advance = (n) => {
    html = html.substring(n)
  }

  while(html) {
    const startTag = parseStartTag()  // TODO 下一步實現 parseStartTag
    if (startTag) {
      advance(startTag[0].length)
      continue
    }
  }
}

html引數即初始化 Vue 例項中的template: '<div>{{msg}}</div>',屬性,

在遍歷 html 過程中,我們每解析出一個關鍵詞,就呼叫advance() { html.substring(n) },刪去這部分對應的字串,

示例中我們呼叫parseStartTag()(下一步實現)解析出開始標籤<div>對應的字串後,就從 html 刪除了<div>這5個字元。

2. 解析開始標籤<div>

接下來讓我們實現parseStartTag(),解析出字串中的開始標籤<div>

也非常簡單,用html.match(regularExp)即可,我們可以從Vue.js 原始碼中的 html-parser.js找到對應的正規表示式startTagOpen

原始碼的正則中非常複雜,因為要相容生產環境的各種標籤,我們暫時不考慮,精簡後就是:/^<([a-zA-Z_]*)/

線上除錯開始標籤正則

用這個正則呼叫html.match(),即可得到['<div', 'div']這個陣列。

提取出需要的資訊後,就把已經遍歷的字元呼叫advance(start[0].length)刪除。

const parseStartTag = () => {
  let start = html.match(this.startTagOpen);
  // ['<div', 'div']
  if (start) {
    const match = {
      tagName: start[1],
      attrs: [],
    }
    advance(start[0].length)
  }
}

注意,startTagOpen只匹配了開始標籤的部分'<div',還需要一次正則匹配,找到開始標籤的結束符號>

找到結束符號後,也要刪除對應已遍歷的部分。

const end = html.match(this.startTagClose)
debugger
if (end) {
  advance(end[0].length)
}
return match

兩部分組合後,就能完整遍歷、解析出模板字串中的<div>開始標籤了。

3. 解析文字內容{{msg}}

下一步我們把模板字串中的文字內容{{msg}}提取出來,仍然是字串遍歷 && 正則匹配

繼續補充 while 迴圈:

while (html) {
  debugger
  // 順序對邏輯有影響,startTag 要先於 text,endTag 要先於 startTag
  const startTag = parseStartTag()
  if (startTag) {
    handleStartTag(startTag)
    continue
  }

  let text
  let textEnd = html.indexOf('<')
  if (textEnd >= 0) {
    text = html.substring(0, textEnd)
  }

  if (text) {
    advance(text.length)
  }
}

因為刪除了已解析部分、並且各部分有解析順序,所以我們只要檢測下一個<標籤的位置即可獲得文字內容在 html 中的結束下標:html.indexOf('<')

之後就能獲得完整的文字內容{{msg}}text = html.substring(0, textEnd)

最後,別忘了,刪除已經遍歷的文字內容:advance(text.length)

4. 解析閉合標籤</div>

到這一步,html 字串已經只剩下'</div>'了,我們繼續用遍歷&&正則解析:

while (html) {
  debugger
  // 順序對邏輯有影響,startTag 要先於 text,endTag 要先於 startTag
  let endTagMatch = html.match(this.endTag)
  if (endTagMatch) {
    advance(endTagMatch[0].length)
    continue
  }
}

我們暫時不需要從閉合標籤中提取資訊,所以只需要遍歷、匹配後,刪除它即可。

解析完成總結

到目前為止,我們已經基本實現了class HTMLParser {},多次用正則解析提取出了 template 字串中的 3 部分資訊:

  • 開始標籤:'<div', '>'
  • 文字內容:'{{msg}}'
  • 閉合標籤:'</div>'

這部分的完整程式碼,可以訪問DEMO -《8分鐘學會 Vue.js 原理》:一、template 字串編譯為抽象語法樹 AST - JSBin檢視。

但為了獲得 AST,我們還需要基於這些資訊,做一些簡單的拼接。

5. 初始化抽象語法樹 AST 的根節點

我們繼續參考Vue.js 原始碼中拼接 AST 的實現

完善class VueCompiler,新增HTMLParser = new HTMLParser()例項,以及parse(template)方法。

class VueCompiler {
  HTMLParser = new HTMLParser()

  constructor() {}

  parse(template) {}
}

AST 是什麼?

先不用去理解晦澀的概念,在 Vue.js 的實現中,AST 就是普通的 JS object,記錄了標籤名、父元素、子元素等屬性:

createASTElement (tag, parent) {
  return { type: 1, tag, parent, children: [] }
}

我們把createASTElement方法也新增到class VueCompiler中。

並增加parse方法中this.HTMLParser.parseHTML()的呼叫

parse(template) {
  const _this = this
  let root
  let currentParent

  this.HTMLParser.parseHTML(template, {
    start(tag) {},
    chars (text) {},
  })

  debugger
  return root
}

start(tag) {}就是我們提取開始標籤對應 AST 節點的回撥,

其接受一個引數tag,呼叫_this.createASTElement(tag, currentParent)來生成 AST 節點。

 start(tag) {
    let element = _this.createASTElement(tag, currentParent)

    if (!root) {
      root = element
    }
    currentParent = element
  },

呼叫start(tag)的位置在class HTMLParser中的parseHTML(html, options)方法:

  const handleStartTag = (match) => {
    if (options.start) {
      options.start(match.tagName)
    }
  }

  while(html) {
    const startTag = parseStartTag()
    if (startTag) {
      handleStartTag(startTag)
      continue
    }
  }

當我們通過parseStartTag()獲取了{tagName: 'div'},就傳給options.start(match.tagName),從而生成 AST 的根節點:

// root
'{"type":1,"tag":"div","children":[]}'

我們把根節點儲存到root變數中,用於最終返回整個AST的引用。

6. 為 AST 增加子節點

除了根節點,我們還需要繼續為 AST 這棵樹新增子節點:文字內容節點

仍然是用回撥的形式(options.char(text)),提取出文字內容節點所需的資訊,

完善VueCompiler.parse()中的chars(text)方法

chars(text) {
  debugger
  const res = parseText(text)
  const child = {
    type: 2,
    expression: res.expression,
    tokens: res.tokens,
    text
  }
  if (currentParent) {
    currentParent.children.push(child)
  }
},

parseHTML(html, options)的迴圈中新增options.chars(text)呼叫:

while (html) {
  // ...省略其他標籤的解析
  let text
  let textEnd = html.indexOf('<')
  // ...

  if (options.chars && text) {
    options.chars(text)
  }
}

解析文字內容的Mustache標籤語法

options.chars(text)接收的text值為字串'{{msg}}',我們還需要從中剔除{{}},拿到msg字串。

仍然是用熟悉的正則匹配:

  const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/
  function parseText(text) {
    let tokens = []
    let rawTokens = []
    const match = defaultTagRE.exec(text)

    const exp = match[1]
    tokens.push(("_s(" + exp + ")"))
    rawTokens.push({ '@binding': exp })

    return {
      expression: tokens.join('+'),
      tokens: rawTokens
    }
  }

結果將是:

{
  expression: "_s(msg)",
  tokens: {
    @binding: "msg"
  }
}

暫時不必瞭解expression, tokens及其內容的具體含義,後續到執行時階段我們會再詳細介紹。

7. 遍歷template字串完成,返回 AST

完整示例:DEMO -《8分鐘學會 Vue.js 原理》:一、template 字串編譯為抽象語法樹 AST - JSBin

經過以上步驟,我們將 template 字串解析後得到這樣一個物件:

// root ===
{
    "type": 1,
    "tag": "div",
    "children": [
        {
            "type": 2,
            "expression": "_s(msg)",
            "tokens": [
                {
                    "@binding": "msg"
                }
            ],
            "text": "{{msg}}"
        }
    ]
}

這就是 Vue.js 的 AST,實現就是這麼簡單,示例中的程式碼都直接來自 Vue.js 的原始碼(compiler 部分

後續我們將基於 AST 生成render()函式,並最終渲染出真實 DOM。

<hr/>

《8分鐘學會 Vue.js 原理》系列,共計5部分:

正在熱火朝天更新中,歡迎交流~ 歡迎催更~