《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
極其簡單的核心邏輯
實現「字串模板<template>
編譯為 render()
函式」的核心邏輯極其簡單,只有 2 部分:
首先用.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 節點。
- 遍歷字串並刪除已遍歷結果
其次,因為<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-if
到if-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部分:
- 一、template 字串編譯為抽象語法樹 AST
- 二、AST 編譯 render() 實現原理
- 三、執行渲染函式 render() 生成虛擬節點 vnode
- 四、虛擬節點 vnode 生成真實DOM
- 五、資料驅動 DOM 更新 - Watcher Observer 和 Dep
正在熱火朝天更新中,歡迎交流~ 歡迎催更~