追記:もう説明されている方がいました!
はじめに
lit-htmlとは、GoogleのPolymerプロジェクトの一部として作成されたライラブリです。
宣言的なUIプログラミングを行う為の効率的なアルゴリズムとしては、仮想DOMがメジャーかもしれませんが、
実は、それ以外にも方法があります。
それは、lit-htmlが実現しているものです。
lit-html
の簡単な説明
lit-html
は、View部分を担うライブラリです。
- テンプレートの定義(
html
) - コンテナへのレンダリング(
render()
)
の2つによりDOMの差分更新を行うシンプルなライブラリです。
let value1 = "class";
let value2 = "some comment";
let value3 = "world";
// テンプレートの定義
const template = html`
<div class="red ${value1}" color="blue">
<!-- ${value2} -->
hello ${value3}
</div>
`;
// コンテナへのレンダリング
render(template, document.body);
カウンターアプリのような場合だと、
ボタンのクリックイベントのたびに、テンプレートに対して何度もrender()
が走ります。
毎回、状態は異なりますが、テンプレートは変わらないです。
こうした特性とES6から追加された「テンプレートリテラル」を生かして、
効率的な差分更新を行うことが可能になっています。
まずは、「テンプレートの定義」から説明していきます。
テンプレートの定義 - TemplateResult
の作成
lit-htmlは、まずhtml
というタグを用いて、テンプレートを定義します。
const template = html`
<div class="red ${value1}" color="blue">
<!-- ${value2} -->
hello ${value3}
</div>
`;
html
は、テンプレートリテラルのタグと呼ばれるJavaScriptの機能です。
実はこれが肝で、今はまだ感覚的な説明になりますが、このテンプレートが表しているのは、
変更される可能性があるのは、value1
・value2
・value3
の3つだけ
ということです。
lit-html
ではこの3つの動的な部分(Part
と呼ばれます)に対する参照を保持することで、更新コストを最小限に抑えています。
詳しくは後で説明していきますが、まずはテンプレートリテラルについて軽い説明を行います。
テンプレートリテラルはただの文字列補間ではありません。
タグはfunction
で、テンプレートリテラル内の
- 静的な部分
strings
- 動的な部分
values
を引数として受け取ります。
const customTag = function(strings, ...values) {
console.log(strings);
console.log(values);
};
customTag`str1${value1}str2${value2}str3${value3}str4`;
結果:
html
タグはこれを利用して、TemplateResult
クラスのインスタンスを作成しています。
TemplateResult
クラスはいくつかのプロパティを持ちます。
-
strings
・・・ 静的な部分の文字列を含む配列が入ります -
values
・・・ 埋め込み(${})の部分の値の配列が入ります -
type
・・・ 'html'または'svg'が入ります。svg
はSVGを利用する場合のsvg
タグを利用した場合です -
processor
・・・ ここにはTemplateProcesser
というインターフェースを持つオブジェクトが入ります。後述します。
最初に上げたテンプレートの場合は、下のような図になります。
少し番号が見えにくいですが、stringsは4つあります。
動的な部分は3つです。
状態更新に対してリアクティブなテンプレートを作ろうとすると、毎回html
タグによる解析が発生しますが、
テンプレートリテラルによる解析はかなりコストが低い為、問題になりません。
仮想DOMの場合、毎回仮想DOMツリーを探索しなければいけないため、比較してコストが高いです。
次の章でレンダリングについて記載していきますが、ここからはDOMツリーの探索が発生します。
ただ、キャッシュの機構やPart
という部分更新の仕組みのおかげで、DOMツリーの探索は最初の1回だけになります。
では、説明していきたいと思います。
レンダリング
レンダリングは、いくつかのフェーズを辿っていくことになります。
ただしフェーズのうちのいくつかは、最初の1回のみ実行され、2回目以降はキャッシュを利用する場合もあります。
そういった部分について、できるだけ細かく説明していきたいと思います。
フェーズ1:<template>
要素の中にDOM要素を作っていく
なぜ<template>
要素の中にDOMを形成するかというと、
<template>
要素の中はScriptやCSS、カスタムエレメントなどが評価されないからです。
いきなりrender
関数に指定したコンテナ要素にDOM要素を追加していくのではなく、まずテンプレートを作っていきます。
ここで、動的な部分へマーカーを付けていく処理が発生します。
たとえば先ほども書いたような下記のテンプレートだと、
html`<div class="red ${value1}" color="blue">
<!-- ${value2} -->
hello ${value3}
</div>`
このようにマーカーが付けられます。
<template>
<div style="display: flex" class$lit$="some {{lit-16848889075722862}}">
<!-- {{lit-16848889075722862}} -->
hello
<!--{{lit-16848889075722862}}-->
</div>
</template>
動的な値が入る属性の属性名には、末尾に**$lit$
**が付与されます。
属性の値には、**{{lit-16848889075722862}}
**という文字が入っていることがわかります。
この「16848889075722862」はランダムに入る数字で、おそらく普通のコメントと差別化するためのものです。
要素の中の動的な部分には、**<!--{{lit-16848889075722862}}-->
**というコメントが埋め込まれました。
これらはマーカーといって、後でこの<template>
内のツリーを探索していくときに目印とするものになります。
このDOMツリーの生成は、先ほど作ったTemplateResult
のstrings
を組み合わせて、
<template>
要素のinnerHTML
に設定しているだけです。
早速フェーズ2についても説明していきます。
フェーズ2:Template
クラスのインスタンス生成
先ほども言った通り、<template>
要素内を探索していきます。
探索には、TreeWalker APIが使用されます。
探索対象のノードは、Element
とComment
とText
です。
探索開始ノードは、<template>
要素のcontent
、つまり子ノードからです。
先ほど書いたテンプレート要素を再度掲載します。
<template>
<div style="display: flex" class$lit$="some {{lit-16848889075722862}}">
<!-- {{lit-16848889075722862}} -->
hello
<!--{{lit-16848889075722862}}-->
</div>
</template>
(※ 煩雑になるので、一旦空白しかないテキストノードは考えないことにします)
最初に当たるのは、div
Elementですね。
<div style="display: flex" class$lit$="some {{lit-16848889075722862}}"></div>
Element
に当たった場合は、まずその要素の属性の一覧を取得します。
この場合は、
- style
- class$lit$
の2つが存在します。
ここで、動的であることをマークしたのはclass$lit$
の方なので、この部分についてTemplatePart
が作成されます。
中身はこんな感じです。
{
type: 'attribute',
index: 0,
name: "class",
strings: ["some ", ""],
sanitizer: undefined
}
**index
**には、この動的な部分=TemplatePart
が現れるDOM要素の位置が記録されます。
**name
**は、属性名です。
**strings
**には、属性の値を{{lit-16848889075722862}}
のマーカーで区切った(split
)した配列が入ります。
sanitizer
は、あとで値のサニタイズに使われますが、今はあまり気にしなくて大丈夫です。
このTemplatePart
は、最終的に作られるTemplate
クラスのparts
プロパティに追加されます。
さて、次の要素を見ていきます。
<template>
<div style="display: flex" class$lit$="some {{lit-16848889075722862}}">
<!-- {{lit-16848889075722862}} -->
hello
<!--{{lit-16848889075722862}}-->
</div>
</template>
次は、コメントノードです。
<!-- {{lit-16848889075722862}} -->
この<!--
と{
の間に挟まるスペースがあるため、ノードの中身が{{lit-16848889075722862}}
と完全一致せず、
コメント内の埋め込みであると判定されます。
ここでできるTemplatePart
は、
{
type: 'node',
index: -1
}
です。のちのフェーズでindex=-1
は無視されることになります。
では、次のノードに行きます。
<template>
<div style="display: flex" class$lit$="some {{lit-16848889075722862}}">
<!-- {{lit-16848889075722862}} -->
hello
<!--{{lit-16848889075722862}}-->
</div>
</template>
hello
というテキストノードは、マーカー(<!--{{lit-16848889075722862}}-->
や{{lit-16848889075722862}}
)を含まない為、カウントはされますが、TemplatePart
は作成されません。
そして次のコメントノードがTemplatePart
の作成対象となります。
<!--{{lit-16848889075722862}}-->
このコメントノードの「中身」は、{{lit-16848889075722862}}
になり、マーカーと完全一致する為、動的な更新が必要な部位であると認識されます。
作成されるTemplatePart
は下記のようになります。
{
type: 'node',
index: 3
}
空白のノードを除いたので、TreeWalker
がこのTemplatePart
が含まれるノードに出会うのは4番目になります。
なので、index
は3です。
ここまでで、探索は終了になります。
これは、
× 残りのノードが無いから
ではなく
〇 ここまでで、動的な部分(values.length
)に対するTemplatePart
が作成されたから
です。
なので、余計なDOM探索は行われません。
こうして、集まったTemplatePart
は、Template
クラスのインスタンスのparts
プロパティに設定されます。
こうして、Template
クラスのインスタンスができたわけですが、
2回目以降に、同じことをしなくていいように、キャッシュが作成されます。
文字列をキーにもつ、Map
オブジェクトにキャッシュされます。
キーに設定される文字列は、静的な文字列(strings
)を{{lit-16848889075722862}}
でつなぎ合わせたものです。
これが意味するところは、
テンプレートが持つ静的な部分と動的な部分の構造が一緒なら、使いまわすということです。
さらに、TemplateResult.strings
という配列に対するWeakMap
によるキャッシュも作成されます。
これは文字が完全一致する以前に、参照そのものが一緒なら、Template
のインスタンスを使いまわすということです。
このようにして、まずTemplate
のインスタンスに対するキャッシュが行われます。
フェーズ3:Template
をもとに、TemplateInstance
が作成される
フェーズ3では、再度<template>
要素を探索していくことになります。
先ほど作ったparts
をループし、index
が示す対象のノードに到達するまで途中のノードはスキップされます。
対象のノードに辿りついたら、最初に作成したTemplateResult
のprocessor
プロパティに指定してあるTemplateProcessor
を用いて、中身をPart
クラスのインスタンスに変換していきます。
TemplateProcessor
はインターフェースで、デフォルトではDefaultTemplateProcessor
のインスタンスが使用されます。
なのでここからの説明は、DefaultTemplateProcessor
の処理の説明になります。
これは、TemplateProcessor
がもつメソッドです。
handleAttributeExpressions
handleTextExpression
先ほど、下記の2種類のTemplatePart
が作られました。
type='attribute'
type='node'
それぞれ番号に対応したTemplateProcessor
のメソッドが割り当てられます。
まず 1. のhandleAttributeExpressions
から説明します。
思い出すために、再度type='attribute'
のオブジェクトの例を貼ります。
{
type: 'attribute',
index: 0,
name: "class",
strings: ["some ", ""],
sanitizer: undefined
}
ここで、name
属性についているプリフィックスによって、処理が変わっていきます。
lit-html
のテンプレートシンタックスでは、
-
@
+ 属性名 ・・・@click=${e => ...}
のようにイベントを設定する属性に利用 -
?
+ 属性名 ・・・?checked=${true}
のように真偽値によってON/OFFを切り替えたい属性に利用 -
.
+ 属性名 ・・・Element
のプロパティ(CustomElement
のプロパティ)として利用 - 属性名のみ ・・・
id=${'hoge'}
のように文字列の属性に利用
というような書き方ができます。
このあたりのシンタックスを決定しているのが、DefaultTemplateProcessor
であり、逆に言えばこのモジュールを入れ替えることで、シンタックスを変更できます。
それぞれ
EventPart
BooleanAttributePart
-
AttributePart
またはAttributePart
の配列 PropertyPart
が作成され、これらはすべてPart
というインターフェースを実装しています。
Part
インターフェースは下記のようになります。
interface Part {
readonly value: unknown;
setValue(value: unknown): void;
commit(): void;
}
setValue()
は、基本的にはvalue
プロパティに値をセットしているだけです。
実際に、差分更新の処理を行うのは、commit()
になります。
詳しくは省きますが、このPart
は差分更新を行う対象のDOMノードに対する参照を保持しています。
そしてTemplateInstance
のインスタンスの__parts
プロパティに保持されます。
また、TemplateInstance
のインスタンス自身も、render()
メソッドの第2引数で指定したDOM要素に対するWeakMap
に保持されます。※
(※ ここ実は微妙に違って、WeakMap
に保持されるのは、NodePart
でその中で、TemplateInstance
の作成やフェーズ1、2というのが、連鎖的に実行されていきます。また、基本的にはNodePart
が入れ子になる形で、子要素に対する再帰的なツリーの構築も行われていき、それぞれのNodePart
が差分更新のロジックを持っています。直感的に理解するために、このような書き方になりました。)
このフェーズ3によって、DOMツリーへの要素の挿入が行われます。
おわり
こうして次回以降のrender()
では、フェーズ1・2・3によって生成されたPart
と参照しているDOMノードに対する差分更新のみが行われていきます。
動的な部分に対するPart
のみが作成され、キャッシュされるので、メモリ的にも処理速度的にもコストが低いです。
テンプレートリテラルによる解析により、動的な部分のみが抽出でき、DOMノードに対する参照が保持されることで、仮想DOMのようなツリー全体を比較するコストもかかりません。
てっきり、テンプレートリテラルはあくまで、仮想DOMのようなツリー構造の構築のために使われていると思っていたので、動的な部分のみを抽出するという使い方に目から鱗という感じでした。
後半、かなり説明飛ばしてますが、ソースコードはコメントも多いですし、モジュールごとの責務が理解できていると、読みやすいと思いますので、ぜひ読んでみてください。
ここまで、読んでいただきありがとうございました~。