前端資料渲染及mustache模板引擎的簡單實現

LiuWango發表於2021-04-30

早期資料渲染的幾種方式

在模板引擎沒有誕生之前,為了用JS把資料渲染到頁面上,誕生了一系列資料渲染的方式。

最最基礎的,莫過於直接使用DOM介面建立所有節點。

<div id="root"></div>
<script>
  var root = document.getElementById('root');
  
  var title = document.createElement('h1');
  var titleText = document.createTextNode('Hello World!');
  
  title.appendChild(titleText);
  root.appendChild(title);
</script>

這種方式需要手動建立所有節點,再依次新增到父元素中,手續繁瑣,基本不具有實際意義。

當然,也可以採用innerHTML的方式新增,上樹:

var root = document.getElementById('root');
root.innerHTML = '<h1>Hello World!</h1>';

對於資料簡單,巢狀層級較少的html程式碼塊來說,這種方式無疑方便了許多,但是,若程式碼巢狀層級太多,會對程式碼可讀性造成極大影響,因為''或者""都是不能換行的(ES6才有反引號可以換行),在一行程式碼裡進行標籤多層巢狀(想想現在看被轉譯壓縮後的程式碼),這對編寫和維護都會造成極大的困難。

直到有一個天才般的想法橫空出世。

var root = document.getElementById('root');

var person = {
  name: 'Wango',
  age: 24,
  gender: '男'
}

root.innerHTML = [
  '<ul>',
  '  <li>姓名: ' + person.name + '</li>',
  '  <li>年齡: ' + person.age + '</li>',
  '  <li>性別: ' + person.gender + '</li>',
  '</ul>',
].join('');

這個方法將不可換行的多行字串轉換為陣列的多個元素,再利用陣列的join方法拼接字串。使得程式碼的可讀性大大提升。

當然,在ES6的模板字串出來之後,這種hack技巧也失去了用武之地。

root.innerHTML = `
  <ul>
    <li>姓名: ${person.name}</li>
    <li>年齡: ${person.age}</li>
    <li>性別: ${person.gender}</li>
  </ul>
`;

但是同樣的,資料通常不是簡單的物件,當資料更加複雜,陣列的巢狀層次更深的時候,即便是模板字串也是力不從心。

於是,mustache庫誕生了!

實現mustache

接觸過JavaJSP或者PythonDTL(The Django template language)等模板引擎的同學對{{}}語法一定不會陌生,模板引擎從後端引入前端後得到了更廣泛的支援,而如今,已經快成為前端框架的標配了。

更多關於mustache的資訊可以檢視GitHub倉庫:
janl/mustache.js

這個mustache.js庫暴露的物件只有一個render方法,接收模板和資料。

<script type="text/template" id="tplt">
  <ul>
    <li>{{name}}</li>
    <li>{{age}}</li>
    <li>{{gender}}</li>
  </ul>
</script>
<script>
 var person = {
   name: 'Wango',
   age: 24,
   gender: '男'
 }
 
 var root = document.getElementById('root');
 
 root.innerHTML = Mustache.render(
   document.getElementById('tplt').innerHTML,
   person
 )
</script>

樸素的實現

沒有編譯思想的同學可能想到的第一種實現方式就是使用正規表示式配合replace方法來進行替換,而對於上面一個例子使用正則確實也是可以實現的。

var root = document.getElementById('root');

function render(tplt, data) {
  // 捕獲變數並使用資料進行替換
  return tplt.replace(/{{\s*(\w+)\s*}}/g, function(match, $1) {
    return data[$1];
  });
}

root.innerHTML = render(
  document.getElementById('tplt').innerHTML,
  person
)

對於簡單的,單層無巢狀的結構來說,確實可以使用正則進行替換,但mustache還可以支援陣列的遍歷,多重巢狀遍歷,物件屬性的打點呼叫等,對於這些正則就捉襟見肘了。

編譯思想的應用

在資料注入之前,我們需要在模板字串編譯為tokens陣列,再將資料注入,將tokens拼接為最終的字串,然後返回資料,這樣做的好處是可以更方便地處理遍歷和巢狀的問題。

於是,我們的模板引擎的render方法如下:

render(tplt, data) {
  // 轉換為tokens
  const tokens = tokenizer(tplt);
  // 注入資料,讓tokens轉換為DOM字串
  const html = tokens2dom(tokens, data);
  // 返回資料
  return html;
}

那麼,tokenizertokens2dom又該如何實現呢?

首先來看tokenizer

這個函式的作用是將模板字串轉換為tokens陣列,那麼什麼是token?簡單來說,token指的是由型別、資料、巢狀結構等組成的陣列,所有tokens就是一個二維陣列,在本例中表現為

var tplt = `
    <div>
      <ol>
        {{#students}}
          <li>
            學生{{name}}的愛好是
            <ol>
              {{#hobbies}}
                <li>{{.}}</li>
              {{/hobbies}}
            </ol>
          </li>
        {{/students}}
      </ol>
    </div>
`;

轉換為:

[
    ["text", "<div><ol>"],
    ["#", "students", [
        ["text", "<li>學生"],
        ["name", "name"],
        ["text", "的愛好是<ol>"],
        ["#", "hobbies", [
            ["text", "<li>"],
            ["name", "."],
            ["text", "</li>"]
        ]],
        ["text", "</ol></li>"]
    ]],
    ["text", "</ol></div>"]
]

由上例可以看出,token有代表文字的text型別,代表迴圈的#型別,代表變數的name型別,此為token的第一個元素,token的第二個元素為這個型別的值,如果有第三個元素,那麼第三個元素為巢狀的結構,當然,在janl/mustache.js中還有更多的型別,這裡只是簡單的列舉幾項。

Scanner物件

要從模板字串轉換為tokens,第一步我們應該想得到的應該是遍歷真個模板字串,找到其中的本文,變數和巢狀結構等型別,於是可以建立一個Scanner物件,專門負責遍歷模板字串和返回找到的文字。

同時,token中是不包含{{}}的,所有還需要定義一個方法跳過這兩個字串。

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

  /**
   * 路過指定內容
   *
   * @memberof Scanner
   */
  scan(tag) {
    if (this.tail.indexOf(tag) === 0) {
      // 直接跳過指定內容的長度
      this.pos += tag.length;
      // 更新tail
      this.tail = this.tplt.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.tplt.substring(++this.pos);
    }
    // 返回經過的文字資料
    return this.tplt.substring(startPos, this.pos).trim();
  }

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

掃描到了相關內容,我們就可以將資料收集起來,並轉換為不含巢狀結構的token,於是定義一個collectTokens函式:

function collectTokens(scanner) {
  const tokens = [];
  let word = '';
  // 當scanner沒有到頭的就持續將獲取的token加入陣列中
  while (!scanner.eos()) {
    // 收集文字
    word = scanner.scanUntil('{{');
    word && tokens.push(['text', word]);
    scanner.scan('{{');

    // 收集變數
    word = scanner.scanUntil('}}');

    // 對不同型別結構進行分類標識
    switch (word[0]) {
      case '#':
        tokens.push(['#', word.substring(1)]);
        break;
      case '/':
        tokens.push(['/', word.substring(1)]);
        break;
      default:
        word && tokens.push(['name', word]);
    }

    scanner.scan('}}');
  }

  return tokens;
}

這時,我們得到了一個這樣的陣列:

[
    ["text", "<div>↵      <ol>"],
    ["#", "students"],
    ["text", "<li>↵            學生"],
    ["name", "name"],
    ["text", "的愛好是↵            <ol>"],
    ["#", "hobbies"],
    ["text", "<li>"],
    ["name", "."],
    ["text", "</li>"],
    ["/", "hobbies"],
    ["text", "</ol>↵          </li>"],
    ["/", "students"],
    ["text", "</ol>↵    </div>"]
]

可以看到,除了巢狀結構外,tokens的基本特徵已經具備了。那麼巢狀結構該如何加入呢?我們可以分析出:

["#", "students"],
["#", "hobbies"],
["/", "hobbies"],
["/", "students"],

可知#是巢狀結構的開始,/是巢狀結構的結束,同時,先出現的students反而後結束,而後出現的hobbies反而先結束。對資料結構有一些研究的同學應該立即就能想到一種資料結構: ---- 一種先進後出的結構。而在JS中,可以用陣列pushpop方法模擬棧結構。只要遇見#我們就壓棧,記錄當前是哪個層級,遇見/就出棧,退出當前層級,直到退到最外層。

於是,我們有了一個新的函式nestTokens:

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的指向
        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(tplt) {
  const scanner = new Scanner(tplt.trim());

  // 收集tokens,並將迴圈內容巢狀到tokens中,並返回
  return nestTokens(collectTokens(scanner));
}

到這裡,模板引擎已經完成了一大半,剩下的就是將資料注入和返回最終的字串了。也就是tokens2dom函式。
不過在此之前,我們還要再解決一個問題,還記得我們在使用正則替換時是怎麼注入資料的嗎?

tplt.replace(/{{\s*(\w+)\s*}}/g, function(match, $1) {
  return data[$1];
});

回顧一下,我們是通過data[$1]來獲取物件資料的,可是,如果我的模板裡寫的是類似{{a.b.c}}這樣的打點呼叫該怎麼辦?JS可不支援obj[a.b.c]這樣的寫法,而janl/mustache.js中是支援變數打點呼叫的。所以,在資料注入前,我們還需要一個函式來解決這個問題。

於是:

/**
 * 在物件obj中用連續的打點字串尋找到物件值
 * 
 * @example lookup({a: {b: {c: 100}}}, 'a.b.c')
 *
 * @param {object} obj
 * @param {string} key
 * @return any
 */
function lookup(obj, key) {
  const keys = key.split('.');

  // 設定臨時變數,一層一層查詢
  let val = obj;
  for (const k of keys) {
    if(val === undefined) {
      console.warn(`Can't read ${k} of undefined`);
      return '';
    };
    val = val[k];
  }
  return val;
}

解決了打點呼叫的問題,就可以開始資料注入了,我們要對不同型別的資料進行不同的操作,文字就直接拼接,變數就查詢資料,迴圈的就遍歷,巢狀的就遞迴。

於是:

function tokens2dom(tokens, data) {

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

    // 按型別拼接字串
    switch (token[0]) {
      case 'name':
        if (token[1] === '.') {
          html += data;
        } else {
          html += lookup(data, token[1]);
        }
        break;
      case '#':
        // 遞迴解決陣列巢狀的情況
        for (const item of data[token[1]]) {
          html += tokens2dom(token[2], item);
        }
        break;
      default:
        html += token[1];
    }
  }

  return html;
}

到這裡我們的模板引擎就全部結束啦,再向全域性暴露一個物件方便呼叫:

// 暴露全域性變數
window.TemplateEngine = {
  render(tplt, data) {
    // 轉換為tokens
    const tokens = tokenizer(tplt);
    // 注入資料,讓tokens轉換為DOM字串
    const html = tokens2dom(tokens, data);

    return html;
  }
}

這裡只是對mustache的一個簡單實現,還有類似於條件渲染等功能沒有實現,同學們有興趣的可以看看原始碼
janl/mustache.js

當然可以看本文實現的程式碼mini-tplt

相關文章