Elm

ElmとJavaScriptの会話(Port)

 (追記)もうすこし基本的なPortの記事も書きました ==> Portを通したElm Record と JavaScript Object の互換性 - Qiita

 一般的に、Elmだけで完全なアプリを作る場合もあれば、JavaScriptプログラムと組み合わせてアプリを作成する場合もあります。Elmに無いライブラリを使いたい場合などは、当然後者を選択します。ElmにはJavaScriptと会話するためのPortという仕組みがあります。今回は以下のサイトに従って勉強しましたが、その備忘録です。ソースコードは多少修正してあります。
http://codeloveandboards.com/blog/2017/05/15/elm-and-the-outer-world/

 Elmは一番基本のModel-View-Updateパタンの場合だと、汚れ仕事であるイベントの管理やDOM表示はElm Realtimeが行ってくれます。美しいパタンですが、汚れ仕事はほとんどできません。そこでCmd/Subを導入して、それ以外のWebアプリではよくある汚れ仕事も行えるようになりました。Httpや乱数などです。しかしそれも限定的で、JavaScriptでなければやれないこともあります。Portはこれを解決すべく、任意のJavaScriptプログラムと交信できる仕組みとして導入されたと考えられます。
Elmで乱数を扱う(Cmd) - Qiita
Elmでサーバと通信する(Cmd) - Qiita
Building custom DOM event handlers in Elm

1.開発環境について

 今回はElmプログラムからJavaScriptのFileReaderオブジェクトを使う例を考えます。今回のプログラムは3つのファイルから構成されます。3つのプログラムファイルはプロジェクトディレクトリ直下のsrcディレクトリにあるとします。
・index.html -- ここにJavaScriptプログラムを展開します
・Main.elm -- elmのMain moduleです。index.htmlのJavaScriptと会話します
・Ports.elm -- Portsを定義したelm moduleです。

 まず必要なパッケージをインストールします。

$ elm-package install elm-lang/http

 以下のコマンドでコンパイルします。

$ elm make --warn --debug src/Main.elm --output src/main.js

 httpサーバを立ち上げ、ブラウザでアクセスするとアプリが起動します。

$ elm-reactor -a=www.xxxxxx.jp -p=3030

2.プログラムの処理の流れ

 大雑把に説明すると以下の流れになります。

画像ファイル名を選択(Elm)  --> 画像ファイルの中身を読む(JavaScript)  --> 画像を表示(Elm)

 もう少し詳細な流れは以下のようになります。

1.ファイル選択 <input type="file">の画面を表示する(Elm)
2.ファイルを選択しイベントを発生させる(Elm)
3.イベント処理で選択されたファイル名をJavaScriptに送る(Elm)
4.ファイル名を受け取り、FileReaderで中身を読み込む(JavaScript)
5.読み込んだファイルデータをElmに送信する(JavaScript)
6.ファイルデータを受け取り、画面に表示する(Elm)

3.port module(src/Ports.elm)

 Elmで以下のように名前と型だけの関数を用意し、Cmd/Subとして実行します。

src/Ports.elm
port module Ports exposing (fileSelected, fileLoaded)

import Json.Decode as JD

-- Out ports
port fileSelected : JD.Value -> Cmd msg

-- In ports
port fileLoaded : (String -> msg) -> Sub msg

 まずmodule宣言ですが、以下のように先頭にportを付ける必要があります。

port module Ports exposing (fileSelected, fileLoaded)

 続けて2つの関数(fileSelected、fileLoaded)の名前と型だけを記述します。これだけを宣言するとElmプログラムとJavaScriptプログラムで以下のように使うことができるようになります。

 以下が選択されたファイル名を送るコードです(Elm --> JavaScript)。 ファイル選択のイベントはElm内で起こり、それをJavaScriptに伝えるべくupdate関数でCmdを発行します(1)。するとJavaScript側で対応する関数が起動されます(2)。

1. Cmd.batch [ fileSelected event ]  @ Elm
2. app.ports.fileSelected.subscribe(function (e) {...}); @ JavaScript

 以下が読み込んだファイルコンテンツを送るコードです(JavaScript --> Elm)。上の2のJavaScriptのcallbackの中でファイルを読み込み、終了したらElmに伝えます(3)。Elm側では(4)で登録済みのSubで受取り、update関数を呼びます。

3. app.ports.fileLoaded.send(base64encoded); @ JavaScript
4. Sub.batch [ fileLoaded FileLoaded ] @ Elm

4.カスタム イベント リスナーの作成

 このカスタムイベントの部分は少しわかりにくいので、細かく見ていこうと思います。まずは以下のview関数を見てください。

view関数
type Msg
    = FileSelected JD.Value
    | FileLoaded String

view model =
    section
        [ sectionStyles ]
        [ imageView model.imageData
        , input
            [ type_ "file"
            , accept "image/*"
            , on "change" (JD.map FileSelected JD.value)
            ]
            []
        ]

 注目してほしいのはon関数ですが、これはHtml.Eventsで定義されている関数でイベントリスナーを登録してくれます。まずは型を見てください。
http://package.elm-lang.org/packages/elm-lang/html/latest/Html-Events#on

on : String -> Decoder msg -> Attribute msg
map : (a -> value) -> Decoder a -> Decoder value

 イベントが起こった時に、decoderはeventオブジェクトを Elmの値に変換してくれます。その値はupdate関数に送られます。型を考慮して実際のコードをみます。

on "change" (JD.map FileSelected JD.value)

 この一行で、(1)JavaScript NativeのDOM eventsオブジェクトの取得、(2)DOM eventsのDecode、(3)Taggingの処理を行っています。

 ここで(JD.map FileSelected JD.value)がdecorderになります。

 JDはJson.Decodeで、JSON値をElm値に変換してくれるmoduleです。
http://package.elm-lang.org/packages/elm-lang/core/5.1.1/Json-Decode

 JD.valueはJavaScriptのJSONオブジェクトをそのままの形でElmの世界に取り入れるための、偽装の変換を行うdecorderです。実際に値そのものの変更は行っていませんが、形だけはElmの値となっています。これは後でportによってそのままJavaScriptに渡されるものです。Elmの世界ではこれ以上この値の中身に触ることはできません。ここではDOM eventオブジェクトをそのままElm値に変換しています。

 またJD.valueで偽装変換された値は、タグ(FileSelected)を付けたMsgでupdate関数に渡されます。タグ付けはJD.mapで行なわれます。結論的に、update関数にはFileSelected eventというMsgが送られます。

5.JavaScriptプログラム(src/index.html)

 JavaScriptプログラムは以下のようにindex.htmlに埋め込んでいます。

src/index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Elm FileReader</title>
  </head>

  <body>
    <div id="elm-area"></div>
    <script src="main.js"></script>
    <script>
        const app = Elm.Main.embed(document.getElementById("elm-area"));

        app.ports.fileSelected.subscribe(function (e) {
          const input = e.target;
          const file = input.files[0];
          const reader = new FileReader();

          reader.onload = (function (event) {
            const base64encoded = event.target.result;
            app.ports.fileLoaded.send(base64encoded);
          });

          reader.onerror = (function (err) {
            console.error(err);
          });

          reader.readAsDataURL(file);
        });
    </script>
  </body>
</html>

 Elmアプリもindex.htmlで起動していますが、以下のコードで行っています。

    <div id="elm-area"></div>
    <script src="main.js"></script>
    <script>
        const app = Elm.Main.embed(document.getElementById("elm-area"));

 上以外はJavaScriptのプログラムになります。まずElm側からファイル名が伝えられます。
 eは、Elm側からfileSelected eventというコマンドで渡されたeventオブジェクトで、Elmのカスタムイベント(on "change")で拾ったJavaScriptのeventを、そのまま渡してくれたものです。つまりJavaScriptのeventです。

        app.ports.fileSelected.subscribe(function (e) {

 次にそのファイルをreadします。

          reader.readAsDataURL(file);

 readが終わったら、データをElm側に渡します。

          reader.onload = (function (event) {
            const base64encoded = event.target.result;
            app.ports.fileLoaded.send(base64encoded);
          });

 以上がJavaScriptプログラムの処理になります。

6.Elmプログラム(src/Main.elm)

 以下がElmプログラムです。

src/Main.elm
module Main exposing (..)

import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (on)
import Json.Decode as JD
import Ports exposing (fileSelected)
import Ports exposing (fileLoaded)

init : ( Model, Cmd Msg )
init =
    initialModel ! []

main : Program Never Model Msg
main =
    Html.program
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }

type Msg
    = FileSelected JD.Value
    | FileLoaded String

type ImageData e s
    = NotLoaded
    | Loading
    | Error e
    | Success s

type alias Model =
    { imageData : ImageData String String }

initialModel : Model
initialModel =
    { imageData = NotLoaded }

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        FileSelected event ->
            ( { model | imageData = Loading }, Cmd.batch [ fileSelected event ] )
        FileLoaded binary ->
            ( { model | imageData = Success binary },  Cmd.batch [] )

view : Model -> Html Msg
view model =
    section
        []
        [ imageView model.imageData
        , input
            [ type_ "file"
            , accept "image/*"
            , on "change" (JD.map FileSelected JD.value)
            ]
            []
        ]

imageView : ImageData String String -> Html Msg
imageView imageData =
    case imageData of
        NotLoaded ->
            p
                []
                [ text "Choose an image file using the selector below..." ]

        Loading ->
            p
                []
                [ text "Loading..." ]

        Error error ->
            p
                []
                [ text error ]

        Success binary ->
            img
                [ style
                    [ ( "max-height", "300px" )
                    , ( "margin-bottom", "3rem" )
                    ]
                , src binary
                ]
                []

subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch [ fileLoaded FileLoaded ]

 今回のテーマで、まだ説明していないのはupdateです。以下に説明していきたいと思います。

update関数
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        FileSelected event ->
            ( { model | imageData = Loading }, Cmd.batch [ fileSelected event ] )
        FileLoaded binary ->
            ( { model | imageData = Success binary },  Cmd.batch [] )

 まず FileSelected event の場合は前に見た通り、以下のイベントに対応しています。

view関数の一部
            , on "change" (JD.map FileSelected JD.value)

 次に FileLoaded binary の場合は、以下のsubscriptionsに対応しています。

subscriptions関数
subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch [ fileLoaded FileLoaded ]

 subscriptionsは、必要なmodel更新を行うべく、任意のイベントソースをリッスンするためのものです。イベントが生じたときにupdateが呼ばれ、modelの更新が行われます。
https://guide.elm-lang.org/architecture/effects/

 このsubscriptionsはJavaScript側の、以下の実行をリッスンしています。

javascriptプログラムの一部
            app.ports.fileLoaded.send(base64encoded);

 今回は以上です。