vue 的模板編譯—ast(抽象語法樹) 詳解與實現


首先AST是什么?

在計算機科學中,抽象語法樹(abstract syntax tree或者縮寫為AST),或者語法樹(syntax tree),是源代碼的抽象語法結構的樹狀表現形式,這里特指編程語言的源代碼。

我們可以理解為:把 template(模板)解析成一個對象,該對象是包含這個模板所以信息的一種數據,而這種數據瀏覽器是不支持的,為Vue后面的處理template提供基礎數據。

這里我模擬Vue去實現把template解析成ast,代碼已經分享到 https://github.com/zhangKunUserGit/vue-ast,具體邏輯都用文字進行了描述,請大家下載運行。

基礎

(1)了解正則表達式,熟悉match,test,  exec 等等JavaScript匹配方法;

(2)了解JavaScript柯里化;

獲取模板

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的方式獲取模板,其他的方式沒有處理。因為我們的重點是如何解析模板。

JavaScript 柯里化

import { createCompiler } from "./createCompiler";

const { compileToFunctions } = createCompiler({});
export { compileToFunctions }
import { parse } from "./parse";

function createCompileToFunctionFn(compile) {
  return function compileToFunctions(template, options) {
    const compiled = compile(template, options)
  }
}

function createCompilerCreator(baseCompile) {
  return function createCompiler() {
    function compile(template, options) {
      const compiled = baseCompile(template, options)
    }
    return {
      compile,
      compileToFunctions: createCompileToFunctionFn(compile)
    }
  }
}
// js柯里化是逐步傳參,逐步縮小函數的適用范圍,逐步求解的過程。
export const createCompiler = createCompilerCreator(function(template, options) {
  console.log('這是要處理的template字符串 -->', template);
  const ast = parse(template.trim(), options);
  console.log('這是處理后的ast(抽象語法樹)字符串 -->', ast);
});

這里我按照Vue源碼邏輯書寫的,柯里化形式的代碼看了容易讓人暈,但是它也有它的好處,在這里體現的淋漓盡致,通過柯里化可以逐步傳參,逐步求解。現在忽略此處,直接看createCompilerCreator()里面的函數就可以了。

解析

我們知道HTML模板是有標簽、文本、注釋組成的,這里不考慮注釋,而標簽又分為單元素標簽(如:img,br 等)和普通標簽(如: div, table 等)。文本又分為帶有綁定的文本(含有{{}} 雙大括號)和普通文本(不含有{{}} 雙大括號)。

所以解析HTML最少要分兩個方法,一個處理標簽,一個處理文本,但是無論單元素還是普通標簽都有開始和閉合,只是形式不一樣罷了。所以把解析HTML 可以分成start(處理開始標簽)、end(處理結束標簽)、char(處理文本):

export function parse(template, options) {
  // 暫存沒有閉合的標簽元素基本信息, 當找到閉合標簽后清除存在於stack里面的元素
  const stack = [];
  // 這里就是解析后的最終數據,這里主要應用了引用類型的特性,最終使root滾雪球一樣,保存標簽的所有信息
  let root;
  // 當前需要處理的元素父級元素
  let currentParent;
  parseHTML(template, {
    start(tag, attrs, unary) {},
    end() {},
    chars(text) {},
  });
  // 把解析后返回出去,這個就是ast(抽象語法樹)
  return root;
}

此時,我們調用了parseHTML函數,看看它干了什么:

export function parseHTML(html, options) {
  const stack = [];
  let index = 0;
  let last, lastTag;
  // 循環html字符串
  while (html) {
    last = html;
    // 處理非script,style,textarea的元素
    if(!lastTag || !isPlainTextElement(lastTag)) {
      let textEnd = html.indexOf('<');
      if (textEnd === 0) {
        // 結束標簽
        const endTagMatch = html.match(endTag);
        if (endTagMatch) {
          const curIndex = index;
          advance(endTagMatch[0].length);
          parseEndTag(endTagMatch[1], curIndex, index);
          continue;
        }
        // 開始標簽
        const startTagMatch = parseStartTag();
        if (startTagMatch) {
          handleStartTag(startTagMatch);
          continue;
        }
      }
      let text;
      // 判斷 '<' 首次出現的位置,如果大於等於0,截取這段,賦值給text, 並刪除這段字符串
      // 這里有可能是空文本,如這種 ' '情況, 他將會在chars里面處理
      if (textEnd >= 0) {
        text = html.substring(0, textEnd);
        advance(textEnd);
      } else {
        text = html;
        html = '';
      }
      // 處理文本標簽
      if (text) {
        options.chars(text);
      }
    } else {
      // 處理script,style,textarea的元素,
      // 這里我們只處理textarea元素, 其他的兩種Vue 會警告,不提倡這么寫
      let endTagLength = 0;
      const stackedTag = lastTag.toLowerCase();
      // 緩存匹配textarea 的正則表達式
      const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'));
      // 清除匹配項,處理text
      const rest = html.replace(reStackedTag, function(all, text, endTag) {
        endTagLength = endTag.length;
        options.chars(text);
        return ''
      });
      index += html.length - rest.length;
      html = rest;
      parseEndTag(stackedTag, index - endTagLength, index);
    }
  }
}

我們第一眼看到的就是那個藍色的while循環。它在那兒默默無聞的循環,直到html為空。在循環體中,用正則判斷html字符串是開始標簽、結束標簽或文本標簽,並分別進行處理。

開始標簽

/**
 * 處理解析后的屬性,重新分割並保存到attrs數組中
 * @param match
 */
function handleStartTag(match) {
  const tagName = match.tagName;
  const unary = isUnaryTag(tagName) || !!match.unarySlash;
  const l = match.attrs.length;
  const attrs = new Array(l);
  for (let i = 0; i < l; i += 1) {
    const args = match.attrs[i];
    attrs[i] = {
      name:args[1], // 屬性名
      value: args[3] || args[4] || args[5] || '' // 屬性值
    };
  }
  // 非單元素
  if (!unary) {
    // 因為我們的parse必定是深度優先遍歷,
    // 所以我們可以用一個stack來保存還沒閉合的標簽的父子關系,
    // 並且標簽結束時一個個pop出來就可以了
    stack.push({
      tag: tagName,
      lowerCasedTag: tagName.toLowerCase(),
      attrs,
    });
    // 緩存這次的開始標簽
    lastTag = tagName;
  }
  options.start(tagName, attrs, unary, match.start, match.end);
}

/**
 * 匹配到元素的名字和屬性,保存到match對象中並返回
 * @returns {{tagName: *, attrs: Array, start: number}}
 */
function parseStartTag() {
  const start = html.match(startTagOpen);
  if (start) {
    // 定義解析開始標簽的存儲格式
    const match = {
      tagName: start[1], // 標簽名
      attrs: [], // 屬性
      start: index, // 標簽的開始位置
    };
    // 刪除匹配到的字符串
    advance(start[0].length);
    // 沒有匹配到結束 '>' ,但匹配到了屬性
    let end, attr;
    while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
      advance(attr[0].length);
      // 把元素屬性都取出,並添加到attrs中
      match.attrs.push(attr);
    }
    if (end) {
      match.unarySlash = end[1];
      advance(end[0].length);
      // start 到 end 這段長度就是這次執行,所處理的字符串長度
      match.end = index;
      return match;
    }
  }
}

具體邏輯我已經寫到代碼中了,其中應用了大量的正則和循環,當匹配到后就調用advance() 刪除匹配的字符串更新html。

結束標簽

/**
 * 解析關閉標簽,
 * 查找我們之前保存到stack棧中的元素,
 * 如果找到了,也就代表這個標簽的開始和結束都已經找到了,此時stack中保存的也就需要刪除(pop)了
 * 並且緩存最近的標簽lastTag
 * @param tagName
 * @param start
 * @param end
 */
function parseEndTag(tagName, start, end) {
  const lowerCasedTag = tagName && tagName.toLowerCase();
  let pos = 0;
  if (lowerCasedTag) {
    for (pos = stack.length -1; pos >= 0; pos -= 1) {
      if (stack[pos].lowerCasedTag === lowerCasedTag) {
        break;
      }
    }
  }
  if (pos >= 0) {
    // 關閉 pos 以后的元素標簽,並更新stack數組
    for (let i = stack.length - 1; i >= pos; i -= 1) {
      options.end(stack[i].tag, start, end);
    }
    stack.length = pos;
    // stack 取出數組存儲的最后一個元素
    lastTag = pos && stack[pos - 1].tag;
  }
}

此時當執行parseEndTag()函數,更新stack和lastTag。

上面提到start(開始標簽)

/**
 * 這個和end相對應,主要處理開始標簽和標簽的屬性(內置和普通屬性),
 * @param tag 標簽名
 * @param attrs 元素屬性
 * @param unary 該元素是否單元素, 如img
 */
start(tag, attrs, unary) {
  // 創建ast容器
  let element = createASTElement(tag,attrs, currentParent);

  // 下面是加工、處理各種Vue支持的內置屬性和普通屬性
  processFor(element);
  processIf(element);
  processOnce(element);
  processElement(element);
  if (!root) {
    root = element;
  } else if (!stack.length && root.if && (element.elseif || element.else)) {
    // 在element的ifConditions屬性中加入condition
    addIfCondition(root, {
      exp: element.elseif,
      block: element
    })
  }
  if (currentParent) {
    if (element.elseif || element.else) {
      processIfConditions(element, currentParent);
    } else if (element.slotScope) {
      // 父級元素是普通元素
      currentParent.plain = false;
      const name = element.slotTarget || '"default"';
      (currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element;
    } else {
      // 把當前元素添加到父元素的children數組中
      currentParent.children.push(element);
      // 設置當前元素的父元素
      element.parent = currentParent;
    }
  }
  // 非單元素,更新父級和保存該元素
  if (!unary) {
    currentParent = element;
    stack.push(element);
  }
},

上面提到end(結束標簽)

/**
 * 閉合元素,更新stack和currentParent
 */
end() {
  // 取出stack中最后一個元素,其實這也是需要閉合元素的開始標簽,如</div> 的開始標簽就是<div>
  // 此時取出的element包含該元素的所有信息,包括他的子元素信息
  const element = stack[stack.length - 1];
  // 取出當前元素的最后一個子節點
  const lastNode = element.children[element.children.length - 1];
  // 如果最后一個子節點是空文本節點,清除當前子節點, 為什么這么做呢?
  // 因為我們在寫HTML時,標簽之間都有間距,有時候就需要這個間距才能達到我們想要的效果,
  // 比如:<div> <span>111</span> <span>222</span> </div>
  // 此時111與222之間就有一格的間距,在ast模板解析時,這個不能忽略,
  // 此時的div的子節點會解析成三個數組, 中間的就是一個文本,只是這個文本是個空格,
  // 而222的span標簽后面的空格我們是不需要的,因為如果我們寫了,div的兄弟節點之間會有一個空格的。
  // 所以我們需要清除children數組中沒有用的項
  if (lastNode && lastNode.type === 3 && lastNode.text === ' ') {
    element.children.pop();
  }
  // 下面才是最重要的,也是end方法真正要做的,
  // 就是找到了閉合標簽,就把保存的開始標簽的信息清除,並更新currentParent
  stack.length -= 1;
  currentParent = stack[stack.length - 1];
},

上面提到的char(文本標簽)

/**
 * 處理文本和{{}}
 * @param text 文本內容
 */
chars(text) {
  // 如果是文本,沒有父節點,直接返回
  if (!currentParent) {
    return;
  }
  const children = currentParent.children;
  // 判斷與處理text, 如果children有值,text為空,那么text = ' '; 原因在end中
  text = text.trim()
    ? text
    : children.length ? ' ' : '';
  if (text) {
    // 解析文本,處理{{}} 這種形式的文本
    const expression = parseText(text);
    if (text !== ' ' && expression) {
      children.push({
        type: 2,
        expression,
        text,
      });
   } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
      children.push({
        type: 3,
        text,
      })
    }
  }
},

這里我們重點需要說一下parseText()方法,解釋都寫在了代碼中。

const tagRE = /\{\{((?:.|\n)+?)\}\}/g;

export function parseText(text) {
  if (tagRE.test(text)) {
    return;
  }
  const tokens = [];
  let lastIndex = tagRE.lastIndex = 0;
  let match, index;
  // exec中不管是不是全局的匹配,只要沒有子表達式,
  // 其返回的都只有一個元素,如果是全局匹配,可以利用lastIndex進行下一個匹配,
  // 匹配成功后lastIndex的值將會變為上次匹配的字符的最后一個位置的索引。
  // 在設置g屬性后,雖然匹配結果不受g的影響,
  // 返回結果仍然是一個數組(第一個值是第一個匹配到的字符串,以后的為分組匹配內容),
  // 但是會改變index和 lastIndex等的值,將該對象的匹配的開始位置設置到緊接這匹配子串的字符位置,
  // 當第二次調用exec時,將從lastIndex所指示的字符位置 開始檢索。
  while ((match = tagRE.exec(text))) {
    index = match.index;
    // 當文本標簽中既有{{}} 在其左邊又有普通文本時,
    // 如:<span>我是普通文本{{value}}</span>, 就會執行下面的方法,添加到tokens數組中。
    if (index > lastIndex) {
      tokens.push(JSON.stringify(text.slice(lastIndex, index)));
    }
    // 把匹配到{{}}中的tag 添加到tokens數組中
    const exp = match[1].trim();
    tokens.push(`_s(${exp})`);
    lastIndex = index + match[0].length
  }
  // 當文本標簽中既有{{}} 在其右邊又有普通文本時,
  // 如:<span>{{value}} 我是普通文本</span>, 就會執行下面的方法,添加到tokens數組中。
  if (lastIndex < text.length) {
    tokens.push(JSON.stringify(text.slice(lastIndex)));
  }
  return tokens.join('+');
}

區分parse和parseHTML

通過上面的代碼我們大概了解了實現方式,但是我們可能暫時無法區分parse和parseHTML方法都做了什么。因為parse里面調用了parseHTML,我們先講講它。

parseHTML: 用正則匹配的方式,逐一循環HTML字符串,分類不同匹配項,保存最基本的tagName(標簽名),attrs(屬性),此時屬性並沒有區分是內置屬性還是普通屬性,只是簡單的分隔了屬性名和屬性值。從函數名中可以看到加了HTML

比如:attrs 可能是這樣的:

attrs = [
  {
    name: '@click',
    value: 'myMethod'
  }, {
    name: ':class',
    value: 'my-class'
  }, {
    name: 'type',
    value: 'button'
  }, {
    name: 'v-if',
    value: 'show'
  }
];

parse: 從parseHTML解析的基本屬性數組中重新解析,區分不同屬性做不同處理,普通屬性與內置屬性處理方式是不一樣的。並且判斷該元素是在哪個位置,也就是確定該元素的父節點、兄弟節點、子節點,最終形成ast。

如何理解stack

stack翻譯成漢語就是“棧”。這里我們可以理解為一個容器,存儲開始標簽的屬性和標簽名。這里Vue進行了巧妙的設計:

當是開始標簽並且標簽是普通標簽(如:div),就push到數組最后面,

當是結束標簽時,找到保存到stack中的項,然后刪除找到的項,刪除就是代表着標簽閉合。

注意:stack 是按照字符串先后順序存儲的,所以我們在接下來解析html字符串時,遇到的閉合標簽就是stack存儲的最后一項。如:

<div><span></span></div>

當執行到</span>字符串前,stack存儲結果:

stack = [div, span];

在執行</span>時,找到stack最后一項,就是span的開始標簽(此時里面包含標簽名和元素屬性)。我們刪除stack中的span(span標簽閉合,span元素的解析結束),此時stack 就只剩下 [div], 以此類推。

總結

最后看看運行前后的效果:

模板解析為ast,需要大量的循環與匹配,需要考慮不同字符串的情況,而這種情況正是我們靜下心來好好思考的。本人才疏學淺,有問題請批評指出。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM