將HTML字串編譯為虛擬DOM物件的基礎實現

LiuWango發表於2021-05-08

本文所有程式碼均儲存在HouyunCheng / mini-2vdom

虛擬DOM只是實現MVVM的一種方案,或者說是檢視更新的一種策略,是實現最小化更新的diff演算法的操作物件。

建立掃描器

所有編譯行為的第一步都是遍歷整個字串,於是我們建立Scanner類,專門用於掃描整個字串。

class Scanner {
  constructor(text) {
    this.text  = text;
    // 指標
    this.pos = 0;
    // 尾巴  剩餘字元
    this.tail = text;
  }

  /**
   * 路過指定內容
   *
   * @memberof Scanner
   */
  scan(tag) {
    if (this.tail.indexOf(tag) === 0) {
      // 直接跳過指定內容的長度
      this.pos += tag.length;
      // 更新tail
      this.tail = this.text.substring(this.pos);
    }
  }

  /**
   * 讓指標進行掃描,直到遇見指定內容,返回路過的文字
   *
   * @memberof Scanner
   * @return str 收集到的字串
   */
   scanUntil(stopTag) {
    // 記錄開始掃描時的初始值
    const startPos = this.pos;
    // 當尾巴的開頭不是stopTg的時候,說明還沒有掃描到stopTag
    while (!this.eos() && this.tail.indexOf(stopTag) !== 0 ) {
      // 改變尾巴為當前指標這個字元到最後的所有字元
      this.tail = this.text.substring(++this.pos);
    }

    // 返回經過的文字資料
    return this.text.substring(startPos, this.pos).trim();
  }

  /**
   * 判斷指標是否到達文字末尾(end of string)
   *
   * @memberof Scanner
   */
  eos() {
    return this.pos >= this.text.length;
  }
}

scanUntil方法用於掃描字串,並將掃描過的內容返回,用於收集為token。整個掃描會分段進行,直到字串的結尾。

轉換為沒有巢狀結構的tokens

先看程式碼,我們先例項化Scanner用於掃描整個傳入字串,同時初始化一個tokens陣列用於儲存token和一個word用於儲存sanner收集到的字串。

整個轉化行為會持續到字串的末尾,而scanscanUntil交替進行,不斷獲取<>之間的內容(即標籤和屬性)或者><之間的內容(即標籤內的內容,包括文字和子標籤)。

為了區分開始標籤和結束標籤,我們在生成的token陣列中的第一項新增#/作為開始或結束的標記,第二項為標籤名,第三項,我們放入開始標籤中收集到的屬性,而不是將屬性單獨放在一個token中,這樣做是為了簡化後邊將tokens轉化為巢狀結構的操作。

於是,我們得到了由形如[型別標記, 標籤名, 資料, 文字]組成的二維陣列。

這裡對是一個標籤否有屬性這一點使用了非常簡單粗暴的實現,即看<>中收集到的字串中是否有空格,有空格則判斷為有屬性,沒空格則判斷為沒有屬性。

在收集標籤屬性的時候,順便使用propsParser對標籤屬性進行了簡單解析。

/**
 * 將html字串轉為無巢狀結構的token,返回tokens陣列
 *
 * @param {string} html
 * @return {array} 
 */
function collectTokens(html) {
  const scanner = new Scanner(html);
  const tokens = [];

  let word = '';
  while (!scanner.eos()) {
    // 掃描文字
    const text = scanner.scanUntil('<');
    scanner.scan('<');
    tokens[tokens.length - 1] && tokens[tokens.length - 1].push(text);
    // 掃描標籤<>中的內容
    word = scanner.scanUntil('>');
    scanner.scan('>');
    // 如果沒有掃描到值,就跳過本次進行下一次掃描
    if (!word) continue;
    // 區分開始標籤 # 和結束標籤 /
    if (word.startsWith('/')) {
      tokens.push(['/', word.slice(1)]);
    } else {
      // 如果有屬性存在,則解析屬性
      const firstSpaceIdx = word.indexOf(' ');
      if (firstSpaceIdx === -1) {
        tokens.push(['#', word, {}]);
      } else {
        // 解析屬性
        const data = propsParser(word.slice(firstSpaceIdx))
        tokens.push(['#', word.slice(0, firstSpaceIdx), data]);
      }
    }
  }

  return tokens;
}

使用propsParser簡單解析標籤屬性

propsParser中,我們同樣使用Scanner進行掃描,用=進行分割,分別得到keyvalue

由於某些屬性是單屬性的,比如字串<button loading disabled class="btn">中的loading,以=分割的話會得到loading disabled class作為key,這顯然是錯誤的。於是我們同樣使用簡單粗暴的方式,用是否有空格來判斷是否有單屬性,同時將單屬性的值設定為true

由於這裡直接使用了"="進行掃描,所以當前的程式不支援單引號,同時="之間不能有空格。

同時,這裡只是對標籤屬性進行了簡單的拆分,並沒有對classstyle內的屬性進行拆分。那是之後的步驟。當然,也可以放在這裡進行。

function propsParser(propsStr) {
  propsStr = propsStr.trim();
  const scanner = new Scanner(propsStr);
  const props = {};

  while(!scanner.eos()) {
    let key = scanner.scanUntil('=');

    // 對單屬性的處理
    const spaceIdx = key.indexOf(' ');
    if (spaceIdx !== -1) {
      const keys = key.replace(/\s+/g, ' ').split(' ');

      const len = keys.length;
      for (let i = 0; i < len - 1; i++) {
        props[keys[i]] = true;
      }
      key = keys[len - 1].trim();
    }
    scanner.scan('="');

    const val = scanner.scanUntil('"');
    props[key] = val || true;
    scanner.scan('"');
  }

  return props;
}

生成有巢狀結構的tokens

在之前生成的tokens是沒有巢狀結構的,是一個簡單的二維陣列。在這裡,我們要將其轉換有巢狀結構的tokens

對於巢狀結構,通常使用來生成,遇到開始標籤(這裡為#)則壓棧,遇到結束標籤(這裡為/)則出棧。

在這裡,我們使用stack來儲存棧狀態,用collector來收集巢狀的內容,在壓棧和出棧的同時也修改collector的指向,以保證巢狀層次的準確性。

同時,我們將巢狀結構放在token的第三個元素的位置。得到形如[型別標記, 標籤名, 子節點, 資料, 文字]tokens

function nestTokens(tokens) {
  const nestedTokens = [];
  const stack = [];
  let collector = nestedTokens;

  for (let i = 0, len = tokens.length; i < len; i++) {
    const token = tokens[i];

    switch (token[0]) {
      case '#':
        // 收集當前token
        collector.push(token);
        // 壓入棧中
        stack.push(token);
        // 由於進入了新的巢狀結構,新建一個陣列儲存巢狀結構
        // 並修改collector的指向
          token.splice(2, 0, []);
          collector = token[2];
        break;
      case '/':
        // 出棧
        stack.pop();
        // 將收集器指向上一層作用域中用於存放巢狀結構的陣列
        collector = stack.length > 0
          ? stack[stack.length - 1][2]
          : nestedTokens;
        break;
      default:
        collector.push(token);
    }
  }

  return nestedTokens;
}

整合tokenizer函式

有了以上兩個函式函式之後,我們可以將其整合為一個函式,方便之後呼叫。

function tokenizer(html) {
  return nestTokens(collectTokens(html));
}

將tokens轉換為虛擬DOM

這一步相對來說就簡單很多,只需要安裝tokens的結構把相應的資料取出即可。

同時,在這裡我們對classstyle屬性進行解析,將形如{class: "item active"}class屬性轉換為

{
    class: {
        item: true,
        active: true
    }
}

的形式。

將形如{style: "border: 1px solid red; height: 300px"}轉換為

{
    style: {
        border: "border: 1px solid red",
        height: "300px"
    }
}

的形式。

同時將在data中的屬性key提取出來。由於當前的虛擬DOM還沒有上樹,所有elm屬性為undefined。對於子節點,我們使用遞迴將子節點追加到children陣列中。

於是最終我們得到形如

{
    sel: "div",
    children: [{
        sel: "p",
            data: {},
            elm: undefined,
            text: "文字",
            key: "1",
        }
    }],
    data: {class: {container: true}, id: "main"},
    elm: undefined,
    text: undefined,
    key: undefined,
}

的虛擬DOM結構。

以下是tokens2vdom的程式碼實現。

function tokens2vdom(tokens) {
  const vdom = {};

  for (let i = 0, len = tokens.length; i < len; i++) {
    const token = tokens[i];
    vdom['sel'] = token[1];
    vdom['data'] = token[3];

    // 解析類名
    if (vdom['data']['class']) {
      vdom['data']['class'] = classParser(vdom['data']['class']);
    }

    // 解析行類樣式
    if (vdom['data']['style']) {
      vdom['data']['style'] = styleParser(vdom['data']['style']);
    }

    // 新增key
    if (vdom['data']['key']) {
      vdom['key'] = vdom['data']['key'];
      delete vdom['data']['key'];
    } else  {
      vdom['key'] = undefined;
    }

    if (token[4]) {
      vdom['text'] = token[token.length - 1];
    } else {
      vdom['text'] = undefined;
    }

    vdom['elm'] = undefined;
    
    const children = token[2];
    if (children.length === 0) {
      vdom['children'] = undefined;
      continue;
    };

    vdom['children'] = [];

    for (let j = 0; j < children.length; j++) {
      vdom['children'].push(tokens2vdom([children[j]]));
    }

    if (vdom['children'].length === 0) {
      delete vdom['children'];
    }
  }

  return vdom;
}

整合toVDOM函式

到這裡我們的需求就基本實現了,我們將之前的函式整合為一個函式即可。

function toVDOM (html) {

  const tokens = tokenizer(html);
  const vdom = tokens2vdom(tokens);

  return vdom;
}

虛擬DOM的結構參照 snabbdom/snabbdom

本文完整的程式碼實現可以檢視 HouyunCheng / mini-2vdom

相關文章