17
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ElmAdvent Calendar 2019

Day 19

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

Last updated at Posted at 2019-12-19

この記事はElmアドベントカレンダー19日目の記事です。

Elmを完全に理解する

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

何を作ったのか

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

elm-spa-trivial-demo.gif

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

セットアップ
$ git clone git@github.com:lilpacy/elm-spa-trivial.git
$ cd elm-spa-trivial
$ npm run setup
開発サーバー起動
$ npm run dev

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

ElmSPAの初歩 @dyoshikawa | Qiita

なぜ作ったのか

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

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

コードの解説

まずは全文

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 )
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);
    }
  })
});

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

次に詳細

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

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

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

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(ページ)を分けている。

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をセットするという流れがわかる。

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文によりコンパイラによってパターンを網羅することができ、状態の考慮を漏らしづらい堅牢な画面を作ることができる。

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

ここからjs

localStorage.clear();

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

// 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);
};

この辺りはもろ先のドキュメントを参考にしている。
onpopstateが発火するのは進む/戻るのみで、進む/戻るが実行された際のoffsets(スクロール位置)の保存もここで行なっている。

// 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(スクロール位置)の保存を行なっている。

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にて

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

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

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

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

終わりに

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

以上。

17
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?