3天學寫mvvm框架[二]:模板解析

此前爲了學習Vue的源碼,我決定本身動手寫一遍簡化版的Vue。如今我將我所瞭解到的分享出來。若是你正在使用Vue但還不瞭解它的原理,或者正打算閱讀Vue的源碼,但願這些分享能對你瞭解Vue的運行原理有所幫助。html

目標

今天咱們的目標是,對於如下的html模板:前端

<div class="outer">
  <div class="inner" v-on-click="onClick($event, 1)">abc</div>
  <div class="inner" v-class="{{innerClass}}" v-on-click="onClick">1{{name}}2</div>
</div>
複製代碼

咱們但願生成以下的js代碼:vue

with(this) {
  return _c(
    'div',
    {
      staticClass: "outer"
    },
    [
      _c(
        'div',
        {
          staticClass: "inner",
          on: {
            "click": function($event) {
              onClick($event, 1)
            }
          }
        },
        [_v("abc")]
      ),
      _c(
        'div',
        {
          staticClass: "inner",
          class: {
            active: isActive
          },
          on: {
            "click": onClick
          }
        },
        [_v("1" + _s(name) + "2")]
      )
    ]
  )
}
複製代碼

(注:對於生成的代碼,爲了方便展現,這裏手動的添加了換行與空格;對於模板,接下來將實現的代碼還不能正確處理換行和空格,這裏也是爲了展現而添加了換行和空格。)git

解析html

咱們的工做將分爲兩步進行:github

  • 首先將字符串形式的模板解析後處理爲咱們須要的數據格式,這裏將其稱爲AST Tree(抽象語法樹)。
  • 接着,咱們將遍歷這顆樹,生成咱們的代碼。

首先,咱們建立類ASTElement,用來存放咱們的抽象語法樹:ASTElement實例擁有一個數組children,用來存放這個節點的子節點,一棵樹的入口是它的根節點;節點類型咱們簡單地劃分爲兩類,文本節點和普通節點(分別將經過document.createTextNodedocument.createElement建立);文本節點擁有text屬性,而普通節點將包含標籤tag信息和attrs列表,attrs用來存放classstylev-if@click:class這類的各類信息:正則表達式

const ASTElementType = {
  NORMAL: Symbol('ASTElementType:NORMAL'),
  PLAINTEXT: Symbol('ASTElementType:PLAINTEXT')
};

class ASTElement {
  constructor(tag, type, text) {
    this.tag = tag;
    this.type = type;
    this.text = text;
    this.attrs = [];
    this.children = [];
  }

  addAttr(attr) {
    this.attrs.push(attr);
  }

  addChild(child) {
    this.children.push(child);
  }
}
複製代碼

解析模板字符串的過程,將從模板字符串頭部開始,循環使用正則匹配,直至解析完整個字符串。讓咱們用一張圖來表示這個過程:數組

在左邊的圖中,咱們看到,示例模板被咱們分爲多個部分,分別歸爲3類:開始標籤、結束標籤和文本。開始標籤能夠包含屬性對。bash

而在右邊的解析過程示意圖中,咱們看到咱們的解析是一個循環:每次循環,首先判斷下一個<字符是否是就是接下來的第一個字符,若是是,則嘗試匹配標籤,匹配標籤又分爲兩種狀況,前後嘗試匹配開始標籤與結束標籤;若是不是,則將當前位置直到下一個<字符之間字符串都做爲文本處理(爲了簡化代碼這裏忽略了文本中包含<的狀況)。如此循環直至模板所有被解析:函數

const parseHtml = function (html) {
  const stack = [];
  let root;
  let currentElement;

  ...

  const advance = function (length) {
    index += length;
    html = html.substring(length);
  };

  while (html) {
    last = html;

    const textEnd = html.indexOf('<');

    if (textEnd === 0) {
      const endTagMatch = html.match(endTag);
      if (endTagMatch) {
        ...
        continue;
      }

      const startTagMatch = parseStartTag();
      if (startTagMatch) {
        ...
        continue;
      }
    }

    const text = html.substring(0, textEnd);
    advance(textEnd);

    if (text) chars(text);
  }

  return root;
};
複製代碼

咱們申明瞭幾個變量,它們分別表示:學習

  • stack:存放ASTElement的棧結構,例如對於<div class="a"><div class="b"></div><div class="c"></div></div>,則會依次push(.a) -> push(.b) -> pop -> push(.c) -> pop -> pop。經過這個棧結構的數據咱們能夠檢查模板中的標籤是否正確地匹配了,不過在這裏咱們會略去這種檢查,認爲全部的標籤都正確匹配了。
  • root:表示整個ASTElement樹的根節點,在趕上第一個開始標籤併爲其建立ASTElement實例時會設置這個值。一個模板應當只有根節點,這也是能夠經過stack變量的狀態來檢查的。
  • currentElement:當前正在處理的ASTElement實例,同時也應當是stack棧頂的元素。

處理閉合標籤

在循環體中,咱們使用了正則endTag來來嘗試匹配閉合標籤,它的定義以下:

const endTag = /^<\/([\w\-]+)>/;
複製代碼

用圖來表示:

\w匹配包括下劃線的任何單詞字符,相似但不等價於「[A-Za-z0-9_]」。這個正則能夠匹配</item></Item></item-one>等字符串。固然有不少符合規範的閉合標籤的形式被排除在外了,不過出於理解Vue原理的目的這個正則對咱們來講就夠了。

若是咱們比配到了閉合標籤,那咱們須要跳過被匹配到的字符串(經過advance)並繼續循環,同時維護stackcurrentElement變量:

const end = function () {
  stack.pop();
  currentElement = stack[stack.length - 1];
};

const parseEndTag = function (tagName) {
  end();
};

...

const endTagMatch = html.match(endTag);
if (endTagMatch) {
  const curIndex = index;
  advance(endTagMatch[0].length);
  parseEndTag(endTagMatch[1], curIndex, index);
  continue;
}
複製代碼

這時咱們能夠進行一些容錯性判斷,好比標籤對是否正確的匹配了等等,這些步驟咱們就先通通跳過了。

處理文本

若是下一個字符不是<,那直到此以前的字符串咱們將爲其生成一個文本節點,並將其加入當前節點做爲子節點:

const chars = function (text) {
  currentElement.addChild(new ASTElement(null, ASTElementType.PLAINTEXT, text));
};
複製代碼

處理開始標籤

對於開始標籤,由於咱們會將0、1或多個屬性對寫在開始標籤中,所以咱們須要分爲3部分處理:開始標籤的頭部、尾部,以及可缺省的屬性部分。因而,咱們須要建立一下3個正則表達式:

const startTagOpen = /^<([\w\-]+)/;
const startTagClose = /^\s*>/;
const attribute = /^\s*([\w\-]+)(?:(=)(?:"([^"]*)"+))?/; 複製代碼

經過圖(由regexper.com生成)來表示:

startTagOpenstartTagClose都比較簡單,這裏不贅述了(須要注意的一點是,我這裏並無考慮存在自閉合標籤的狀況,例如<input />)。對於屬性對,咱們能夠看到=以及以後的部分是可缺省的,例如disabled="disabled"disabled都是能夠的。

所以整個匹配過程也分爲3步:

  • 匹配頭部
  • 逐一匹配屬性對,並加入當前ASTElement的屬性對中
  • 匹配尾部

最後,將新建立的ASTElement壓入棧頂並標記爲當前元素:

const start = function (match) {
  if (!root) root = match;
  if (currentElement) currentElement.addChild(match);
  stack.push(match);
  currentElement = match;
};

const parseStartTag = function () {
  const start = html.match(startTagOpen);
  if (start) {
    const astElement = new ASTElement(start[1], ASTElementType.NORMAL);
    advance(start[0].length);
    let end;
    let attr;
    while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
      advance(attr[0].length);
      astElement.addAttr([attr[1], attr[3]]);
    }
    if (end) {
      advance(end[0].length);
      return astElement;
    }
  }
};

const handleStartTag = function (astElement) {
  start(astElement);
};

const startTagMatch = parseStartTag();
if (startTagMatch) {
  handleStartTag(startTagMatch);
  continue;
}
複製代碼

生成代碼

通過以上的步驟,咱們即可以解析模板字符串並獲得一顆由ASTElement組成的樹。接下來,咱們就須要遍歷這棵樹,生成用於渲染這棵樹的代碼字符串。最終在獲得代碼字符串以後,咱們將其傳入Function構造函數來生成渲染函數。

首先要作的事,即是用with(this)來包裹整段代碼:

const generateRender = function (ast) {
  const code = genElement(getRenderTree(ast));
  return 'with(this){return ' + code + '}';
};
複製代碼

這樣當咱們正確的指定this以後,在模板中咱們就能夠書寫{{ calc(a + b.c) }}而非囉嗦的{{ this.calc(this.a + this.b.c) }}了。

getRenderTree將遞歸地遍歷整棵樹:

const getRenderTree = function ({ type, tag, text, attrs, children}) {
  return {
    type,
    tag,
    text: parseText(text),
    attrs: parseAttrs(attrs),
    children: children.map(x => getRenderTree(x))
  };
};
複製代碼

在此過程當中,咱們將對原先的ASTElement樹進行進一步的處理,由於原先的書保留的都是原始的數據,而這裏咱們須要根據咱們的渲染過程對數據進行進一步的加工處理。

這裏的加工處理分爲兩個部分:

  • 處理文本節點的文本
  • 處理屬性列表

接下來咱們就經過代碼來看看咱們要進行哪些預處理。

首先對於文本節點,咱們須要從中找到包含方法/變量的部分,即被{{}}所包含的部分。這裏咱們來舉幾個例子,例如abc須要被轉換爲代碼'abc'{{ getStr(item) }}須要被轉換爲代碼getStr(item)abc{{ getStr(item) }}def須要被轉換爲代碼'abc' + getStr(item) + 'def'

也就是說,咱們須要不斷的匹配文本中包含{{}}的部分,保留其中的內容,同時將其他部分轉換爲字符串,並最終拼接在一塊兒:

const tagRE = /\{\{(.+?)\}\}/g;
const parseText = function (text) {
  if (!text) return;
  if (!tagRE.test(text)) {
    return JSON.stringify(text);
  }
  tagRE.lastIndex = 0;
  const tokens = [];
  let lastIndex = 0;
  let match;
  let index;
  let tokenValue;
  while ((match = tagRE.exec(text))) {
    index = match.index;
    if (index > lastIndex) {
      tokenValue = text.slice(lastIndex, index);
      tokens.push(JSON.stringify(tokenValue));
    }
    tokens.push(match[1].trim());
    lastIndex = index + match[0].length;
  }
  if (lastIndex < text.length) {
    tokenValue = text.slice(lastIndex)
    tokens.push(JSON.stringify(tokenValue));
  }
  return tokens.join('+');
};
複製代碼

對於屬性部分(或者說,指令),首先來講一下咱們將支持的(至關有限的)屬性:

  • class:例如class="abc def",將被處理爲'class': 'abc def'這樣的鍵值對。
  • v-class::例如v-class="{{innerClass}}",將被處理爲'v-class': innerClass這樣的鍵值對。這裏咱們偷個懶,暫時不像Vue那樣對動態的class實現對象或數組形式的綁定。
  • v-on-[eventName]:例如v-on-click="onClick",將被處理爲'v-on-click': onClick這樣的鍵值對;而v-on-click="onClick($event, 1)",將被處理爲'v-on-click': function($event){ onClick($event, 1) }這樣的鍵值對。

因爲以前實現屬性匹配所使用的正則比較簡單,暫時咱們並不能使用:class或者@click這樣的形式來進行綁定。

對於v-class的支持,和處理文本部分是類似的。

對於事件,須要判斷是否須要用function($event){}來包裹。若是字符串中僅包含字母等,例如onClick這樣的,咱們就認爲它是方法名,不須要包裹;若是不只僅包含字母,例如onClick()flag = true這樣的,咱們則包裹一下:

const parseAttrs = function (attrs) {
  const attrsStr = attrs.map((pair) => {
    const [k, v] = pair;
    if (k.indexOf('v-') === 0) {
      if (k.indexOf('v-on') === 0) {
        return `'${k}': ${parseHandler(v)}`;
      } else {
        return `'${k}': ${parseText(v)}`;
      }
    } else {
      return `'${k}': ${parseText(v)}`;
    }
  }).join(',')
  return `{${attrsStr}}`;
};
const parseHandler = function (handler) {
  console.log(handler, /^\w+$/.test(handler));
  if (/^\w+$/.test(handler)) return handler;
  return `function($event){${handler}}`;
};
複製代碼

在Vue中對於不一樣的屬性/綁定所須要進行的處理是至關複雜的,這裏咱們爲了簡化代碼用比較簡單的方式實現了至關有限的幾個屬性的處理。感興趣的童鞋能夠閱讀Vue源碼或者本身動手試試實現自定義指令。

最後,咱們遍歷被處理過的樹,拼接出咱們的代碼。這裏咱們調用了_c_v兩個方法來渲染普通節點和文本節點,關於這兩個方法的實現,咱們將在下一次實踐中介紹:

const genElement = function (el) {
  if (el.type === ASTElementType.NORMAL) {
    if (el.children.length) {
      const childrenStr = el.children.map(c => genElement(c)).join(',');
      return `_c('${el.tag}', ${el.attrs}, [${childrenStr}])`;
    }
    return `_c('${el.tag}', ${el.attrs})`;
  } else if (el.type === ASTElementType.PLAINTEXT) {
    return `_v(${el.text})`;
  }
};
複製代碼

你還能夠嘗試...

  • 在解析模板時,咱們沒有考慮註釋節點,對於匹配標籤的正則咱們實現的很簡單,由於沒法匹配相似<?xml<!DOCTYPE或是<xsl:stylesheet這樣的標籤
  • 咱們並無正確處理模板中的換行和空格
  • 在處理文本時咱們沒有考慮若是字符串中包含<那該怎麼處理
  • 咱們沒有考慮自閉和標籤而是假設全部的標籤都有開始和閉合標籤
  • 咱們不會對匹配錯誤的標籤作容錯性處理,不會考慮必須包含/沒法包含的標籤關係(例如table下應當先包含tbody,而不該當直接包含trp內部不能包含div等等)
  • 對於屬性的正則咱們也實現的很簡單,以致於沒法匹配@click:src這種形式
  • 支持的指令/屬性至關有限,注意還須要支持例如disableddisabled="disabled"這樣的縮寫格式

若是你想本身動手實踐一下,這些都將是頗有趣的功能點。

總結

這一次,咱們實踐了怎樣去解析模板字符串並由今生成一顆抽象語法樹,同時由今生成了渲染代碼。

在最後一次實踐中,咱們將把咱們已經完成的內容結合起來,最終完成前端的渲染工做。

參考:

相關文章
相關標籤/搜索