Edited at
Elm 2Day 9

[Elm] 予期しない CSS transition の発火をしないように Virtual DOM にお願いする方法


概要

本記事では、Elm プログラムにおいて CSS の transition プロパティで定義された遷移アニメーションが予期しないタイミングで引き起こされる例を挙げ、その解決法を示します。

また、ただのデモアプリのくせに無駄にCSSをまじめに書いたものを見せつけることで、「CSSちゃんと書けるとこんなにびよんびよんできるんだぜ〜」と自慢することが目的です。

本記事のコードは Elm 0.18 を対象にしていますが、Elm 0.19 でも対処法は同じです。

また、本記事で紹介しているコードはgithubで公開しています。


まずはこれを見てください

FireShot Capture 13 - elm-transition-exa_ - http___localhost_8080_elm-transition-example_index2.html.png

わりと現実的にありえそうなデモをさくっと作っておきました。

タブが2つあって、切り替えると内容が変わります。

Tab Bには "Edit" と書かれたリンクがあり、これをクリックするとポップアップが立ち上がって日付を変更できるイメージです。

(実際にはポップアップはダミーなので日付を変更することはできません)

なにか気づきましたか?

Tab Bに切り替えた時に、一瞬ポップアップが表示されたり、一瞬暗転したりするのではないでしょうか?

(ブラウザによって挙動が若干異なるようです)

目障りですね〜

せっかく無駄に完成度が高くて自慢したいのに、なんかうざいです。

実はこの現象は elm-lang/navigation/ でルーティングによって複数のビューを切り替えるときにも発生する可能性があり、CSS transition を多用しているとわりと遭遇します。


まずは実装を見てみる

まず、タブのBody部分(nameとかが表示されてるところ)は下記のコードで実現されています。

, div

[ class "tab_body"
, attribute "role" "tabpanel"
]
[ case model.tab of
TabA ->
renderTabBodyA model

TabB ->
renderTabBodyB model
]

model.tab に現在選択されているタブの状態が入っていて、その状態に応じて renderTabBodyArenderTabBodyB の内容を出し分けしています。

renderTabBodyA は下記のように定義されています。

renderTabBodyA : Model -> Html Msg

renderTabBodyA _ =
div
[]
[ div
[ class "field"
]
[ div
[ class "field_title"
]
[ Html.text "name"
]
, div
[ class "field_body"
]
[ div
[ class "field_text"
]
[ Html.text "Foo"
]
]
]
, div
[ class "field"
]
[ div
[ class "field_title"
]
[ Html.text "age"
]
...

とくに特殊なところはありません。

renderTabBodyB は下記のように定義されています。

renderTabBodyB : Model -> Html Msg

renderTabBodyB model =
div
[]
[ div
[ class "field"
]
[ div
[ class "field_title"
]
[ Html.text "Some date"
]
, div
[ class "field_body"
]
[ div
[ class "field_text"
]
[ Html.text "2017-11-28"
]
, div
[ class "field_edit"
, Html.onClick ShowDatePicker
]
[ Html.text "Edit"
]
]
]
, div
[ class "popup"
, ariaHidden <| not model.showDatePicker
]
[ div
[ class "popup_body"
]
...

renderTabBodyA と似た構造をしていますが、ポップアップウィンドウ用のマークアップも含まれています。

class "popup"div に包まれた部分がポップアップウィンドウです。

model.showDatePicker の状態によってポップアップの表示・非表示を管理しています。

HTMLに描画されると、以下のように aria-hidden 属性の値が切り替わります。

<div class="popup" aria-hidden="true">

...

<div class="popup" aria-hidden="false">

...

CSS側でこの aria-hidden の値が変わるときに transition が発火するように設定しており、なめらかなアニメーションでポップアップを出したり隠したりしています。

通常、transition はタグを新しく生成した時には起きないはずなのですが、ここで定義した transition がタブの切替時になぜか発火してしまっているようです。


なんでポップアップが開いちゃうの?

このうざい挙動は、実は Virtual DOM が気をきかせて View の書き換えをするときにできるだけ変更を少なくしようとすることに起因します。

ブラウザの Web Developer Tool を開いて、タブを切り替えた時の DOM の変化を見てみましょう。

FireFox や Google Chrome なら、実際に変更された部分がハイライトされて表示されているはずです。

DOMの変化を観察してみると、以下のことが分かります。


  1. "Tab B" をクリックする

  2. Virtual DOM が差分を計算する

  3. age の内容を表示している class="field" のタグを流用して class="popup" のタグを作成しようとする

  4. その結果、class="field"class="popup" に書き換え、aria-hidden 属性を あらたに追加 する


  5. class="popup"aria-hidden の値が変化したので、CSSの transition が発生してしまう

まじかよ、お節介だな。


対策

まぁね、ついお節介だなんて言っちゃったけど、いつも Virtual DOM さんにはお世話になってるしね、ちょっと言い過ぎたなとは思っています。

Elm コード見ればそりゃあ確かに「まったく関係ない別の独立したViewだな」って分かりますけど、

Virtual DOM さんにしてみれば、そんな文脈は一切渡されずに、変更前後の View だけ渡されて「変更箇所見つけろよ」って言われるんですもん。

そりゃあ既存のタグの流用くらいしちゃいますよ。

これは、Virtual DOM さんの上司である我々の責任でもあるわけです。

ちゃんと Virtual DOM さんに、「ここはまったく別の View だから流用しないでタグを作りなおしといてね〜」って伝えないといけません。

それを可能にするのが、以下に定義した unique 関数です。

import Html.Keyed as Keyed

{-| When `identifier` is changed, `child` is newly created instead of reusing exisiting HTML tags.
-}

unique : String -> Html msg -> Html msg
unique identifier child =
Keyed.node "div"
[]
[ ( identifier, child )
]

この unique 関数で View に unique な ID をつけてラップすると、その ID が変わるたびに内部のタグを一から作り直すように Virtual DOM に指示を出すことができます。

今回のタブの例だったら、以下のように変更するだけです。

-- もとのコード

, div
[ class "tab_body"
, attribute "role" "tabpanel"
]
[ case model.tab of
TabA ->
renderTabBodyA model

TabB ->
renderTabBodyB model
]

-- `unique` を使ったコード
, div
[ class "tab_body"
, attribute "role" "tabpanel"
]
[ case model.tab of
TabA ->
unique "TabA" <| renderTabBodyA model

TabB ->
unique "TabB" <| renderTabBodyB model
]

修正したデモを触ってみると、正しく動いていることがわかると思います。

ね、簡単でしょ?


Html.Keyed.node ってなに?

unique 関数を定義するのに使った Html.Keyed.node は、本来は List の要素に対してIDを付与することで、Virtual DOM が最適な差分検知を行うためのものです。

List の要素の追加や削除をしたときに、その前後のViewを比較するだけでは確信を持ってどのViewが変わったのかを判断することができません。

そこで、各要素に特別なIDを付与して、Virtual DOM が最適な差分検知をすることができるように手伝います。

unique 関数はこの Html.Keyed.node を利用して、画面が別のものであることを Virtual DOM に告げています。

elm-lang/navigation/ で複数のページを管理するときには、各ページの View を unique 関数でラップすると予期せぬ挙動を防ぐことができます。

もちろん、タグを再利用するのに比べて、毎度URLが変わるたびにタグを作りなおすことになるため、その処理速度の違いを気にするのであればがんばって細粒度に管理してください。

(ふつうはほとんど処理速度の差は出ないと思います)


まとめ

必要な情報を与えないでおいて、「あいつマジ使えねぇな〜」なんて罵倒する無能な上司にはならないよう、我々も Virtual DOM から敬われるような仕事をしたいものですね。

P_20171112_161213_vHDR_On.jpg