1. 程式人生 > >3天學寫mvvm框架[二]:模板解析

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

此前為了學習Vue的原始碼,我決定自己動手寫一遍簡化版的Vue。現在我將我所瞭解到的分享出來。如果你正在使用Vue但還不瞭解它的原理,或者正打算閱讀Vue的原始碼,希望這些分享能對你瞭解Vue的執行原理有所幫助。

目標

今天我們的目標是,對於以下的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程式碼:

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")] ) ] ) } 複製程式碼

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

解析html

我們的工作將分為兩步進行:

  • 首先將字串形式的模板解析後處理為我們需要的資料格式,這裡將其稱為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類:開始標籤、結束標籤和文字。開始標籤可以包含屬性對。

而在右邊的解析過程示意圖中,我們看到我們的解析是一個迴圈:每次迴圈,首先判斷下一個<字元是不是就是接下來的第一個字元,如果是,則嘗試匹配標籤,匹配標籤又分為兩種情況,先後嘗試匹配開始標籤與結束標籤;如果不是,則將當前位置直到下一個<字元之間字串都作為文字處理(為了簡化程式碼這裡忽略了文字中包含<的情況)。如此迴圈直至模板全部被解析:

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"這樣的縮寫格式

如果你想自己動手實踐一下,這些都將是很有趣的功能點。

總結

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

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

參考: