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

## 概要

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

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

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

また、本記事で紹介しているコードは[githubで公開](https://github.com/arowM/elm-transition-example)しています。

## まずはこれを見てください
[![FireShot Capture 13 - elm-transition-exa_ - http___localhost_8080_elm-transition-example_index2.html.png](https://qiita-image-store.s3.amazonaws.com/0/16345/90644529-cbc6-2bdf-7e37-604dd45c78e8.png)](https://arowm.github.io/elm-transition-example/index.html)

わりと現実的にありえそうな[デモ](https://arowm.github.io/elm-transition-example/index.html)をさくっと作っておきました。
タブが2つあって、切り替えると内容が変わります。
Tab Bには "Edit" と書かれたリンクがあり、これをクリックするとポップアップが立ち上がって日付を変更できるイメージです。
(実際にはポップアップはダミーなので日付を変更することはできません)

なにか気づきましたか？
Tab Bに切り替えた時に、一瞬ポップアップが表示されたり、一瞬暗転したりするのではないでしょうか？
(ブラウザによって挙動が若干異なるようです)

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

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

## まずは実装を見てみる

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

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

        TabB ->
            renderTabBodyB model
    ]
```

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

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

```elm
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` は下記のように定義されています。

```elm
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 属性](https://www.w3.org/TR/2014/REC-wai-aria-20140320/states_and_properties#aria-hidden)の値が切り替わります。

```html
<div class="popup" aria-hidden="true">
    ...
```

```html
<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` 関数です。

```elm
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 に指示を出すことができます。

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

```elm
-- もとのコード
, 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
    ]
```

[修正したデモ](https://arowm.github.io/elm-transition-example/index2.html)を触ってみると、正しく動いていることがわかると思います。
ね、簡単でしょ？

## `Html.Keyed.node` ってなに？

`unique` 関数を定義するのに使った [Html.Keyed.node](http://package.elm-lang.org/packages/elm-lang/html/2.0.0/Html-Keyed) は、本来は 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](https://qiita-image-store.s3.amazonaws.com/0/16345/2d7c52fb-d99b-9e0d-1c83-7dfe4d79a82e.jpeg)](https://connpass.com/event/74279/)