[Elmを完全に理解する]elm-spa-exampleへの橋渡しelm-spa-trivialを作った

この記事は[Elmアドベントカレンダー](https://qiita.com/advent-calendar/2019/elm)19日目の記事です。

## Elmを完全に理解する

例によって言葉の定義を再確認しておく。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">エンジニア用語の「完全に理解した」「何も分からない」「チョットデキル」は「ダニング・クルーガー効果」で簡単に説明ができます。これは一種の認知バイアスで能力の低い段階では自分の能力の低さを認識できないためです（過大評価しがち）。その反面で能力が高くなると過少評価しがちです。 <a href="https://t.co/LGaJ4E5hWo">pic.twitter.com/LGaJ4E5hWo</a></p>&mdash; おちゃめ (@ochame_nako) <a href="https://twitter.com/ochame_nako/status/1115270046794653696?ref_src=twsrc%5Etfw">April 8, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 何を作ったのか

[Richard Feldman (@rtfeldman)](https://twitter.com/rtfeldman)の[elm-spa-example](https://github.com/rtfeldman/elm-spa-example)への橋渡しになればと思い粗雑かつ些細なSPAということで[elm-spa-trivial](https://github.com/lilpacy/elm-spa-trivial)というレポジトリを作った。

![elm-spa-trivial-demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/170241/5716f8e6-f725-67af-7823-d3c6b64cf1a4.gif)

4ページから構成されている静的なページでスクロール位置も保存してくれる。コード量としては200行程度。
README.mdにも記載しているが、以下のコマンドで簡単にローカルで動かすことができる。(ちなみに、バージョンは0.19.1)

```sh:セットアップ
$ git clone git@github.com:lilpacy/elm-spa-trivial.git
$ cd elm-spa-trivial
$ npm run setup
```

```sh:開発サーバー起動
$ npm run dev
```

今年のAdventCalenderでも似たような記事もあったりするようなので割とそこに課題感はあるのだと感じた。

[ElmSPAの初歩 @dyoshikawa | Qiita](https://qiita.com/dyoshikawa/items/b296194088dced323a8a)

## なぜ作ったのか
Elmを業務で触り始めて半年が経った。4月に新卒入社をして先輩社員に言われるのがelm-spa-exampleが参考になるということだった。

けれど、チュートリアルとelm-spa-exampleがあまりに飛躍していて理解するのにかなり時間と労力を要したため、半年前の自分と同じ立場にいるような人に向けて少しでもelm-spa-exampleのような規模の大きめなSPAを作るに至るまでの橋渡しになればと思いレポジトリとして公開をした。

## コードの解説

### まずは全文

```elm:Main.elm
port module Main exposing (Msg(..), main, update, view)

import Browser
import Html exposing (Html, a, div, h1, h3, text)
import Html.Attributes exposing (class, href)
import Html.Events exposing (preventDefaultOn)
import Json.Decode as D


port onUrlChange : (() -> msg) -> Sub msg


port getLocation : () -> Cmd msg


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


port pushUrl : String -> Cmd msg


port setOffsets : () -> Cmd msg


type alias Flags =
    String


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


subscriptions _ =
    Sub.batch
        [ onUrlChange UrlChanged
        , gotLocation GotLocation
        ]


type Model
    = Top
    | Polano
    | MilkyTrain
    | RainWind
    | NotFound


init : Flags -> ( Model, Cmd Msg )
init flags =
    ( locationHrefToModel flags
    , Cmd.none
    )


type Msg
    = UrlChanged ()
    | GotLocation String
    | Clicked String
    | NoOp


locationHrefToModel : String -> Model
locationHrefToModel here =
    if here == "http://localhost:1234/" then
        Top

    else if here == "http://localhost:1234/polano-square" then
        Polano

    else if here == "http://localhost:1234/milky-train" then
        MilkyTrain

    else if here == "http://localhost:1234/rain-wind" then
        RainWind

    else
        NotFound


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        UrlChanged _ ->
            ( model
            , getLocation ()
            )

        Clicked url ->
            ( model
            , pushUrl url
            )

        GotLocation url ->
            ( locationHrefToModel url
            , setOffsets ()
            )

        NoOp ->
            ( model, Cmd.none )


type alias Page =
    { title : String, phrase : String }


topPage : Html Msg
topPage =
    div
        [ class "background top" ]
        [ h1 [] [ text "宮沢賢治" ]
        , link (Clicked "http://localhost:1234/polano-square") [ href "" ] [ text "ポラーノの広場" ]
        , link (Clicked "http://localhost:1234/milky-train") [ href "" ] [ text "銀河鉄道の夜" ]
        , link (Clicked "http://localhost:1234/rain-wind") [ href "" ] [ text "雨ニモマケズ風ニモマケズ" ]
        ]


polanoSquarePage : Page
polanoSquarePage =
    { title = "ポラーノの広場"
    , phrase = "あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。"
    }


milkyTrainPage : Page
milkyTrainPage =
    { title = "銀河鉄道の夜"
    , phrase = "カムパネルラ、また僕たち二人きりになったねえ、どこまでもどこまでも一緒に行こう。僕はもうあのさそりのようにほんとうにみんなの幸さいわいのためならば僕のからだなんか百ぺん灼やいてもかまわない。"
    }


rainWindPage : Page
rainWindPage =
    { title = "雨ニモマケズ風ニモマケズ"
    , phrase = "雨ニモマケズ風ニモマケズ雪ニモ夏ノ暑サニモマケヌ丈夫ナカラダヲモチ慾ハナク決シテ瞋ラズイツモシヅカニワラッテヰル"
    }


view : Model -> Html Msg
view model =
    case model of
        NotFound ->
            div [] [ text "404 not found" ]

        Top ->
            topPage

        Polano ->
            viewPage polanoSquarePage

        MilkyTrain ->
            viewPage milkyTrainPage

        RainWind ->
            viewPage rainWindPage


viewPage : Page -> Html Msg
viewPage page =
    let
        outer =
            page.phrase |> String.repeat 100
    in
    div [ class "background" ]
        [ h1 [] [ text page.title ]
        , h3 [] [ text "宮沢賢治" ]
        , div [] [ text outer ]
        ]


link : msg -> List (Html.Attribute msg) -> List (Html msg) -> Html msg
link msg attrs children =
    a (preventDefaultOn "click" (D.map alwaysPreventDefault (D.succeed msg)) :: attrs) children


alwaysPreventDefault : msg -> ( msg, Bool )
alwaysPreventDefault msg =
    ( msg, True )
```

```js:index.js
require('../css/style.css');
const { Elm } = require('../src/Main.elm');

localStorage.clear();

var app = Elm.Main.init({
  node: document.getElementById('elm'),
  flags: location.href
});

// Inform app of browser navigation (the BACK and FORWARD buttons)
window.onpopstate = () => {
  const currentUrl = localStorage.getItem('nextUrl');
  const offsets = { x : window.pageXOffset, y : window.pageYOffset };
  localStorage.setItem(currentUrl, JSON.stringify(offsets));
  localStorage.setItem('currentUrl', currentUrl);
  localStorage.setItem('nextUrl', location.href);
  console.log(currentUrl, JSON.stringify(offsets));
  app.ports.onUrlChange.send(null);
};

// Change the URL upon request, inform app of the change.
app.ports.pushUrl.subscribe((nextUrl) => {
  const offsets = { x : window.pageXOffset, y : window.pageYOffset };
  const currentUrl = location.href;
  localStorage.setItem(currentUrl, JSON.stringify(offsets));
  localStorage.setItem('currentUrl', currentUrl);
  localStorage.setItem('nextUrl', nextUrl);
  history.pushState({}, '', nextUrl);
  console.log(currentUrl, JSON.stringify(offsets));
  app.ports.onUrlChange.send(null);
});

app.ports.getLocation.subscribe(() => {
  app.ports.gotLocation.send(location.href);
});

app.ports.setOffsets.subscribe( () => {
  requestAnimationFrame( () => {
    const nextUrl = localStorage.getItem('nextUrl');
    const offsets = JSON.parse(localStorage.getItem(nextUrl));
    if(offsets) {
      window.scroll(offsets.x, offsets.y);
    }
  })
});
```

文章は青空文庫より引用させていただいている。

### 次に詳細

```elm
main : Program Flags Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }
```

今回は`Browser.element`を採用している。この場合、React/Angularなど他のSPAライブラリ/フレームワークと共存させることができる。が、ナビゲーションをポート経由で自前実装しないといけない。

少し大変にはなるのだけど、Elmの勉強という点では`Browser.application`に先に慣れてしまうよりは、`Browser.application`は何を便利にしてくれてるのか、を知るという意味では有益だと思う。

Elmの初期化に関してはジンジャーさんの以下の記事に詳しい

[Elm 0.19 の初期化方法 6 種類 @jinjor | Qiita](https://qiita.com/jinjor/items/245959d2da710eda18fa)

```elm
type Model
    = Top
    | Polano
    | MilkyTrain
    | RainWind
    | NotFound
```

`Model`はレコードにするよりカスタムタイプにした方が、状態の網羅がしやすくなる。

```elm
locationHrefToModel : String -> Model
locationHrefToModel here =
    if here == "http://localhost:1234/" then
        Top

    else if here == "http://localhost:1234/polano-square" then
        Polano

    else if here == "http://localhost:1234/milky-train" then
        MilkyTrain

    else if here == "http://localhost:1234/rain-wind" then
        RainWind

    else
        NotFound
```

ここは本来Parserを定義してRouteに変換する、ということを行うことが多いのだけど、何を便利にしてくれているのかを知る、という意味で原始的に文字列の一致で`Model`(ページ)を分けている。

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

        Clicked url ->
            ( model
            , pushUrl url
            )

        UrlChanged _ ->
            ( model
            , getLocation ()
            )

        GotLocation url ->
            ( locationHrefToModel url
            , setOffsets ()
            )

        NoOp ->
            ( model, Cmd.none )
```

Elmの`Update`はJavascriptでいうところのPromiseなどと並ぶ、コールバック地獄を解決する1つのアプローチだと思ってみると理解がしやすくなる。

`Clickされる` -> `Urlが変わる` -> `location.hrefを取得する` -> `offsetsをセットする`という流れがわかる。

```elm
type alias Page =
    { title : String, phrase : String }


polanoSquarePage : Page
polanoSquarePage =
    { title = "ポラーノの広場"
    , phrase = "あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。"
    }


milkyTrainPage : Page
milkyTrainPage =
    { title = "銀河鉄道の夜"
    , phrase = "カムパネルラ、また僕たち二人きりになったねえ、どこまでもどこまでも一緒に行こう。僕はもうあのさそりのようにほんとうにみんなの幸さいわいのためならば僕のからだなんか百ぺん灼やいてもかまわない。"
    }


rainWindPage : Page
rainWindPage =
    { title = "雨ニモマケズ風ニモマケズ"
    , phrase = "雨ニモマケズ風ニモマケズ雪ニモ夏ノ暑サニモマケヌ丈夫ナカラダヲモチ慾ハナク決シテ瞋ラズイツモシヅカニワラッテヰル"
    }


view : Model -> Html Msg
view model =
    case model of
        NotFound ->
            div [] [ text "404 not found" ]

        Top ->
            topPage

        Polano ->
            viewPage polanoSquarePage

        MilkyTrain ->
            viewPage milkyTrainPage

        RainWind ->
            viewPage rainWindPage


viewPage : Page -> Html Msg
viewPage page =
    let
        outer =
            page.phrase |> String.repeat 100
    in
    div [ class "background" ]
        [ h1 [] [ text page.title ]
        , h3 [] [ text "宮沢賢治" ]
        , div [] [ text outer ]
        ]
```

elm-spa-exampleでもそうだけど、一度コンフィグ的なファイル(ここではPage)を作った（挟んだ）上でそれをレイアウト関数(ここではviewPage)に渡すことで`Html Msg`に変換するということをデザインパターンとしてよくやる。

また、Elmではエラーも含めてデータ型として定義するため、case文によりコンパイラによってパターンを網羅することができ、状態の考慮を漏らしづらい堅牢な画面を作ることができる。

```elm
link : msg -> List (Html.Attribute msg) -> List (Html msg) -> Html msg
link msg attrs children =
    a (preventDefaultOn "click" (D.map alwaysPreventDefault (D.succeed msg)) :: attrs) children


alwaysPreventDefault : msg -> ( msg, Bool )
alwaysPreventDefault msg =
    ( msg, True )
```

`Browser.element`を用いてるため、aタグの挙動をキャンセルして自前実装でリプレイスしている。

ここら辺のナビゲーションに関してはエヴァン神の以下の記事に詳しい。

[browser/notes/navigation-in-elements.md](https://github.com/elm/browser/blob/1.0.0/notes/navigation-in-elements.md)

ここからjs

```js
localStorage.clear();
```

新しくアクセスしてきたり、リロードがなされた場合にはローカルストレージをクリアする。
ローカルストレージはスクロール位置の保存などに使っている。

```js
// Inform app of browser navigation (the BACK and FORWARD buttons)
window.onpopstate = () => {
  const currentUrl = localStorage.getItem('nextUrl');
  const offsets = { x : window.pageXOffset, y : window.pageYOffset };
  localStorage.setItem(currentUrl, JSON.stringify(offsets));
  localStorage.setItem('currentUrl', currentUrl);
  localStorage.setItem('nextUrl', location.href);
  console.log(currentUrl, JSON.stringify(offsets));
  app.ports.onUrlChange.send(null);
};
```

この辺りはもろ[先の](https://github.com/elm/browser/blob/1.0.0/notes/navigation-in-elements.md)ドキュメントを参考にしている。
onpopstateが発火するのは進む/戻るのみで、進む/戻るが実行された際のoffsets(スクロール位置)の保存もここで行なっている。

```js
// Change the URL upon request, inform app of the change.
app.ports.pushUrl.subscribe((nextUrl) => {
  const offsets = { x : window.pageXOffset, y : window.pageYOffset };
  const currentUrl = location.href;
  localStorage.setItem(currentUrl, JSON.stringify(offsets));
  localStorage.setItem('currentUrl', currentUrl);
  localStorage.setItem('nextUrl', nextUrl);
  history.pushState({}, '', nextUrl);
  console.log(currentUrl, JSON.stringify(offsets));
  app.ports.onUrlChange.send(null);
});
```

こっちはリンクがクリックされた際の挙動。同じくoffsets(スクロール位置)の保存を行なっている。

```js
app.ports.setOffsets.subscribe( () => {
  requestAnimationFrame( () => {
    const nextUrl = localStorage.getItem('nextUrl');
    const offsets = JSON.parse(localStorage.getItem(nextUrl));
    if(offsets) {
      window.scroll(offsets.x, offsets.y);
    }
  })
});
```

ElmのDomの再描画は`requestAnimationFrame`のキューに積まれるため、モデルの更新後の再描画の後に行わせたいjsの処理がある場合は、`Update`にて

```elm
xxxxMsg ->
    ( モデルの更新, requestAnimationFrameをコールするport)
```

と記述することになる。requestAnimationFrameは再描画のコールバックだと捉えると理解がしやすいかもしれない。
今回は再描画が終わったタイミングで保存しておいたスクロールの位置を復元している。

Elmでは`getViewport`や`setViewport`といった`Task`型でスクロール位置にアクセスできるものもあるが、`Browser.element`を用いる場合にはナビゲーションをportで自作することになるためそのタイミングでjsでやった方が楽だと感じる。

が、これに関しても`Browser.application`などを使うのであれば`Task`型でやろうと考えている。

## 終わりに

これだけでelm-spa-exampleを読み解き、数万行単位の大規模なSPAを作る、というのには不十分であることは重々承知ではあるが、初学者が中級者へステップアップするにあたって少しでも力添えになれたのだとしたら、作者冥利につきるのではないだろうか。

以上。