Html.Lazy
elm/html
パッケージは、画面に何かを表示するのに使われます。これをどのように最適化するのか理解するには、まず最初にブラウザで何が起きているのかについて知っておく必要があります!
DOMとは?
HTMLファイルを作ろうとするときは、次のようにHTMLを直接書くと思います。
<div>
<p>Chair alternatives include:</p>
<ul>
<li>seiza</li>
<li>chabudai</li>
</ul>
</div>
ここで裏で生成されているDOMデータ構造は、次のようなものだと考えることができます。
この黒い箱は、大量の属性を持った重量DOMオブジェクトを表しています。これに何らかの変更を加えたときは、高価なレンダリングとページ内容のリフローが実行されることがあります。
仮想DOMとは?
Elmファイルを作っているなら、何かを表示するのにelm/html
を使っていると思います。
viewChairAlts : List String -> Html msg
viewChairAlts chairAlts =
div []
[ p [] [ text "Chair alternatives include:" ]
, ul [] (List.map viewAlt chairAlts)
]
viewAlt : String -> Html msg
viewAlt chairAlt =
li [] [ text chairAlt ]
viewChairAlts ["seiza","chabudai"]
は、裏では次のような『仮想DOM』データ構造を生成していると考えることができます。
この白い箱は軽量JavaScriptオブジェクトを表しています。属性は指定されたものだけを保持しています。この軽量オブジェクトを作っても、レンダリングやリフローが起きることは決してありません。ポイントは、レンダリングやリフローが起きうるDOMノードに比べて、軽量オブジェクトを作るのははるかに軽い処理だということです。
レンダリング
もしElmが常にこのような仮想DOMを使って動作しているとすると、我々が見ている画面のDOMへはどのように変換されているのでしょうか? Elmプログラムが開始したとき、次のようなことが起こっています。
init
を呼び出して、最初のModel
を取得するview
を呼び出して、最初の仮想DOMノードを取得する
これで仮想DOMができましたから、実際のDOMにこれを正確に複製します。
素晴らしい! でも、これが変更されたときはどうなるのでしょうか? 毎フレームDOM全体に対してこれを繰り返すのではうまく行きませんが、それでは代わりにどうしているというのでしょうか?
差分
一度DOMを初期化すると、代わりに仮想DOMが優先的に操作されるように切り替わります。Model
が変更されるときはいつも、view
が再び実行されます。ここで、可能な限り少なくDOMを操作するにはどうすればいいのか調べるために、仮想DOMの結果の『差分』をとるのです。
いま Model
には「椅子の代わり」となるものとして "seiza"
(正座)と "chabudai"
(ちゃぶ台)のふたつが含まれていますが、また新たな「椅子の代わり」を手に入れ、新しいli
ノードを追加したいと想像してみてください。そして。裏では変更を検出するために、Elmは現在の仮想ノードと次の仮想ノードの差分をとります。
Elmは3つめのli
が追加されたのがわかります。緑色で示しておきました。いまElmは、実際のDOMをどのように変更して合わせるのかを正確に知っています。単に新しいli
を挿入するだけです。
このような差分処理によって、可能な限りDOMの操作を減らすようにしています。そして、もし違いが見つからなかったなら、DOMにはまったく触れる必要はありません! つまりこの差分処理は、レンダリングとリフローを可能な限り減らすることを助けてくれるのです。
しかし、これを更に減らすことなどできるのでしょうか?
Html.Lazy
Html.Lazy
モジュールは、仮想DOMの構築すら減らすことができるのです! 中心となるアイデアは、次のようなlazy
関数です。
lazy : (a -> Html msg) -> a -> Html msg
椅子のサンプルに戻って見てみると、viewChairAlts ["seiza","chabudai"]
というような呼び出しがあります。でもその代わりに、lazy viewChairAlts ["seiza","chabudai"]
というように呼ぶこともできるのです。これは遅延バージョンで、次のようにひとつの『遅延』ノードを作成します。
このノードは、その関数と引数への参照を保持しているだけです。Elmは必要があれば引数に関数を適用して構造全体を生成することができますが、これはいつも必要だというわけではありません!
Elmの最もクールなところのひとつは、関数は「同じ入力からは同じ出力がある」ということが保証されていることです。そのため、仮想DOMを比較しているときに『遅延』ノードにいつ遭遇しても、関数は同じなのか?引数は同じなのか?と調べ、もしそれらが同じであれば、結果の仮想DOMがまったく同じであるとわかります! そのため、仮想DOMの構築をまるごと飛ばすことができるのです! もしこれらのいずれかが変更されていれば、仮想DOMノードを構築し、いつものように差分を取ります。
Note: しかし、ふたつの値が『同一』であるというのはどういうときなのでしょうか? パフォーマンスの最適化のため、裏ではJavaScriptの
===
演算子を使っています。
- 構造的等値性は、
Int
とFloat
、String
、Char
、Bool
に使われます。- 参照的等値性は、レコードやリスト、カスタム型、辞書などに使われます。
構造的等値性とは、それがどのように生成されたものであるかにかかわらず
4
は4
と同じだということです。参照的等値性とは、実際のメモリ上のポインタが同じであることを意味しています。参照的等値性を使っていれば、それがもし何十万や何百万もの要素を持つデータ構造であっても、計算量がつねにO(1)
と安価です。そのため、lazy
を使っても決してコードが遅くなったりはしないということです。すべての検査はとても安価です!
使いかた
遅延ノードを置く理想的な場所は、アプリケーションのもっとも根元の部分です。多くのアプリケーションでは、ヘッダやサイドバー、検索結果など、異なる視覚的領域を持つようにセットアップされますが、ユーザがそのひとつをいじったとしても、その他のものまで影響を受けるということは稀です。これはlazy
を呼び出すかどうかの判断にちょうどいいです!
例えば、私のTodoMVCの実装では、view
は次のように定義されています。
view : Model -> Html Msg
view model =
div
[ class "todomvc-wrapper"
, style "visibility" "hidden"
]
[ section
[ class "todoapp" ]
[ lazy viewInput model.field
, lazy2 viewEntries model.visibility model.entries
, lazy2 viewControls model.visibility model.entries
]
, infoFooter
]
テキスト入力、エントリ、コントロールは、それぞれ別々の遅延ノードの中にあることに注目してください。どのように文字を入力しても、エントリやコントロールに対しては仮想DOMノードを構築することなく、入力したい文字を自由に入力することができます。これらは変更されていないからです! つまり、最初のアドバイスとしては、アプリケーションの根本に遅延ノードを使ってみましょう。
アイテムの長いリストでも遅延ノードは有効かもしれません。TodoMVCアプリケーションでやっていることは、TODOリストへ項目を追加することだけです。数百もの項目を持つことが考えられますが、それらが変更されることはめったにありません。これは遅延性を使うちょうどいい候補となります! viewEntry entry
をlazy viewEntry entry
へと変えることで、めったに役に立たないたくさんの処理をスキップすることができます。つまり、ふたつめのアドバイスは、個々の要素がめったに変更されないような、繰り返しの構造に対して遅延ノードを使ってみてください。
まとめ
普通のユーザインターフェイスでは、DOMを操作することはほかのどんなことよりも高価となります。私のベンチマークによれば、手の込んだデータ構造でも使ってあなたは何をしてもいいのですが、でも結局はlazy
をどれだけうまく使えるかどうかだけが問題でした。
次のページでは、lazy
をさらに使いこなすためのテクニックを学んでいきましょう!