👋

Elmにおける気軽にJavaScriptを呼び出せない(ポート)問題

2021/01/27に公開

結論

ElmでJavaScriptと双方向通信するのは、ちょっと面倒なコードを書かなければなりません。そのまま突き進んでも良いですが、Elmだけで完結する道を選ぶか、Elmを捨てる道を選んでも良いかもしれません。

背景

Elmは原則、実行時エラーが起きない、とても守りに特化した言語です。複雑な言語仕様を削りに削って、副作用を起こす手段はHTTP通信,乱数,時間, HTMLイベント, ファイルぐらいに制限されています。JavaScriptを内包したElmコードをユーザがライブラリ化することは固く禁じられています。

しかし、それでは実用的なプログラムを記述することが出来ないためJavaScriptとやりとりするための仕組みポートが用意されています。これはElmとJavaScriptをサーバ・クライアントモデルのように見立てるもので、ElmはJavaScriptに命令(Cmd)を投げたり、実行結果を待ち受けたり(Subscription)することができ、JavaScriptも同様のことが行なえます。

Elm <-> JavaScript

これはFFIの実装の一種として考えられますが、JavaScriptをラップしてElmから呼び出す形式と違い、それぞれの実行は言語ごとに断絶されています。そのため、ElmでJavaScriptの実行時エラーが侵食されることは無く、原則実行時エラーが起きないと言う鉄の掟を守ることが出来るのです。しかし、そのおかげで双方向にやり取りするために面倒なコードを書かなければならないと言うトレードオフが発生します。

JavaScriptのwindow.confirmで双方向通信を考えてみる

双方向通信を考える例として、window.confirmを考えてみましょう。
動くコードを貼っておきます。

まずElm側の実装として、ボタンをクリックさせます。

Elmはボタンのクリックをトリガーとしてwindow.confirm(JavaScript)の呼び出しを要請します。confirmはOK, Cancelどちらが押されたかを検知してElmに伝えます。

その結果値を待ち受けて、Elmは結果を表示します。

もしElmがJavaScriptをラップする事ができる言語であったならば

仮にJavaScriptをラップすることが出来る言語だったと仮定しましょう。その場合は、JavaScript.call.confirmこんな感じのメソッドとしてJavaScriptを呼び出せるかもしれません。この場合はやりたいことがわかりやすく・短いと言うことが挙げられます。代わりにJavaScriptとの境界がないため、もしミスがあれば実行時エラーが起きてしまうでしょう。

onClick(() =>
   const isOK = JavaScript.call.confirm(); // JavaScriptを呼び出したとする
   showResult(isOk);
);

Elmのポートではどうなっているか

Elmのコードを見てみましょう。confirmのダイアログが開くためのトリガーとしてConfirmメッセージを発行します。

type Msg
    = Confirm
    | ConfirmReceive Bool
    
    
view : Model -> Html Msg
view model =
    div []
        [ button [ onClick Confirm ] [ text "確認ダイアログを開く" ]
    ...

Confirmメッセージの副作用(Cmd)として、JavaScriptを呼び出すためのport関数confirmを呼び出します。

port confirm : () -> Cmd msg
port confirmReceiver : (Bool -> msg) -> Sub msg

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Confirm ->
            ( model, confirm () )
   ...

JavaScript側ではconfirmが呼び出された場合、待ち構える(subscribe)コールバック関数を定義しておきます。confirmメソッドの結果isOkをElmに渡すためのport関数confirmReceiverに渡します(send)。

  const app = Elm.Main.init({ node: document.querySelector('main') })
    
    app.ports.confirm.subscribe(() => {
       const isOk = confirm("Elm好きですか?");
        app.ports.confirmReceiver.send(isOk);
    });

Elmはこの結果をconfirm () を呼び出した後に受け取れるわけではありません。同様に待ち受ける必要があります。subscriptionsで、ConfirmReceiveメッセージを発行する形で待ち受けます。updateでやっとこの値を受け取ることができます。

subscriptions : Model -> Sub Msg
subscriptions model =
    confirmReceiver ConfirmReceive
    

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ...

        ConfirmReceive isElmLove ->
            ( { model | elmLoveMaybe = Just isElmLove }, Cmd.none )

そしてこの結果をViewで表示します。

type alias Model =
    { elmLoveMaybe : Maybe Bool }
    
    
view : Model -> Html Msg
view model =
    div []
        [ button [ onClick Confirm ] [ text "確認ダイアログを開く" ]
        , case model.elmLoveMaybe of
            Just isElmLove ->
                p []
                    [ text <|
                        if isElmLove then
                            "やったー!"

                        else
                            "残念。。。"
                    ]

            Nothing ->
                text ""
        ]

まとめ

Elmはとても固く・シンプルに設計された言語です。それはとても利点ですが、同時に強い制約で開発者を縛り付けることになります。例えば、(個々人の感覚に寄りますが)Firebaseやローカルストレージなどの仕組みを安全に利用するコストとしてのポートのやりとりは許容すべき範囲内に思えます。しかし、confirmや簡単な計算ライブラリに命令を投げ、その値を受取るなどのやり取りとしてはオーバースペックに思えます。このようなケースとしてElmで完結出来るようにHTML・CSSを自作したり、ライブラリのロジックをElmに移植するなどが手として考えられます。しかし、規模が小さいアプリケーションでそれをしたい場合は一度立ち止まってElmでやるべきものかどうかを疑ってみても良いと思います(大規模であれば、実行時エラーが起きない・シンプルなコードに統一出来るなどは大きな利点なので腹を括る判断をするのも選択肢の一つです)。是非、Elmをうまく活用してみてください!

Discussion