はじめに
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;
}