ports
Elm
VirtualDom

[Elm] DOM更新後にJSコードを実行する

この内容は Elm 0.18, 0.19 を対象にしています。
また、Elm Discourseの回答 が元ネタです。

結論

update 関数で DOM を更新した後に JavaScript を実行したい場合、

elm.app.ports.foo.subscribe(function (id) {
  requestAnimationFrame(function () {
    /* when this callback executes, the view should have rendered. */
  })
})

のように ports を登録する必要があります。

問題提起

Elm の update 関数は、型 update : Model -> Msg -> (Model, Cmd Msg) で示されるように、なんらかのイベント (Msg で示されるもの) に応じて、更新後のDOM構造 (厳密にはDOM構造そのものではなく Model) と副作用 (Cmd Msg) を同時に指定します。

ports を用いて JavaScript を実行する場合、Cmd Msg の部分にその処理を記述しますが、
更新後のDOMに依存する JavaScript コードを実行したい場合はどうしたらいいでしょうか?

画面更新と Cmd の処理タイミング

update 関数によって新しい DOM 構造が指定されると、Elm はその更新処理を animation-frame に登録します。
その直後に、Cmd Msg として指定された処理を行います。

animation-frame はブラウザ画面のリフレッシュレートに応じた間隔で処理を呼び出すため、
特に工夫なく ports を使って JavaScript の処理を Cmd として登録した場合、画面の更新処理よりも先にその JavaScript が実行されてしまう可能性があります。

解決方法

画面の更新処理を animation-frame に登録しているので、実行したい JavaScript も同じように animation-frame に登録すれば、 animation-frame がタスクキューとして実行順を保証してくれます。

具体的には、冒頭で「結論」として示したように

elm.app.ports.foo.subscribe(function (id) {
  requestAnimationFrame(function () {
    /* when this callback executes, the view should have rendered. */
  })
})

のような形式で、実行したい処理を animation-frame に登録する処理を記述すれば良いことになります。

elm-lang/dom などは内部的に animation-frame に処理を登録する実装になっているため、更新後に初めて作成される HTML タグに対して elm-lang/dom で定義されているスクロールやフォーカスなどの処理を適用する際には、特に気にすることなく update の返り値に更新後のModelとともに処理を登録すればうまく動きます。

危険🐐スクリプト芸との関わり

実は危険🐐スクリプト芸を使っても「DOM 構造に依存するJavaScriptを実行する」という需要を叶えることができます。

単純に、View に script タグを直接埋め込むだけです。

someView : Model -> Html Msg
someView { showPart } =
    div
        []
        [ if showPart then
            div
                []
                [ anotherView
                , node "script" [] [ text """ /* JavaScript code */ """ ]
                ]
          else
            text ""
        ]

ただ、この手法は「バグ」としてあつかわれており、次のバージョンの Elm では使えなくなる可能性が高いです。

非推奨な危険🐐スクリプト芸ではなく、animation-frame を使った方法を今のバージョンでも使うことをオススメします。

さくらちゃん