Elm Advent Calendar 2016 の10日目の記事です。
今回は、Html.Lazy モジュールについて。
Html.Lazy とは
Virtual DOM のパフォーマンスがいくら良いとは言え、無駄な Virtual DOM を際限なく作り続けるとコストになります。そこで、Html.Lazy モジュールはこのコストを抑え、パフォーマンスを最適化する方法を提供します。
仕組み
一言で言うと「モデルが変わらなければビューも変わらない」という性質を利用します。Elm の関数はすべて純粋であることが保証されているため、引数が変わらなければ返す結果はいつも同じです。そこで、Elm ランタイムはHtml
を生成する関数の引数が前回と同じであるかを見て、同じであればロジックを丸ごとスキップして前回の値を使いまわします。
使い方
使い方は簡単です。ビューを生成する関数の頭にlazy
とつけるだけです。
-- 最適化なし
view model =
viewSomething model.something
-- 最適化あり
import Html.Lazy exposing (lazy)
view model =
lazy viewSomething model.something
TodoMVC でも使われています。
lazy
は特別なキーワードではなく、関数として定義されています。引数の数に応じてlazy
、lazy2
、lazy3
の3つが用意されています。
lazy : (a -> Html msg) -> a -> Html msg
lazy2 : (a -> b -> Html msg) -> a -> b -> Html msg
lazy3 : (a -> b -> c -> Html msg) -> a -> b -> c -> Html msg
Html.Lazy を使う上での注意点
注意は、引数の同一性を**「参照の一致」**によって判定していることです。lazy
関数のドキュメントの一部を引用すると次のように書いてあります(強調筆者)。
During diffing, we can check to see if model is referentially equal to the previous value used, and if so, we just stop.
つまり、view
関数内で動的に生成した値は参照が前回と一致しないため、引数に含めてはいけません。次の例は動的に新しいレコードを生成しているため、lazy
は動作しません。
-- 無効
view model =
lazy viewSomething { value = model.something }
ただし、以下は値が同じであれば参照が一致すると考えて大丈夫です。
- 数値
- 文字列
- 型コンストラクタ(
True
,False
,Just
,Nothing
,[]
など。Just a
は別参照になる。)
また、ビューを生成する関数も動的に生成されたもの(あるいはラムダ式)であってはいけません。
-- 無効
view foo bar baz oneMoreThing =
let
f a b c =
viewSomething a b c oneMoreThing
in
lazy3 f foo bar baz
こうすると、oneMoreThing
が変更されたことをlazy3
が検知できなくなってしまいますので、こうした抜け道は無効になっています。
参照を一致させるための工夫
上のような注意事項があるので制限がきつい気がしますが、工夫することで何とかなることも多いです。以下の例では、関数を適切にリファクタリングすることでlazy
を有効にする例です。
例1: 定数をlet .. in
中に書かずにトップレベルに宣言する
定数を毎回生成するのは勿体ないです。
-- 無効
view model =
let
value = Just 1
in
lazy2 viewSomething model.something value
定数をトップレベルに持っていくことで、参照は毎回同じになります。
-- 有効
view model =
lazy2 viewSomething model.something value
value =
Just 1
例2: Msg
を生成して渡さない
動的に生成した Msg を渡すと参照が新しくなってしまいます。
-- 無効
view person =
lazy viewSomething (Click person.id)
viewSomething msg =
div [] [ onClick msg ]
次のように関数内で組み立てるか、
-- 有効
view person =
lazy2 viewSomething Click person.id
viewSomething toMsg id =
div [] [ onClick (toMsg id) ]
Html.map
を使って、ビューを生成した後で Msg を変換します。
-- 有効
view person =
viewSomething
|> Html.map (\_ -> Click person.id)
viewSomething =
div [] [ onClick () ]
後者のコードからはそもそもlazy
が消えていますが、もちろん定数であるビューもトップレベルに置けばlazy
と同じように前回の値が使いまわされるため、意味的には同じです。
lazy が有効かどうかを確認する
lazy を正しく使えているかを確認する効率的なやり方があればいいのですが、今のところそのような方法はないと思います。愚直ですが手っ取り早いのは、「ビュー関数内にDebug.log
を仕掛けて、意図せず関数が呼ばれていないかをチェックする」です。別の方法としては、「ブラウザのプロファイル機能を使って、(以下同様)」です。後者の方がコードを弄らなくていいのと、潰すべき残りの関数が明らかになるというメリットがありそうです。
Reactとの比較
Reactにも同様の仕組みがあり、同様の注意点があるようです。
shouldComponentUpdate() による手動のチューニング
ReactのコンポーネントにはshouldComponentUpdate()
という関数が用意されています。デフォルトはtrue
を返しますが、この関数をオーバーライドして適切にfalse
を返すことで、レンダリングをスキップします。
まさにHtml.Lazy
と同様に「前回の状態と同じであればレンダリングしない」という戦略のためにあるのですが、その判定ロジックはユーザーにゆだねられています。
PureComponent を使う
その後、Reactに追加されたPureComponent
という仕組みを使うと、shouldComponentUpdate()
を自動化することが出来ます。PureComponent
は、外部から与えられる引数props
と自身の状態state
の変化を見ることで、レンダリングが必要かどうかを自動的に判定します。
判定ロジックはドキュメントを見ると次のように書いてあります(強調筆者)。
React.PureComponent's shouldComponentUpdate() only shallowly compares the objects.
どの程度まで「浅く」なのか微妙に分からなかったので調べると、PureComponent
の前身であるshallowCompare
と同じ挙動をすると書いてありました。以下に引用します(強調筆者)。
shallowCompare performs a shallow equality check on the current props and nextProps objects as well as the current state and nextState objects.
It does this by iterating on the keys of the objects being compared and returning true when the values of a key in each object are not strictly equal.
props
とstate
の各キーに対して"strictly equal"とあることから、コンポーネントに与えた引数に対する判定ロジックはElmのHtml.Lazy
と同じと考えて良さそうです。
使用上の注意
PureComponent
のドキュメントによると、使用者は以下のことに注意するべしと書いてあります。
- 更新前後のオブジェクトを比較できるように、Immutableなデータ構造を検討する
-
PureComponent
の子供はすべて"pure"である必要がある
これらは両方ともElmでは解決済みの問題と言えるでしょう。ElmではすべてのデータがImmutableであり、全ての関数がビューを含めて"pure"です。ビューに状態を持てないので、例えば"時計コンポーネント"を置くためには現在時刻をモデルから引くしか方法がなく、ある意味で面倒です。しかし、この性質のおかげで「誤ってPureComponent
の下に置かれた"時計コンポーネント"が動かない」などというトラブルとは無縁でいられます。これは一種のトレードオフと言えるでしょう。
実際にHtml.Lazyを使ってみた
現在会社で取り組んでいるアプリ(OSS)でパフォーマンスが気になってきたので、実際に使ってみました。
参照なんてそうそう一致しないだろうと高をくくっていたのですが、やってみると意外と使える部分が多かったです。というのは、あるビューでlazy
が使えなくても(一番大きなModel
を渡している関数が結構ある)末端のビューに近づくほど引数の粒度が小さくなっていくため、途中のどこかで参照が一致する事が多くありました。
効果は時間が足りずまだきちんと測れてませんが、プロファイルで見ると不要な処理がかなり減っていました。以下はあるオブジェクトを画面上でドラッグした時の様子です。
体感としても心なしか軽くなった気がします。
まとめ
Html.Lazy は注意点さえ押さえれば、簡単に信頼性のあるパフォーマンス最適化が可能です。ぜひ使いこなしましょう。