12
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

lit-htmlで使われている「仮想DOMじゃない」アルゴリズムについて解説する

Last updated at Posted at 2020-01-14

追記:もう説明されている方がいました!

はじめに

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の機能です。

実はこれが肝で、今はまだ感覚的な説明になりますが、このテンプレートが表しているのは、

変更される可能性があるのは、value1value2value3の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`;

結果:

image.png

htmlタグはこれを利用して、TemplateResultクラスのインスタンスを作成しています。

TemplateResultクラスはいくつかのプロパティを持ちます。

  1. strings ・・・ 静的な部分の文字列を含む配列が入ります
  2. values ・・・ 埋め込み(${})の部分の値の配列が入ります
  3. type ・・・ 'html'または'svg'が入ります。svgはSVGを利用する場合のsvgタグを利用した場合です
  4. processor ・・・ ここにはTemplateProcesserというインターフェースを持つオブジェクトが入ります。後述します。

最初に上げたテンプレートの場合は、下のような図になります。

image.png

少し番号が見えにくいですが、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ツリーの生成は、先ほど作ったTemplateResultstringsを組み合わせて、

<template>要素のinnerHTMLに設定しているだけです。

早速フェーズ2についても説明していきます。

フェーズ2:Templateクラスのインスタンス生成

先ほども言った通り、<template>要素内を探索していきます。

探索には、TreeWalker APIが使用されます。

探索対象のノードは、ElementCommentTextです。

探索開始ノードは、<template>要素のcontent、つまり子ノードからです。

先ほど書いたテンプレート要素を再度掲載します。

<template>
  <div style="display: flex" class$lit$="some {{lit-16848889075722862}}">
    <!--  {{lit-16848889075722862}}  -->
    hello
    <!--{{lit-16848889075722862}}-->
  </div>
</template>

(※ 煩雑になるので、一旦空白しかないテキストノードは考えないことにします)

最初に当たるのは、divElementですね。

<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が示す対象のノードに到達するまで途中のノードはスキップされます。

対象のノードに辿りついたら、最初に作成したTemplateResultprocessorプロパティに指定してあるTemplateProcessorを用いて、中身をPartクラスのインスタンスに変換していきます。

TemplateProcessorはインターフェースで、デフォルトではDefaultTemplateProcessorのインスタンスが使用されます。

なのでここからの説明は、DefaultTemplateProcessorの処理の説明になります。

これは、TemplateProcessorがもつメソッドです。

  1. handleAttributeExpressions
  2. handleTextExpression

先ほど、下記の2種類のTemplatePartが作られました。

  1. type='attribute'
  2. type='node'

それぞれ番号に対応したTemplateProcessorのメソッドが割り当てられます。

まず 1. のhandleAttributeExpressionsから説明します。

思い出すために、再度type='attribute'のオブジェクトの例を貼ります。

{
  type: 'attribute',
  index: 0,
  name: "class",
  strings: ["some ", ""],
  sanitizer: undefined
}

ここで、name属性についているプリフィックスによって、処理が変わっていきます。

lit-htmlのテンプレートシンタックスでは、

  1. @ + 属性名 ・・・ @click=${e => ...}のようにイベントを設定する属性に利用
  2. ? + 属性名 ・・・ ?checked=${true}のように真偽値によってON/OFFを切り替えたい属性に利用
  3. . + 属性名 ・・・ Elementのプロパティ(CustomElementのプロパティ)として利用
  4. 属性名のみ  ・・・ id=${'hoge'}のように文字列の属性に利用

というような書き方ができます。

このあたりのシンタックスを決定しているのが、DefaultTemplateProcessorであり、逆に言えばこのモジュールを入れ替えることで、シンタックスを変更できます。

それぞれ

  1. EventPart
  2. BooleanAttributePart
  3. AttributePartまたはAttributePartの配列
  4. 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のようなツリー構造の構築のために使われていると思っていたので、動的な部分のみを抽出するという使い方に目から鱗という感じでした。

後半、かなり説明飛ばしてますが、ソースコードはコメントも多いですし、モジュールごとの責務が理解できていると、読みやすいと思いますので、ぜひ読んでみてください。

ここまで、読んでいただきありがとうございました~。

12
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?