概念介紹
在開發(fā)Vue的時候編譯器會將模板語法編譯成正常的HTML語法,而直接編譯的時候是非常困難的,因此此時會借助AST抽象語法樹進行周轉,進而變?yōu)檎5腍TML語法,使編譯工作變得更加簡單。

抽象語法樹的本質上是一個JS對象,Vue在審視所有HTML結構時是以字符串的新式進行的,最終將其解析為JS對象。AST抽象語法樹服務于模板編譯,將一種語法翻譯為另一種語法。在Vue中將模板語法編譯為HTML語法,自己作為中轉站。

抽象語法樹與虛擬DOM節(jié)點的關系:

抽象語法樹的終點是渲染函數(shù)(h函數(shù))。
渲染函數(shù)(h函數(shù)),它既是AST的產(chǎn)物,也是vnode(虛擬節(jié)點)的起源。h函數(shù)里面是不含指令的。
抽象語法樹不會進行diff算法的并且抽象語法樹不會直接生成虛擬節(jié)點,抽象語法樹最終生成的是渲染函數(shù)的
相關算法儲備
-
指針思想(JS中的指針只是字符串或者數(shù)組的一個下標位置,不同于C語言中的指針,多說一句,C語言中的指針是可以操作內存的)
在此列一個算法例子,體會指針在js中的應用:找出字符串a(chǎn)aaaaabbbbbbbcccccccccccccddddd'中,連續(xù)重復出現(xiàn)次數(shù)最多的字符。
在解這個算法的時候,既然有“最多”,那必然是有比較,而比較的對象至少得有兩個,因此首先就要想到我們需要設置兩個指針,而這兩個指針的位置如何確定呢?再一看題目是“連續(xù)”,所以兩指針的初始位置必然是相鄰的,如下圖:
image.png
確定了i,j兩指針位置后,就開始進行比較了,比較過程中如果i,j指向的字符相同,則i不動,j后移;否則將j此刻的值賦給i,j后移一位,說明在賦值之前,i,j之前的字符都是相同的;開啟新一輪i,j是否相同的比較,比較的結束條件是i不小于這個字符串的長度length。
分析完解題思路,代碼就好寫了:
// 給定一個字符串
var str = 'aaaaaabbbbbbbcccccccccccccddddd';
// 設置兩指針
var i = 0, j = 1;
// 記錄重復最多次數(shù)
var maxRepeat = 0;
// 記錄重復最多的字符;
var maxRepeatStr= '';
// 當i還在范圍內的時候,應該繼續(xù)尋找
while(i <= str.length - 1) {
// 看i指向的字符和j指向的字符是不是不相同
if (str[i] ==== str[j]) {
j++;
} else {
// console.log(i+'和'+j+'之間的文字連續(xù)相同,字母'+str[i]+'重復了'+ (j - i) + '次')
// 和當前重復次數(shù)最多的進行比較
if (j - i > maxRepeat) {
// 如果當前文字重復次數(shù)(j-i)超過了此時的最大值
// 就讓它成為最大值
maxRepeat = j - i;
maxRepeatStr = str[i]
}
i = j;
j++;
}
}
// 循環(huán)結束之后,就可以輸出答案了
console.log('maxRepeatChar', maxRepeatChar)
- 遞歸深入-即規(guī)則復現(xiàn)
同上列舉一個例子,體會遞歸的運算效率:試輸出斐波那契數(shù)列的前10項,即1、1、2、3、5、8、13、21、34、55。然后請思考,代碼是否有大量重復的計算?應該如何解決重復計算?
觀察推理得出,這一列數(shù)字從第三項起都是自身得前兩項之和,所以每次只要前兩項相加即可,初級代碼如下:
// 試輸出斐波那契數(shù)列的前10項,即1、1、2、3、5、8、13、21、34、55
// 創(chuàng)建一個函數(shù),功能是返回下標為n的這項的數(shù)字
// 遞歸需要有終點
function fib(n) {
console.log('fid-n:', n)
// 看下標n是不是0或1,如果是,返回常數(shù)1
// 如果不是,就遞歸
return n == 0 || n == 1 ? 1 : fib(n - 1) + fib(n - 2)
}
for (var i = 0; i < 9; i++) {
console.log(fib(i))
}
這樣做的內存開銷是非常大的,相當于每次計算都需要把前兩項再計算一下

所以優(yōu)化以上的算法,在此引入一個緩存的概念,這樣每次計算的值都進行緩存,再進行下次計算時只需要把緩存里的兩項拿出來做計算就可以。
for(var i = 0; i < 10; i ++) {
console.log(fib(i));
}
// 定義一個緩存對象,用來存放已經(jīng)計算的項
var cache = {};
function fib(n) {
// 判斷緩存對象中有沒有這個值,如果有,直接返回
if (cache.hasOwnProperty(n)) {
return cache[n]
}
// 緩存對象沒有這個值
// 看下標n是不是0或1,如果是,返回常數(shù)1
// 如果不是,就遞歸
var v = (n === 0 || n === 1) ? 1 : fib(n -1) + fib(n - 2);
// 寫入緩存,也就是說,每算一個值,就要把這個值存入緩存對象
cache[n] = v;
return v;
}
- 遞歸的深入用法(離最終的AST算法越來越接近了~)
試將高維數(shù)組[1, 2, [3, [4, 5], 6], 7, [8], 9]變?yōu)閳D中所示的對象
{
children: [
{value: 1},
{ value: 2},
{ children: [
{value: 3},
{ children: [
{ value: 4},
{ value: 5}
]},
{value: 6}
]},
{ value: 7},
{ children: [
{ value: 8},
{ value: 9}
]}
]
}
這個算法是將多維數(shù)組處理為一個對象,從第一層去遍歷,如果是數(shù)字,則存為對象,若是數(shù)組,則存為該層的一個子級chldren,再按照此規(guī)律處理自己里邊的數(shù)字,數(shù)組,直到再沒有數(shù)組為止。通常的做法是將這個數(shù)組作為整體去遞歸:
// 解法①
// 測試數(shù)組
var arr = [1,2, 3, [4, 5, [6, 7], 8], 9];
var indexI = 0;
// 轉換函數(shù)
function convert(arr) {
indexI++;
// 準備一個結果數(shù)組
var result = [];
// 遍歷傳入的arr的每一項
for (var i = 0; i < arr.length; i++) {
// 如果遍歷到的數(shù)字是number,直接放進去
if (typeof arr[i] == "number") {
result.push({value: arr[i]})
} else if (Array.isArray(arr[i])) {
// 如果遍歷到的是數(shù)組,先建一個children
result.push({
children: convert(arr[i])
})
console.log('result:', result)
}
}
// console.log('result:', result)
return result;
}
var obj = convert(arr)
console.log(indexI) // 3
console.log(obj)
// 解法②
// 測試數(shù)組
var arr1 = [1,2, 3, [4, 5, [6, 7], 8], 9];
var indexJ = 0;
// 轉換函數(shù)
// 即寫法1的遞歸次數(shù)小于寫法②,寫法②中,遇見任何類型數(shù)據(jù)都要遞歸一下
function convert1(item) {
indexJ++;
if (typeof item == 'number') {
return {value: item}
} else if (Array.isArray(item)) {
// 如果傳進來的參數(shù)是數(shù)組
return {
children: item.map(_item => convert1(_item))
}
}
}
var obj1 = convert1(arr1)
console.log(indexJ) // 12
console.log(obj1)
4、堆棧
又名堆棧,它是一種運算受限的線性表,僅在表尾能進行插入和刪除操作。這一端被稱為棧頂,相對地,把另一端稱為棧底。
向一個棧插入新元素又稱作進棧、入?;驂簵?;從一個棧刪除元素又稱作出棧或退棧。
后進先出(LIFO)特點:棧中的元素,最先進棧的必定最后出棧,后進棧的一定會先出棧。
JS中,??梢杂脭?shù)組模擬。需要限制只能使用push()和pop(),不能使用unshift()和shift()。即數(shù)組尾是棧頂。
棧棧和遞歸非常像 詞法分析的時候,經(jīng)常要用到棧這個數(shù)據(jù)結構

試編寫“智能重復”smartRepeat函數(shù),實現(xiàn):
將3[abc]變?yōu)閍bcabcabc
將3[2[a]2[b]]變?yōu)閍abbaabbaabb
將2[1[a]3[b]2[3[c]4[d]]]變?yōu)閍bbbcccddddcccddddabbbcccddddcccdddd
不用考慮輸入字符串是非法的情況,比如:
2[a3[b]]是錯誤的,應該補一個1,即2[1[a]3[b]]
[abc]是錯誤的,應該補一個1,即1[abc]
看到這個題目,是不是跟上面的指針的例子有點前后呼應了,指針的例子是找出重復的字符,而這這個例子則是按照[前的數(shù)字展開字符。
結合棧的概念,那這個例子的解法思路是:
遍歷每一個字符,如果是數(shù)字,那么就將該數(shù)字壓入棧①;如果是[,就把空字符串壓入棧②;如果是字母,就用這個字符替換棧②頂?shù)目兆址?/p>
如果是],就將棧①中的數(shù)字n彈棧,再把棧②中棧頂?shù)淖帜钢貜蚽(這個數(shù)字)次數(shù),從棧②中彈出,拼接到新棧②的頂上。
function smartRepeat(templateStr) {
let index = 0; // 指針
let stack1 = [], stack2 = []; // stack1存放數(shù)字,stack2存放臨時字符串
var rest = templateStr; // 字符串剩余部分
while(index < templateStr.length - 1){ // 遍歷
rest = templateStr.substring(index); // 剩余部分
if (/^\d+\[/.test(rest)){ // 看當前剩余部分是不是以數(shù)字和[開頭
let times = Number(rest.match(/^(\d+)\[/)[1]); // 得到這個數(shù)字
stack1.push(times); // 就把數(shù)字壓棧
stack2.push('') // 把空字符串壓棧
index += times.toString().length + 1; // 讓指針后移,times這個數(shù)字是多少位數(shù),就后移多少位在加1
} else if (/^\w+]/.test(rest)){
// 如果是字母,那么此時就把棧頂這項改為這個字母
let word = rest.match(/^(\w+)\]/)[1];
stack2[stack2.length - 1] = word;
// 讓指針后移,word這個數(shù)字是多少位數(shù),就后移多少位在加1
index += word.length
} else {
// 如果這個字符是],那么就
// Ⅰ將stack1彈棧,就把字符串棧②的棧②頂?shù)脑刂貜蛣倓傔@個數(shù)字次數(shù),
let times_pop = stack1.pop();
// Ⅱ彈棧②(stack2),
let word = stack2.pop();
// Ⅲ把字符串棧的新棧頂?shù)脑刂貜蛣倓倧棾龅哪莻€字符串指定次數(shù),拼接到新棧②頂上
// repeat 是es6的方法,比如'a'.repeat(3), 得到'aaa'
stack2[stack2.length - 1] += word.repeat(times_pop)
index += 1
}
}
// while結束之后,stack1和stack2中肯定還剩余1項。
// 返回棧2中剩下的這一項,重復棧1中剩下的這1項次數(shù),組成這個字符串
// 如果剩的個數(shù)不對,那就是方括號]沒有閉合
return stack2[0].repeat(stack1[0])
}
var str = smartRepeat('3[1[a]3[b]2[3[c]4[d]]]')
console.log(str) // abbbcccddddcccddddabbbcccddddcccddddabbbcccddddcccdddd
AST的形成
有了前面四部分的鋪墊,基本掌握了AST所需要的算法及數(shù)據(jù)結構,下面給出一個模板字符串(相當于vue種template中的模板)
var templateString = `
<div class="box aa" id="mybox">
<h3>你好</h3>
<ul>
<li>A</li>
<li>B</li>
<li>C</li>
<li>D</li>
</ul>
</div>
`
var ast = parse(templateString)
console.log('ast:\n', ast)
parse函數(shù)就是將模板解析為AST,最終返回AST,解析過程和上例基本相同:
parse.js
export default function (templateString) {
// 指針
var index = 0;
// 剩余部分
var rest = '';
// 開始標記
var startRegExp = /^\<([a-z]+[1-6]?)(\s[^\<]+)?\>/;
// 結束標記
var endRegExp = /^\<\/([a-z]+[1-6]?)/;
// 準備兩個棧;
var stack1 = [];
var stack2 = [{children: []}];
// 抓取結束標記前的文字
var wordRepExp = /^([^\<]+)\<\/[a-z]+[1-6]/
while (index < templateString.length - 1) {
rest = templateString.substring(index)
// console.log(templateString[index])
// 識別遍歷到的這個字符,是不是一個開始標簽
if (startRegExp.test(rest)) {
let tag = rest.match(startRegExp)[1];
let attrsString = rest.match(startRegExp)[2];
// console.log('檢測到開始標記:', tag)
// 將開始標記推入棧中
stack1.push(tag)
stack2.push({children: [], tag: tag, attrs: parseAttrString(attrsString)})
// 指針移動標簽的長度加2再加attrsString的長度,因為<>占兩位
const attrLen = attrsString ? attrsString.length : 0;
index += tag.length + 2 + attrLen;
} else if (endRegExp.test(rest)) {
// 識別遍歷到的字符,是不是結束標簽
// 指針移動標簽的長度加3,因為</>占三位
let tag = rest.match(endRegExp)[1];
// 此時tag一定是和stack1棧頂元素相同的
let pop_tag = stack1.pop();
if (tag == pop_tag) {
let pop_arr = stack2.pop();
if (stack2.length) {
// 檢查stack2[stack2.length - 1]是否有children屬性,如果沒有就創(chuàng)建一個數(shù)組
stack2[stack2.length - 1].children.push(pop_arr)
}
} else {
throw new Error(stack1[stack1.length - 1] + '標簽沒有封閉')
}
// console.log('檢測到結束標記:', tag)
index += tag.length + 3;
// console.log(stack1, JSON.stringify(stack2))
} else if (wordRepExp.test(rest)) {
// 識別遍歷到的這個字符,是不是文字,并且不是全空
let word = rest.match(wordRepExp)[1];
// 看word是不是全是空
if (!/^\s+$/.test(rest)) {
// 不是空
// console.log('檢測到文字-', word)
// 改變此時sctack2棧頂元素
stack2[stack2.length - 1].children.push({
'text': word,
'type': 3
})
}
// 指針移動標簽的長度加字符長度
index += word.length;
} else {
// 標簽中的文字
index++;
}
}
// console.log(stack2)
// 此時stack2就是我們之前默認放置的一項了,此時要返回這一項的children即可
return stack2[0].children[0];
}
在這個AST的分解過程,還有一個不能忽略的細節(jié)是:標簽所帶的屬性,如class,id等,所以parseAttrString函數(shù)中就是對標簽屬性的處理:
parseAttrString.js
// 把attrsString 組裝為數(shù)組之后返回
export default function (attrsString) {
if (!attrsString) return []
// console.log('attrsString', attrsString)
// 當前是否在引號內
var isYinhao = false;
// 斷點
var point = 0;
// 結束數(shù)組
var result = [];
// 遍歷attrsString,而不是用split()一個空格分隔
for (let i = 0; i < attrsString.length; i++) {
let char = attrsString[i];
if (char == '"') {
isYinhao = !isYinhao
} else if (char == ' ' && !isYinhao) {
// 遇見了空格,并且不在引號內
// console.log(i)
if (!/^\s*$/.test(attrsString.substring(point, i))) {
result.push(attrsString.substring(point, i).trim())
}
point = i;
}
}
// 循環(huán)結束之后,最后還剩一個屬性k-v
result.push(attrsString.substring(point).trim())
// 下面的代碼功能是:將["k=v","k=v", "k=v"]變?yōu)閇{name: k, value: v}, {name: k, value: v}]這種類型
result = result.map(item => {
// 根據(jù)等號拆分
const o = item.match(/^(.+)="(.+)"$/);
return {
name: o[1],
value: o[2]
}
})
console.log(result)
return result
}
另外一種解釋
import { compileToFunctions } from './compileToFunctions';
// Vue 對象
function Vue(options) {
// 獲取模板
const selected = document.querySelector(options.el);
this.$mount(selected);
}
// mount 模板
Vue.prototype.$mount = function (el) {
const html = el.outerHTML;
compileToFunctions(html, {});
};
export default Vue;
使用querySelector的方式獲取模板,查看如何解析模板。
