Vue源碼--AST抽象語法樹

概念介紹

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

過程

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

代碼演變過程
抽象語法樹與虛擬DOM節(jié)點的關系:
抽象語法樹與虛擬DOM節(jié)點的關系

抽象語法樹的終點是渲染函數(shù)(h函數(shù))。

渲染函數(shù)(h函數(shù)),它既是AST的產(chǎn)物,也是vnode(虛擬節(jié)點)的起源。h函數(shù)里面是不含指令的。

抽象語法樹不會進行diff算法的并且抽象語法樹不會直接生成虛擬節(jié)點,抽象語法樹最終生成的是渲染函數(shù)的

相關算法儲備
  1. 指針思想(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)
  1. 遞歸深入-即規(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))
}

這樣做的內存開銷是非常大的,相當于每次計算都需要把前兩項再計算一下

image.png

所以優(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;
}
  1. 遞歸的深入用法(離最終的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ù)結構

image.png

試編寫“智能重復”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ù)字展開字符。

結合棧的概念,那這個例子的解法思路是:

  1. 遍歷每一個字符,如果是數(shù)字,那么就將該數(shù)字壓入棧①;如果是[,就把空字符串壓入棧②;如果是字母,就用這個字符替換棧②頂?shù)目兆址?/p>

  2. 如果是],就將棧①中的數(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的方式獲取模板,查看如何解析模板。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容