ポート(Ports)

ポートを使うと、ElmとJavaScriptの間でやりとりができます。

よくあるポートの使いみちとしてWebSocketlocalStorageが挙げられますが、ここではWebSocketを使った例に注目してみましょう。

JavaScriptでのポート

以前のページに出てきたHTMLとほとんど同じですが、少しだけJavaScriptが追加されています。ここで接続しているwss://echo.websocket.orgは、送ったものを何でもそのまま返してくるWebSocketサーバーです。こちらのデモから、このコードでチャットルームを作るための骨組みを見ることができます。

<!DOCTYPE HTML>
<html>

<head>
  <meta charset="UTF-8">
  <title>Elm + Websockets</title>
  <script type="text/javascript" src="elm.js"></script>
</head>

<body>
    <div id="myapp"></div>
</body>

<script type="text/javascript">

// Elmアプリケーションを開始します
var app = Elm.Main.init({
    node: document.getElementById('myapp')
});

// WebSocketの通信を確立します
var socket = new WebSocket('wss://echo.websocket.org');

// `sendMessage`というポートにコマンドが送られてきたとき、
// 受け取ったメッセージをWebSocketに渡します
app.ports.sendMessage.subscribe(function(message) {
    socket.send(message);
});

// WebSocketがメッセージを受信したら、今度は
// `messageReceiver`のポートを通してElmにメッセージを送ります
socket.addEventListener("message", function(event) {
    app.ports.messageReceiver.send(event.data);
});

// WebSocketを扱うのにJavaScriptライブラリを
// 使いたいときは、このコードの実装を置き換えてください
</script>

</html>

この「JavaScriptとの相互運用」の章に出てくる他の例と同じようにElm.Main.init()関数を呼んでいますが、今回はその戻り値のappオブジェクトを利用します。sendMessageポートでElmからのメッセージを待ち受けつつ、messageReceiverポートを使ってElmへデータを送信します。

これはちょうど、Elm側のコードと対応しています。

Elmでのポート

こちらが対応するElmのコードです。特に、予約語のportが使われている行に注意してみてください。さっきJavaScript側でポートが使われているところを見ましたが、そのポートをElmではこんなふうに定義します。

port module Main exposing (..)

import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Json.Decode as D



-- MAIN


main : Program () Model Msg
main =
  Browser.element
    { init = init
    , view = view
    , update = update
    , subscriptions = subscriptions
    }




-- PORTS


port sendMessage : String -> Cmd msg
port messageReceiver : (String -> msg) -> Sub msg



-- MODEL


type alias Model =
  { draft : String
  , messages : List String
  }


init : () -> ( Model, Cmd Msg )
init flags =
  ( { draft = "", messages = [] }
  , Cmd.none
  )



-- UPDATE


type Msg
  = DraftChanged String
  | Send
  | Recv String


-- ユーザーがエンターキーを押すか、Send ボタンをクリックしたとき、`sendMessage`ポートを使っています。
-- これがどんなふうにWebSocketとつながっているのかindex.htmlにあるJavaScriptと対応させてみてください。
--
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    DraftChanged draft ->
      ( { model | draft = draft }
      , Cmd.none
      )

    Send ->
      ( { model | draft = "" }
      , sendMessage model.draft
      )

    Recv message ->
      ( { model | messages = model.messages ++ [message] }
      , Cmd.none
      )



-- SUBSCRIPTIONS


-- `messageReceiver`ポートを使って、JavaScriptから送られるメッセージを待ち受けています。
-- どうやってWebSocketとつながっているのかは、index.htmlファイルを見てください。
--
subscriptions : Model -> Sub Msg
subscriptions _ =
  messageReceiver Recv



-- VIEW


view : Model -> Html Msg
view model =
  div []
    [ h1 [] [ text "Echo Chat" ]
    , ul []
        (List.map (\msg -> li [] [ text msg ]) model.messages)
    , input
        [ type_ "text"
        , placeholder "Draft"
        , onInput DraftChanged
        , on "keydown" (ifIsEnter Send)
        , value model.draft
        ]
        []
    , button [ onClick Send ] [ text "Send" ]
    ]



-- DETECT ENTER


ifIsEnter : msg -> D.Decoder msg
ifIsEnter msg =
  D.field "key" D.string
    |> D.andThen (\key -> if key == "Enter" then D.succeed msg else D.fail "some other key")

最初の行でただのmoduleの代わりにport moduleと書いていることに気をつけましょう。これでモジュールの中でポートを定義できるようになります。もし忘れてしまってもコンパイラーがヒントを表示してくれるので、ここでつまづく人は少ないでしょう。

さて、sendMessagemessageReceiverportの宣言ではいったい何が起きているのでしょうか?

外向きのメッセージ(Cmd

sendMessageはElmから外の世界へメッセージを送るために使います。

port sendMessage : String -> Cmd msg

ここではStringの値を送ると宣言していますが、フラグに使えるものならどんな型でもポートを通して送信できます。どの型が利用できるかは、1つ前のページで解説しています。このlocalStorageの例ではJson.Encode.ValueをJavaScriptに送っています。

sendMessageポートは他の関数とまったく同じように使うことができます。もしupdate関数からsendMessage "hello"というコマンドを発行したなら、JavaScriptでそれを受け取るやり方はこうです:

app.ports.sendMessage.subscribe(function(message) {
    socket.send(message);
});

このJavaScriptコードはsendMessageから送られてくる外向きのあらゆるメッセージを待ち構えています。もしそうしたければ複数の関数をsubscribeに登録することができます。

var foo = function(message) {...};
var bar = function(message) {...};

app.ports.sendMessage.subscribe(foo);
app.ports.sendMessage.subscribe(bar);

注: JavaScriptの他の関数と同じように1度のsubscribeで複数の関数を登録することはできません。

// これは2つ目以降の引数は無視されます
app.ports.sendMessage.subscribe(foo, bar);

また、登録した関数の参照を渡すことでメッセージの受け取りを停止させるunsubscribeもあります。

var foo = function(message) {...};

app.ports.sendMessage.subscribe(foo);
...
app.ports.sendMessage.unsubscribe(foo);

ですが、ふつうは一度登録したあとで変更せず静的に扱うのがよいでしょう。

もう1つポートを定義するときに大切なことがあります。JavaScriptの関数を逐語的にポートで定義して、必要に応じてたくさんのポートを使い分けるのはいい方法ではありません。それよりもポートを通して送るメッセージのほうをリッチにして、ポート自体は抽象度を高めて数を少なくしておくほうが好ましいでしょう。例えば、Elmの側ではカスタム型で送りたいデータを表現し、Json.Encodeで変換して送ります。そしてJavaScriptでは、そのメッセージを処理する専用の関数を書いて待ち構えておくのです。多くの開発者が、そのほうが関心事をきれいに分離できると言っています。ElmにはElmの、JavaScriptにはJavaScriptの管理するべき状態がはっきりとあるはずです。

内向きのメッセージ(Sub

messageReceiverはElmの外の世界から送られてくるメッセージを待ち受けるために使います。

port messageReceiver : (String -> msg) -> Sub msg

これはStringの値を受け取ることを示していますが、ここでも、フラグや外向きのポートで使える型は何でも受け取ることができます。Stringの指定を、JavaScriptとの境界をまたぐことのできる好きな型に入れ替えてください。

messageReceiverもまた、他の関数と同じように使えます。この例ではsubscriptionsの定義においてJavaScriptからのメッセージを待ち受けるためにmessageReceiver Recvを呼び出しています。こうすることで、update関数の中でRecv "how are you?"のようなメッセージを受け取ることができます。

JavaScript側では、いつでも好きな時にポートを使ってメッセージを送れます。

socket.addEventListener("message", function(event) {
    app.ports.messageReceiver.send(event.data);
});

今回はたまたまWebSocketが受信したのと同じタイミングでデータをElmに送っていますが、別のタイミングで送りたくなることがあるかもしれません。もしかすると、WebSocketから送られてくるデータと一緒に他の場所にあるデータを使うかもしれません。その場合もElmは「データがどこから取得されたのか」「メッセージをいつ受け取るのか」といったことを予め知っておく必要はありません! ただJavaScript側でデータを整形し、対応するポートに送るだけでいいのです。

注意するべきこと

ポートはElmとJavaScriptを強く結合させます! 欲しいJavaScriptの関数すべてに1対1で対応するポートを作るようなことは絶対に避けるべきです。あなたはElmが大好きで、何もかもElmの中で解決したいと考えているかもしれませんが、ポートはそのために作られた道具ではありません。誤った使いかたをしないためには、 Elm と JavaScript が受け持つべき責務を、例えば「状態を管理するのはどちらか?」というぐあいに1つずつ取り上げて問いかけてみましょう。そして1つか2つだけのポートを使い、責務を果たすために過不足のないメッセージをやりとりしましょう。もし複雑なシナリオでポートを使う必要があるなら、JavaScriptへ送るメッセージの中に、カスタム型が取りうる選択肢を { tag: "active-users-changed", list: ... } のようにタグとして埋め込むことで、Elm側のMsgを再現することができます。

では、ポートを使う上での簡単なガイドラインと、よくある落とし穴を示しておきましょう。

  • Json.Encode.Value型の値はポートでやり取りするのに向いています。 そのほかにも、フラグの例で見たようにelm/coreに含まれる型にもポートを通して渡せるものがあります。これは Elm に JSON デコーダーが導入される前から存在しているものです。詳しくはこちらを読んでみてください。
  • すべての portport module の中で宣言されなくてはいけません。 ひとつの port module の中にすべてのポートをまとめてしまうのがおそらく最善で、ひとつの場所にすべての JavaScript とのインターフェイスがあったほうがより把握しやすいでしょう。
  • ポートはアプリケーションのためのものです。 port module はアプリケーションでは使えますが、パッケージでは使えません。こうすることで、アプリケーションを作るときには必要に応じて JavaScript を使えるよう融通を効かせながらも、公開されているパッケージはすべて Elm で書かれていることを保証しているのです。長い目で見ると、これが強固なエコシステムとコミュニティを構築する助けになります。JavaScriptとの相互運用に関するこの制限事項によって、Elmが何を得て何を失ったのか、次の節で詳しく解説しています。
  • ポートは最適化によって消されることがあります。 Elmコンパイラーはとても積極的にデッドコード除去による最適化を行っており、Elmコードの中で一度も呼び出されていないポートは、コンパイル後のJavaScriptから取り除かれてしまいます。Elmのコンパイラーは、JavaScript側のコードがポートを使っていることを知らないのです。JavaScriptでポートを使ったコードを書く前に、まずElmの側でそのポートを使うようにしましょう。

既にJavaScriptで書かれたアプリケーションを運用しているなら、ぜひともこの節で学んだ知識を使って、JavaScriptの中にElmを組み込む方法を模索してみてください。ElmとJavaScriptを共存させるという考え方はあまり魅力的に思えないかもしれません。しかし、このやり方はサービスの運用やプロジェクトの管理を円滑にするという点で、全体をElmで書き直すよりずっと大きな効果を示すことがわかっており、実績に裏打ちされた戦略なのです。

results matching ""

    No results matching ""