昨今のJavaScriptのViewライブラリは以下の特徴を備えています。
- DOM構造を宣言的に記述できる
- 動的な値のマッピングを宣言的に記述できる
- 再描画の際、DOM構造の変異を最小限に留める
例えば、ReactであればJSXと仮想DOMによるdiff/patch処理によってこれらを実現しています。
一方、hyperHTML(およびlit-html)はECMAScriptの構文であるTagged Template Literalを使ってこれらの機能を実現するライブラリです。
import hyper from 'hyperhtml/esm';
const render = () => {
hyper(document.getElementById('container'))`
<h1>Hello</h1>
<p>It is ${new Date().toLocaleString()}</p>
`;
};
render();
setInterval(render, 1000); // 日時を表示するテキストノードのみ変異する
import { html, render } from 'lit-html/lib/lit-extended';
const doRender = () => {
render(
html`
<h1>Hello</h1>
<p>It is ${new Date().toLocaleString()}</p>
`,
document.getElementById('container')
);
};
doRender();
setInterval(doRender, 1000); // 日時を表示するテキストノードのみ変異する
「宣言的な記述」「動的な値の挿入」はまさにTemplate Literalの得意とするところです。そして、「効率的な再描画」はTagged Template Literalの仕様を活かして、比較的シンプルに実装されています。この記事では、これらのライブラリがどのようにして「効率的な再描画」を実現しているのかを詳解します。
Template Tag関数によってどこが動的な部分なのかが分かる
Tagged Template LiteralのTagにあたる部分は、ただの関数です。第一引数にリテラル部分の文字列の配列を、第二引数以降に${...}
による挿入部分の値を受け取ります。
const tag = (template, ...values) => ({ template, values });
const user = {
name: 'rikuba',
country: 'JP'
};
tag`
<div class="user">
<i class="${`user-country flag-icon flag-icon-${user.country}`}"></i>
<span class="user-name">${user.name}</span>
</div>
`;
戻り値 = {
"template": [
"\n <div class=\"user\">\n <i class=\"",
"\"></i>\n <span class=\"user-name\">",
"</span>\n </div>\n"
],
"values": [
"user-country flag-icon flag-icon-JP",
"rikuba"
]
};
第一引数template
の配列を<!--ランダム文字列-->
でjoinし、<template>要素のinnerHTMLに代入すれば、以下のようなDOMツリーが得られます。
<div class="user">
<i class="<!--ランダム文字列-->"></i>
<span class="user-name"><!--ランダム文字列--></span>
</div>
このDOMツリーを順に辿っていけば、動的な値(タグ関数の第二引数以降の値 ...values
)をどこにマッピングすればよいかという情報を集めることができます。
- 要素ノードにおいて、属性値が
<!--ランダム文字列-->
であれば、その属性値に値がマッピングされる - コメントノードにおいて、その内容が
ランダム文字列
であれば、その位置に値がマッピングされる(Nodeが動的に挿入される)
この情報から、次のような処理を行う関数を作ることができます。
-
values[0]
をルートのdiv
から1番目の子要素のclass属性値として代入する関数 -
values[1]
をルートのdiv
から2番目の子要素の最初の子要素として挿入する関数
これらの関数を順に適用することで、この構造のDOMツリーに対して効率的に値を適用することができます。ちなみに、これらの関数はhyperHTMLではUpdate、lit-htmlではPartに当たります。
この「文字列配列からDOMツリーを作り、順に辿って関数を作っていく」処理は、1つのテンプレート文字列に対しては1回実行すれば十分です。そこでTagged Template Literalのもう一つの言語仕様が使えます。
Template ObjectはJSエンジンによってキャッシュされている
タグ関数の第一引数には、同じテンプレート文字列に対しては常に同一の配列オブジェクト(正確にはTemplate Object)が渡されます。リテラル部分の文字列が同じであれば、${...}
の値は関係ありません。
// Template Objectをそのまま返すだけのタグ関数
const tag = (template, ...values) => template;
tag`私は${'rikuba'}です。` === tag`私は${'りくば'}です。`; //=> true (リテラル部分が同じ)
tag`私は${'rikuba'}です。` === tag`わたしは${'りくば'}です。`; //=> false (リテラル部分が異なる)
この仕様により、呼び出される側のタグ関数内では、同じ箇所のTagged Template Literalからの呼び出しであることを自然に検知できます。Map
などを使うことで、2回目以降は処理を省略できます。
const map = new WeakMap;
const tag = (template, ...values) => {
// 初めて見るテンプレートであれば、`parse`処理を行う
if (!map.has(template)) {
const result = parse(template);
map.set(template, result);
}
const { element, patches } = map.get(template);
patches.forEach((patch) => patch(element, values));
return element;
};
const parse = (template) => {
const patches = [];
// ...前節で説明したような処理
return {
get element() { return element.cloneNode(true); },
patches
};
};
const paragraph = (message) => tag`<p>${message}</p>`;
const link = (text, url) => tag`<a href="${url}">${text}</a>`;
const p1 = paragraph('Hello');
const a = link('Qiita', 'https://qiita.com/');
const p2 = paragraph('World'); // `parse`は実行されない
この仮想コードではtagが毎回elementを作って返していますが、elementを引数として受け取るようにすれば、その要素に対するpatch処理になります。hyperHTMLであればbind
が、lit-htmlであればrender
がそれに当たります。
実際のところ……
「Template ObjectがJSエンジンによってキャッシュされる」という仕様は、TypeScriptによってdownpileされたコードでは必ずしも満たされないため、ライブラリ作者は注意が必要です。hyperHTMLは同じテンプレート文字列から確実に同一のTemplate Objectを得るための処理を通すことで対処しています。
結び
hyperHTMLやlit-htmlは、機能やエコシステムの面ではまだこれからではありますが、その実装はとてもスマートであり、IEを無視すればtranspileも必要ないので、使いやすく理解しやすいライブラリだと思います。