Mustacheテンプレートエンジンの内部実装とカスタム実装

はじめに

Vueではデータバインディングを実現するためにテンプレート構文が用いられています。この仕組みの核となるのがテンプレートエンジンであり、Mustacheはその代表的な実装として知られています。

データをビューに変換する方法

データをHTMLに変換する主な方法には次のようなものがあります:

DOM操作による方法:JavaScriptで直接DOMを生成して挿入する。 配列のjoinメソッドを使う方法:HTML文字列を配列で管理し、join('')で結合してinnerHTMLに挿入する。 ES6のテンプレート文字列を使う方法:文字列の扱いが容易で可読性が高い。 テンプレートエンジンを使う方法:Mustacheのようなライブラリを用いる。

配列joinの例

<div id="container"></div>
<script>
const data = { name: "Tina", age: 11, sex: "girl" };
const template = [
  "<div>",
  "  <div>" + data.name + "<b>情報</b></div>",
  "  <ul>",
  "    <li>名前:" + data.name + "</li>",
  "    <li>性別:" + data.sex + "</li>",
  "    <li>年齢:" + data.age + "</li>",
  "  </ul>",
  "</div>"
].join('');
document.getElementById('container').innerHTML = template;
</script>

Mustacheの使用例

<script src="https://cdn.bootcdn.net/ajax/libs/mustache.js/4.2.0/mustache.js"></script>
<div class="container"></div>
<script>
const template = `
<ul>
  {{#people}}
  <li>
    <div class="hd">{{name}}<b>プロフィール</b></div>
    <div class="bd">
      <p>名前:{{name}}</p>
      <p>性別:{{sex}}</p>
      <p>年齢:{{age}}</p>
    </div>
  </li>
  {{/people}}
</ul>`;

const data = {
  people: [
    { name: "Tina", age: 11, sex: "female" },
    { name: "Bob", age: 12, sex: "male" },
    { name: "Lucy", age: 13, sex: "female" }
  ]
};

document.querySelector('.container').innerHTML = Mustache.render(template, data);
</script>

Mustacheの内部構造

トークンの構造

テンプレート文字列はパースされてトークンと呼ばれる構造に変換されます。たとえば次のようなテンプレート:

<ul>
  {{#people}}
  <li>{{name}}</li>
  {{/people}}
</ul>

は次のようなトークン構造に変換されます:

[
  ['text', '<ul>'],
  ['#', 'people', [
    ['text', '<li>'],
    ['name', 'name'],
    ['text', '</li>']
  ]],
  ['text', '</ul>']
]

Mustacheのカスタム実装

Scannerクラス

export default class Scanner {
  constructor(template) {
    this.template = template;
    this.pos = 0;
    this.tail = template;
  }

  scan(tag) {
    if (this.tail.startsWith(tag)) {
      this.pos += tag.length;
      this.tail = this.template.slice(this.pos);
    }
  }

  scanUntil(tag) {
    const start = this.pos;
    while (!this.eof() && !this.tail.startsWith(tag)) {
      this.pos++;
      this.tail = this.template.slice(this.pos);
    }
    return this.template.slice(start, this.pos);
  }

  eof() {
    return this.pos >= this.template.length;
  }
}

テンプレートのトークン化

export default function tokenize(template) {
  const scanner = new Scanner(template);
  const tokens = [];
  let text, tag;

  while (!scanner.eof()) {
    text = scanner.scanUntil('{{');
    if (text) tokens.push(['text', text]);

    scanner.scan('{{');
    tag = scanner.scanUntil('}}');
    if (tag) {
      if (tag.startsWith('#')) {
        tokens.push(['#', tag.slice(1)]);
      } else if (tag.startsWith('/')) {
        tokens.push(['/', tag.slice(1)]);
      } else {
        tokens.push(['name', tag]);
      }
    }

    scanner.scan('}}');
  }

  return nestTokens(tokens);
}

ネスト構造の処理

export default function nestTokens(tokens) {
  const result = [];
  let collector = result;
  const stack = [];

  for (const token of tokens) {
    switch (token[0]) {
      case '#':
        collector.push(token);
        stack.push(token);
        collector = token[2] = [];
        break;
      case '/':
        stack.pop();
        collector = stack.length ? stack[stack.length - 1][2] : result;
        break;
      default:
        collector.push(token);
    }
  }

  return result;
}

データの探索関数

export default function lookup(data, key) {
  if (key.includes('.')) {
    const keys = key.split('.');
    let current = data;
    for (const k of keys) {
      current = current[k];
    }
    return current;
  }
  return data[key];
}

レンダリング処理

export default function render(tokens, data) {
  let output = '';
  for (const token of tokens) {
    switch (token[0]) {
      case 'text':
        output += token[1];
        break;
      case 'name':
        output += lookup(data, token[1]);
        break;
      case '#':
        output += processLoop(token, data);
        break;
    }
  }
  return output;
}

function processLoop(token, data) {
  const items = lookup(data, token[1]);
  let result = '';
  if (Array.isArray(items)) {
    for (const item of items) {
      result += render(token[2], { ...item, '.': item });
    }
  }
  return result;
}

タグ: mustache template-engine parser tokenization rendering

5月15日 03:30 投稿