仮想DOMの内部の動き


PreactでVDOMがどのように機能するかを示すフローチャート

仮想DOM(VDOMあるいはVNode)は魅力的です✨ しかし複雑で、理解が難しいものでもあります😱 ReactPreact、その他同様のJSのライブラリでは、これをコアで使っています。残念ながら私は、これを詳細かつ分かりやすく説明している優れた記事や資料を見つけられませんでした。ですから、自分で書こうと思い立ったのです。

備考:これは非常に長い記事です。内容をシンプルに表すために画像を山ほど挿入しましたが、それゆえにさらに長い記事になってしまいました。
私はPreactのコードとVDOMを使いました。容量が小さくて済み、将来、簡単に見なおすことができるからです。しかし、概念のほとんどはReactにも共通していると思います。
皆さんがこれを読んだ後、仮想DOMをよく理解できるようになり、できればReactやPreactのようなライブラリに活用してくれることを願っています。

このブログではシンプルな例を取り上げ、これらが実際にはどのように機能するのかが理解できるような様々なシナリオを見ていきます。特に、以下の点に焦点を当てましょう。

  1. BabelとJSX
  2. VNodeを作る—単一の仮想DOM要素
  3. コンポーネントとサブコンポーネントの扱い
  4. 最初のレンダリングの実行、そしてDOM要素の作成
  5. 再レンダリング
  6. DOM要素の除去
  7. DOM要素の移動

アプリ

アプリは、”FilterdList“と”List“という2つのコンポーネントを含んだ、フィルタリングできるシンプルな検索アプリです。Listはアイテムのリスト(初期値は”California”と”New York”です)をレンダリングします。アプリは、検索フィールドに入力された文字に基づいてリストにフィルタリングする検索フィールドを持っています。非常に単純ですね。


アプリを図式化したもの(クリックすると拡大できるので、詳細が分かります)

実際のアプリ:http://codepen.io/rajaraodv/pen/BQxmjj

全体像

大まかな流れについて言うと、私たちはJSX(JSのhtml)を使ってコンポーネントを書き、CLIのツールであるBabelによってピュアJSに変換されます。そしてPreactの”h“(hyperscript)関数が、VDOMツリー(別名VNode)に変換します。そして最後に、Preactの仮想DOMアルゴリズムが、私たちのアプリを作るVDOMから実DOMを作ります。



全体像

VDOMのライフサイクルの詳細に入る前に、ライブラリの起点となるJSXを理解しておきましょう。

1. BabelとJSX

Preactに似たライブラリであるReactでは、HTML表記ではなく、全てがJavaScriptです。ですから、JavaScriptでHTMLを書く必要があるのです。しかし、ピュアJSでDOMを書くのは、まるで悪夢です。

私たちのアプリでは、以下のようにHTMLを書く必要があります。

備考:”h”については、後で説明します。

ここでJSXの出番です。JSXは本質的に、JavaScriptでHTMLを書くことを可能にしてくれるものなのです。さらに、波括弧{}を使うことで、内部でJSを使えるようにもなります。

JSXを使うことで、以下のように簡単にコンポーネントを書くことができます。

JSXツリーをJavaScriptに変換

JSXは使い勝手のいい言語ではあるのですが有効なJSではありません。とは言え、最終的に私たちは”実”DOMが必要になるので、JSXは実DOMを書くのに役立ちます。その他においては活用できません。

ですから、同様のJSONオブジェクト(VDOMツリー)に変換する方法が必要となってきます。そうすることで、実DOMを作成することができる入力データとして最終的に使用することができるのです。まずは、この作業を行うための関数が必要です。

その関数とは、Preactの“h”関数で、Reactの“Readct.createElement”と同様の働きをします。

“h”はhyperscriptの略で、JS(VDOM)でHTMLを作成するための最初のライブラリの1つです。

では、どのようにJSXを”h”関数の呼び出しに変換すればいいのでしょう? そこでBabelの出番です。Babelは各JSXノードを探索し、”h”関数を呼び出すように変換してくれます。

Bable JSX (React対Preact)

BabelはJSXをReact.createElementの呼び出しに変換する初期設定になっています。これは、Reactが既定値となっているためです。


左:JSX 右:JSのReactバージョン(クリックすると拡大できます)

ですが、“Babel Pragma”を追加することで、関数名を好きな名称(例えばPreactの”h”)へと簡単に変更することができます。以下がその方法です。

Option 1:
//.babelrc
{ "plugins": [
["transform-react-jsx", { "pragma": "h" }]
]
}
Option 2:
//Add the below comment as the 1st line in every JSX file
/** @jsx h */


Babel Pragmaを追加した”h”関数(クリックすると拡大できます)

主要マウントを実DOMへ

コンポーネントの”render”メソッドにあるコードが”h”関数に変換されるだけでなく、マウントも開始します。

ここでコードの実行がスタートし、全てが始まります!

//Mount to real DOM
render(, document.getElementById(‘app’));
//Converted to "h":
render(h(FilteredList), document.getElementById(‘app’));

“h”関数の出力

“h”関数はJSXの出力を取り、”VNode”と呼ばれるものを作成します(Reactでは”createElement”がReactElementを作成します)。Preactでの”VNode”(またはReactでの”Element”)は単純なJSオブジェクトで、プロパティや子要素を含んだ単一のDOMノードを表現しています。

このような感じです。

{
"nodeName": "",
"attributes": {},
"children": []
}

例えば、私たちのアプリの入力では、VNodeは以下のようになります。

{
"nodeName": "input",
"attributes": {
"type": "text",
"placeholder": "Search",
"onChange": ""
},
"children": []
}

備考:”h”関数がツリー全体を作成することはありません。与えられたノードに対して単純にJSオブジェクトを作成するだけです。しかし、”render“メソッドはツリー構造の中にDOM JSXをすでに持ち合わせているので、結果として子要素、そして孫要素を持ったVNodeになり、ツリーのように見えるのです。

リファレンスコード:
“h”:https://github.com/developit/preact/blob/master/src/h.js
VNode:https://github.com/developit/preact/blob/master/src/vnode.js
“render”:https://github.com/developit/preact/blob/master/src/render.js
“buildComponentFromVNode”:https://github.com/developit/preact/blob/master/src/vdom/diff.js#L102

では、仮想DOMがどのように機能するのか見ていきましょう。

Preactにおける仮想DOMアルゴリズムのフローチャート

以下のフローチャートでは、コンポーネント(そして子コンポーネント)がPreactでどのように作成、更新、削除されるかを示しています。また、”componentWillMount”などのライフサイクルイベントが呼び出された時のフローも示しています。

備考:理解しづらいと感じたとしても安心してください。順を追って部分ごとに説明していきます。


一度に全てを理解することは難しいと思いますので、フローチャートを部分ごとに区切って、いくつかのシナリオを交えながら順を追って説明していきましょう。

備考:特定のステップを説明する際は、そのライフサイクルの部分を”黄色”でハイライトしておきます。

シナリオ1:アプリの最初の部分を作成

1.1 – 与えられたコンポーネントに対してVNode(仮想DOM)を作成する

ハイライトされた部分は、与えられたコンポーネントに対してVNode(仮想DOM)ツリーを作成する最初のループを示しています。ただし、これはサブコンポーネントに対するVNodeの作成は含んでいないことに注意してください(これは別のループになります)。


黄色のハイライト部分は、VNodeの作成を示しています。

以下の画像は、アプリが最初に読み込みをした時に何が起こるかを示しており、ライブラリがメインのFilteredListコンポーネントに対して、最終的にVNodeとその子要素ならびに属性を作成しています。

備考:ここに至るまでに、”componentWillMount”そして”render”のライフサイクルメソッドも呼び出します(フローチャート内の緑色のブロックをご覧ください)。


(クリックすると拡大できます)

この段階では、”div“という親ノードを持ったVNodeしかありません。”div“には、”input“と”List“という子ノードが含まれています。

リファレンスコード:

ほとんどのライフサイクルイベントは、componentWillMountやrenderなどに似ています。https://github.com/developit/preact/blob/master/src/vdom/component.js

1.2 – コンポーネントではない場合は、実DOMを作成する

このステップでは、単純に親ノード(div)に対する実DOMを作成し、子ノード(”input”と”List”)に対してはプロセスを繰り返します。


ハイライト部分のループは、子コンポーネントに対する実DOMの作成を示しています。

この段階では、以下の画像が示すように、”div”しかありません。

リファレンスコード:

document.createElement:https://github.com/developit/preact/blob/master/src/dom/recycler.js

1.3 – 全ての子に対して繰り返しの処理を行う

この段階で、ループは全ての子ノードに対して繰り返しの処理が行われます。私たちのアプリでは、”input”と”List”の子ノードだけが繰り返されます。


各子ノードに対するループ

1.4 – 子ノードを処理し、親ノードに追加する

このステップでは、リーフを処理します。”input”には親ノード(”div”)があるので、divの子ノードとしてinputを追加します。その後、制御を停止させ、”List”(”div”の2つ目の子ノード)の作成に戻ります。


リーフの処理を完了させる

この時点で、私たちのアプリは以下のようになります。

備考:”input”が作成されても、”input”は子ノードを持っていないので、即座にループを開始し、”List”を作成することはありません。その代わり、最初に”input”を親ノードである”div”に追加し、そして”List”の処理に戻ります。

リファレンスコード:

appendChild:https://github.com/developit/preact/blob/master/src/vdom/diff.js

1.5 子コンポーネントを処理する

制御はステップ1.1に戻り、”List”コンポーネントに対する処理を1から開始します。しかし、”List”はコンポーネントなので、新たなVNodeのセットを得るために、”List”コンポーネントのrenderメソッドを呼び出します。フローは以下のようになります。


子”コンポーネント”に対して全てをリピートする

ループがListコンポーネントに対する処理を完了し、ListのVNodeが返却されると、以下のようになります。

リファレンスコード:

“buildComponentFromVnode”:https://github.com/developit/preact/blob/master/src/vdom/diff.js#L102

1.6 全ての子ノードに対してステップ1.1からステップ1.4までを繰り返す

全ての子ノードに対して、1.1から1.4までのステップが繰り返されます。リーフノードに達したら、ノードの親にそれを追加して、処理を繰り返します。


全ての子と親が作成、追加されるまで同じ処理を繰り返します。

以下の画像は、各ノードがどのように追加されるかを示しています(ヒント:深さ優先探索)。


VDOMアルゴリズムによって作成される実DOMツリー

1.7 処理を完了させる

これで処理は完了です。全てのコンポーネント(子コンポーネントから親コンポーネントに至るまで)に対して”componentDidMount”だけが呼び出され、停止します。

重要:全ての処理が完了すると、各コンポーネントのインスタンスに実DOMへの参照が追加されます。この参照は、残りのアップデート(作成、更新、削除)を行う際に比較要素として使われたり、同じDOMノードを再度作成するのを避けるために使われたりします。

シナリオ2:リーフノードの削除

“cal”とタイプしEnterを押してみます。すると、2つ目のListノードであるリーフノード(New York)が削除されますが、他の親ノードは保持されます。

このシナリオのフローがどのようになっているのか見ていきましょう。

2.1 前回同様、VNodeを作成する

最初のレンダリングを終えた後に実行する変更は、”更新”となります。VNodeを作成する場合、更新のサイクルは、サイクルを作成したり、VNodeを再度作成したりするのと非常によく似ています。

しかし、これはコンポーネントの更新(作成ではありません)なので、各コンポーネントとサブコンポーネントに対して”componentWillReceiveProps”、”shouldComponentUpdate”、”componentWillUpdate”を呼び出します。

さらに、DOMの要素がすでに存在する場合、更新サイクルではDOMの要素を再度作成することはありません。


コンポーネント更新のライフサイクル

リファレンスコード

removeNode:
https://github.com/developit/preact/blob/master/src/dom/index.js#L9

insertBefore:
https://github.com/developit/preact/blob/master/src/vdom/diff.js#L253

2.2 実DOM ノードの参照を使い、ノードを複製することを避ける

前述したように、各コンポーネントには、初期のローディング中に作成された実DOMツリーに対応する参照があります。下記の画像は、今の時点で、どのように参照がアプリを探索するかを示しています。


前のDOMと各コンポーネント間の参照を示しています

VNodeが作成されると、それぞれのVNodeの属性は、そのノードの実DOMの属性と比較されます。実DOMが存在する場合、ループは次のノードに移ります。


実DOMが(更新中に)”すでに存在する”場合のサイクル

リファレンスコード

innerDiffNode:https://github.com/developit/preact/blob/master/src/vdom/diff.js#L185

2.3 実DOM に余計なノードがあった場合、ノードを削除する

下記の画像は実DOMとVNodeの違いを示しています。


(クリックすると拡大できます)

違いがあるのは、実DOMの”New York”ノードがアルゴリズムによって削除されているからです。その様子を下記のワークフローで示しました。また、このアルゴリズムは、全てが終了すると、”componentDidUpdate”ライフサイクルイベントを呼び出します。


DOMノード削除のライフサイクル

シナリオ3- コンポーネント全体をアンマウントする

ユースケース:blabla(何とかかんとか)と打ちこんだとしましょう。これは”California”や”New York”と合いませんので、子コンポーネントの”List”はレンダリングしません。つまり、全てのコンポーネントをアンマウントする必要があるということです。


検索結果がゼロの場合は、Listコンポーネントは削除されません


FilteredListの”render”メソッド

コンポーネントを削除するのは、1つのノードを削除するのとほぼ同じです。ただし、コンポーネントへの参照を持っているノードを削除する場合は、フレームワークが”componentWillUnmount”を呼び出し、そして、DOMの要素全てを再帰的に削除します。実DOM からすべての要素が削除されると、参照されたコンポーネントの”componentDidUnmount”メソッドを呼び出します。

下記の画像は、実DOM”ul”の”List”コンポーネントへの参照を示しています。


sadf

下記の画像では、コンポーネントの削除とアンマウントが行われるフローの部分をハイライトしてあります。


コンポーネントの削除とアンマウント

リファレンスコード

unmountComponent::https://github.com/developit/preact/blob/master/src/vdom/component.js#L250

最後に

この記事で、仮想DOMが(少なくともPreactでは)どのように機能するか理解していただけたら幸いです。

今回用いたシナリオでは、主要な部分の説明に留めています。コードの最適化のいくつかは省略していますので、ご了承ください。

なお、何か間違いを見つけましたら、ぜひお知らせください。喜んで修正します。そして、さらに知りたいことがあれば、それもまたぜひお知らせください。

以上です! 🙏🏼 👍

🎉🎉🎉もしこの記事を気に入っていただけたのなら、2つお願いしたいことがあります。1つは、Mediumで💚(いいね)をしてください。2つめは、Twitterでシェアをお願いします🎉🎉🎉

私のアカウントはこちらです:https://twitter.com/rajaraodv