Elm でマテリアルデザインなウェブアプリを作った

アプリ概要

アプリとしてはニッチな用途で、写真用のモノクロネガフィルムを自家現像するときに便利なタイマーアプリです。下記は埋め込まれた実際に動くアプリです。

Photo Film Dev で公開しています。

時間配分をレシピと呼んでいますが、レシピはサーバーに保存されるためログインすれば複数の端末でアプリを使用できます。

今はレシピは自分のアカウントでしか見れないですが別のアカウントに共有する機能が作れたらなと考えています。

技術構成

ソースコードGitHub に GPL3 で提供しています。

github.com

アプリの技術構成としては次の通りです。

Elm

Elm は言語としては Haskell 様の文法を採用した alternative HTML/CSS/JS です。The Elm Architecture がいい感じです。チュートリアルを読めば書けるようになります。

モジュール分割

最初は1ファイルに全部書いて始めたのですがわりとすぐにどの辺りに何を書いたか分からなくなったのと同じ名前を付けたいものが出てきたのでモジュール分割をしました。詳細は後述します。

モジュール 役割
Model Msg 以外のデータ型
データ同士の変換関数など
Model.Foo enum として定義する Foo
getset など関連する関数
Msg Msg のデータ型
Msg.Foo 入れ子になる Msg のデータ型
update 関数と対応する
Port port 関数とその引数になる型
その型と内部で使用する型との変換関数
Cmd Port で定義した関数を組み合わせた Cmd を生成する関数
Sub Port で定義した関数を組み合わせた Sub を生成する関数
Update 直和型として定義した Msg 型のそれぞれの値構築子に対応する update 関数
View 画面要素ごとに分割した view 関数
Text 多言語テキスト

Enum

Elm にはアドホック多相が組み込み関数にしかありません。例えば Step という段階を表す型を作って Haskell であれば Enum 型クラスにするところですができないのでモジュールを分けて toIntfromIntcompare などを定義することになります。標準ライブラリーの型でも Maybe など Monad になるものはモジュールごとに andThen を定義しているので正当な方法だと思います。

module Model.Step exposing (..)

type Step
    = Soak
    | Dev
    | Stop
    | …

toInt : Step -> Int
toInt step =
    case step of
        Soak -> 0
        Dev -> 1
        Stop -> 2
        …

fromInt : Int -> Maybe Step
fromInt value =
    case value of
        0 -> Just Soak
        1 -> Just Dev
        2 -> Just Stop
        …
        _ -> Nothing

compare : Step -> Step -> Order
compare step0 step1 = Basics.compare (toInt step0) (toInt step1)

Dict 様レコードでの DRY

フィールドの型が全部同じで辞書(マップ)として使用するような下記のようなレコードの場合フィールドごとに同じような処理を書こうとしてもそのままでは実装を複製するしかありません。Dict にする手もありますが存在することが分かっているのに不必要な Maybe だらけになってしまいます。

type alias TimeSpans =
    { soak : TimeSpan
    , dev : TimeSpan
    , stop : TimeSpan
    , fix : TimeSpan
    , rinse : TimeSpan
    , wet : TimeSpan
    }

そこで先の enum として作った型をキーとして getset 関数を作ります。

module Model.Step exposing (..)

…

get : Step -> { soak : a, dev : a, stop : a, fix : a, rinse : a, wet : a } -> a
get step =
    case step of
        Soak -> .soak
        Dev -> .dev
        Stop -> .stop
        …

set : Step -> a -> { soak : a, dev : a, stop : a, fix : a, rinse : a, wet : a } -> { soak : a, dev : a, stop : a, fix : a, rinse : a, wet : a }
set step value record =
    case step of
        Soak -> { record | soak = value }
        Dev -> { record | dev = value }
        Stop -> { record | stop = value }
        …

これで下記のようにアクセスできます。

timeSpans |> get step
timeSpans |> set step value

これで don't repeat yourself にのっとって処理をまとめることができます。

Msgupdate の分割

Msgupdate の分割のしかたは対応させています。

-- Msg.elm
type Msg
    = Drawer Visible
    | SelectRecipe Recipe
    | GoRun
    …

-- Main.elm
update : Msg -> Model Msg -> ( Model Msg, Cmd Msg )
update msg model =
    case msg of
        Msg.Drawer visible -> Update.drawer visible model
        Msg.SelectRecipe recipe -> Update.selectRecipe recipe model
        Msg.GoRun -> Update.goRun model
        …

Msg.Drawer には Update.drawer というように対応させています。入れ子になったメッセージである VisibleMsg.Visible モジュールに定義してあります。Model の状態による条件分岐は各々の update の中で行っています。

PortCmdSub

Model モジュールに Recipe は下記のような型で定義しているのですがこれは port 関数で JavaScript に渡したり JavaScript から渡されたりすることができません。なので Port モジュールに同名の Recipe を作り変換関数も Port に定義しました。

-- Model.elm
type alias Recipe =
    { id : UUID
    , name : String
    , timeInputs : TimeInputs
    }

-- Port.elm
type alias Recipe =
    { id : String
    , name : String
    , timeInputs : Model.TimeInputs
    }

Port.Recipe から Model.Recipe への変換には elm-form-decoder を使いました。フォームからの変換以外でも外界からデータを取り込む場合に便利です。

Port.setRecipeCmdPort.Recipe に依存していますが Cmd.setRecipeModel.Recipe に依存するように Port の変換関数を使って変換しています。Sub.changeRecipe も同じく Port.changeRecipeSub から変換していますが、msg の構築子が2つあるのは変換の成功時・失敗時を表現するためです。

-- Port.elm
port setRecipeCmd : Recipe -> Cmd msg -- ここの Recipe は Port.Recipe

-- Cmd.elm
setRecipe : Recipe -> Cmd msg -- ここの Recipe は Model.Recipe
-- Port.elm
port changeRecipesSub : (Array Recipe -> msg) -> Sub msg -- ここの Recipe は Port.Recipe

-- Sub.elm
changeRecipes : (List RecipeDecoderError -> msg) -> (Dict String Recipe -> msg) -> Sub msg -- ここの Recipe は Model.Recipe

Material Design

Material Design を採用しライブラリーとしては elm-mdc を使用しました。UI センスがなくても Material Designガイドラインにしたがえばそれっぽくなるので便利です。

github.com

Elm のライブラリーは Elm しか内包できません。このライブラリーは Elm の他に JavaScript なども含むため Git リポジトリーとして公開され、サブモジュールとして依存します。

elm.jsonpackage.json は下記のようになります。

// elm.json
{
    "type": "application",
    "source-directories": [
        "src",
        "elm-mdc/src"
    ],
    "elm-version": "0.19.1",
    "dependencies": {},
    …
}
// package.json
{
  "dependencies": {
    "elm-mdc": "file:elm-mdc",
    …
  },
  …
}

ビルドタスクは GNU Make で管理しているのですが Parcel を使用しているにもかかわらず上記事情のため elm-mdc の面倒も見ているので少々複雑になっています。

多言語テキスト

Text モジュールでは下記の型の値を列挙しています。といっても今のところ日本語のみですが。

type alias Text = Language -> String

モジュール依存関係

モジュールの依存関係を下図に示します。Text は図から省略しています。

f:id:kakkun61:20200720040856p:plain
モジュール依存関係

Text は他のいずれにも依存しません。Model グループは Text にのみ依存します。黄色で ModelMsg を囲っているものは単に矢印の数を削減するためのもので、黄色枠への矢印もしくは黄色枠からの矢印はその中の1個以上への矢印もしくはその中の1個以上からの矢印と読んでください。MsgModelText にのみ依存します。他に特記すべきは PortCmdSub のみから依存され必ずラップされたものが他から参照されます。

Firebase

サーバーは自前で書くつもりだったのですが Firebase をさわってみたら認証とストレージだけで十分だったのでサーバーは自前では立てないことになりました。local storage を使ってスタンドアローンで動くところまで作っていましたが Firestore がオフラインの面倒も見てくれるので local storage の実装は消しました。

先に書いたように Elm のライブラリーは Elm のみに限定されるので、必然的に JavaScript に依存する Firebase のラッパーライブラリーはありません。とはいえ書くのはグルーコードなので大した問題ではありません。

クレジットページ

頒布をともなう場合、多くのオープンソースライセンスはコピーライト表記とライセンス表記を求めます。なのでクレジットページを作成しました。これは自動生成されるようにしています。NPM License CheckerElm License Checker を使用して生成しています。Elm License Checker はなかったので作りました。(Elm License Checker は PureScript 製です。作成時 Elm で CLI を作れることを知らなかったので。)

www.npmjs.com

謝辞

Discord の Elm-jp サーバーのみなさまにはお世話になりました。