ポート(Ports)
ポートを使うと、ElmとJavaScriptの間でやりとりができます。
よくあるポートの使いみちとしてWebSocket
やlocalStorage
が挙げられますが、ここでは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
と書いていることに気をつけましょう。これでモジュールの中でポートを定義できるようになります。もし忘れてしまってもコンパイラーがヒントを表示してくれるので、ここでつまづく人は少ないでしょう。
さて、sendMessage
とmessageReceiver
のport
の宣言ではいったい何が起きているのでしょうか?
外向きのメッセージ(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 デコーダーが導入される前から存在しているものです。詳しくはこちらを読んでみてください。
- すべての
port
はport module
の中で宣言されなくてはいけません。 ひとつのport module
の中にすべてのポートをまとめてしまうのがおそらく最善で、ひとつの場所にすべての JavaScript とのインターフェイスがあったほうがより把握しやすいでしょう。
- ポートはアプリケーションのためのものです。
port module
はアプリケーションでは使えますが、パッケージでは使えません。こうすることで、アプリケーションを作るときには必要に応じて JavaScript を使えるよう融通を効かせながらも、公開されているパッケージはすべて Elm で書かれていることを保証しているのです。長い目で見ると、これが強固なエコシステムとコミュニティを構築する助けになります。JavaScriptとの相互運用に関するこの制限事項によって、Elmが何を得て何を失ったのか、次の節で詳しく解説しています。
- ポートは最適化によって消されることがあります。 Elmコンパイラーはとても積極的にデッドコード除去による最適化を行っており、Elmコードの中で一度も呼び出されていないポートは、コンパイル後のJavaScriptから取り除かれてしまいます。Elmのコンパイラーは、JavaScript側のコードがポートを使っていることを知らないのです。JavaScriptでポートを使ったコードを書く前に、まずElmの側でそのポートを使うようにしましょう。
既にJavaScriptで書かれたアプリケーションを運用しているなら、ぜひともこの節で学んだ知識を使って、JavaScriptの中にElmを組み込む方法を模索してみてください。ElmとJavaScriptを共存させるという考え方はあまり魅力的に思えないかもしれません。しかし、このやり方はサービスの運用やプロジェクトの管理を円滑にするという点で、全体をElmで書き直すよりずっと大きな効果を示すことがわかっており、実績に裏打ちされた戦略なのです。