ElmのナビゲーションをBrowser.Elementで実装してみる

2020年01月21日 Yasu

Elm_logo.png

はじめに

現在作っている社内向けの業務アプリには、共通のヘッダやメニューを持つ静的な親となるページがあります。その子ページとして、複数のシングルページアプリケーション(SPA)を切り替えて動かすという実装を試みています。すべての機能を一つのSPAにまとめようとすると、開発やメンテナンスも大変なので、少しずつ作っていける手法としてどうかなと思って採用してみました。いうなれば、マルチページシングルページアプリケーション(!?)となります。

multipage-spa.png

ElmでSPAを開発する際にはプログラムの入り口なる関数としてBrowser.applicationを使います。けっこう複雑な仕組みを伴うので、elm-spa-exampleという実装例を参考にしてゴリゴリ書く場合が多いです。個人的にはよりシンプルなElmのパッケージサイトのソースを参考にするケースが多いです。また、最近ではelm-spaというツールが流行っていたりまだ使ったことはありません)、さらにうさぎさんが作られたalchelmyというツールもあります(まだ使ったことはありません…)。

いずれもBrowser.Navigationというモジュールと合わせて使います。基本的な説明やパターンは公式ガイドのナビゲーションの節に記載があります。普通にSPAを作る場合にはこのままで問題ないのですが、先のマルチページシングルページアプリケーションを実装しようとすると、ナビゲーションの部分で問題が出てきました。具体的にはBrowser.application<body>タグ以下をすべて乗っ取ってしまうため、共通項目としてベースとなるHTMLに書いたヘッダーやメニューが表示されなくなってしまいます。いろいろと調べていると、Browser.elementを使って任意のノードのSPAをマウントして、ナビゲーション自体はportを使ってJavascriptサイドで行う方法の解説、How do I manage URL from a Browser.element?がありました。ブラウザの拡張プラグインやアナリティクスのコードとバッティングするときもこの方法を使うと良いとう議論も多くみかけます。

この解説に詳しく説明は書いてあるのですが、実際の実装例が見つけられず、自分の理解不足を補うためにも実装してみて気づいた点をここにまとめておきます。

環境

  • Elm 0.19.1

公式ガイドのサンプル

先にあげた公式ガイドのサンプルが下記のコードになります。

module Main exposing (..)

import Browser
import Browser.Navigation as Nav
import Html exposing (..)
import Html.Attributes exposing (..)
import Url



-- MAIN


main : Program () Model Msg
main =
  Browser.application
    { init = init
    , view = view
    , update = update
    , subscriptions = subscriptions
    , onUrlChange = UrlChanged
    , onUrlRequest = LinkClicked
    }



-- MODEL


type alias Model =
  { key : Nav.Key
  , url : Url.Url
  }


init : () -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
init flags url key =
  ( Model key url, Cmd.none )



-- UPDATE


type Msg
  = LinkClicked Browser.UrlRequest
  | UrlChanged Url.Url


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    LinkClicked urlRequest ->
      case urlRequest of
        Browser.Internal url ->
          ( model, Nav.pushUrl model.key (Url.toString url) )

        Browser.External href ->
          ( model, Nav.load href )

    UrlChanged url ->
      ( { model | url = url }
      , Cmd.none
      )



-- SUBSCRIPTIONS


subscriptions : Model -> Sub Msg
subscriptions _ =
  Sub.none



-- VIEW


view : Model -> Browser.Document Msg
view model =
  { title = "URL Interceptor"
  , body =
      [ text "The current URL is: "
      , b [] [ text (Url.toString model.url) ]
      , ul []
          [ viewLink "/home"
          , viewLink "/profile"
          , viewLink "/reviews/the-century-of-the-self"
          , viewLink "/reviews/public-opinion"
          , viewLink "/reviews/shah-of-shahs"
          ]
      ]
  }


viewLink : String -> Html msg
viewLink path =
  li [] [ a [ href path ] [ text path ] ]

サンプルコードの内容に関しましては、日本語に翻訳されたガイドに詳しい説明がありますのでそちらを参照してください。

Browser.elementを使ってナビゲーションをする

先の解説を参考に各パート毎に書き換えていきます。

導入部

port module Main exposing (..)

import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Json.Decode as D
import Url

portを使用するための宣言をモジュールの先頭に加えます。また、Browser.Navigationは不要となるので削除します。また後ほど定義する関数で使用するパッケージJson.DecodeUrlおよびHtml.Eventsを追加します。

MAIN

-- MAIN


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

プログラムの入り口をBrowser.elementに変更し、引数となるレコードから不要なonUrlChangeonUrlRequestを削除します。Flagsとして受け取る値の型をStringに変更します。

MODEL

-- MODEL


type alias Model =
    { url : Maybe Url.Url
    }


init : String -> ( Model, Cmd Msg )
init locationHref =
    ( Model (locationHrefToRoute locationHref), Cmd.none )

初期のURLを文字列として受け取ります。FlagsではUrl.Urlとしては受け取ることはできません。また、Browser.Navigation.KeyBrowser.application以外では生成されないため不要となります。さらに後ほど説明しますが、Modelに保持するUrl.UrlMaybeでラップします。

UPDATE

-- UPDATE


type Msg
    = LinkClicked String
    | UrlChanged (Maybe Url.Url)


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        LinkClicked url ->
            ( model, pushUrl url )

        UrlChanged url ->
            ( { model | url = url }
            , Cmd.none
            )

LinkClickedは後ほど定義する、aタグの代わりとなるSPA内部向けのリンクを処理するlink関数で呼ばれます。これまで、Browser.Internalで行っていた処理を、portを通してJavascript側でブラウザに対してURLの変更を指示します。

今回はUrlChangedでは受け取ったurlModelの値を更新しているだけですが、本来はこのurlUrl.Parserでパースして様々なページに振り分ける処理が必要となります。

SUBSCRIPTION

-- SUBSCRIPTIONS


subscriptions : Model -> Sub Msg
subscriptions _ =
    onUrlChange (locationHrefToRoute >> UrlChanged)

後ほど定義するport関数onUrlChangeが呼ばれるたびに、このsubscriptionが実行されます。その結果として先程のメッセージUrlChngedが発行されます。

VIEW

-- VIEW


view : Model -> Html Msg
view model =
    div []
        [ text "The current URL is: "
        , b [] [ text (Maybe.map Url.toString model.url |> Maybe.withDefault "NOT FOUND") ]
        , ul []
            [ viewLink "/home"
            , viewLink "/profile"
            , viewLink "/reviews/the-century-of-the-self"
            , viewLink "/reviews/public-opinion"
            , viewLink "/reviews/shah-of-shahs"
            ]
        ]


viewLink : String -> Html Msg
viewLink path =
    li [] [ link (LinkClicked path) [ href path ] [ text path ] ]

モデルのurlMaybe Url.Urlに変更されたので、それに伴う処理を追加しています。link関数は後ほど定義されますが、内部リンクを必要する際にaタグの代わりに使用します。クリックされるとLinkClickedメッセージを発行し、port関数を経由してJavascript側へURLの変更を通知します。ここでhrefをつけないと、リンクとしてのCSSが働きませんので注意が必要です。

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


port pushUrl : String -> Cmd msg


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


locationHrefToRoute : String -> Maybe Url.Url
locationHrefToRoute locationHref =
    Url.fromString locationHref

今回の変更で追加される処理になります。port経由でブラウザのURLに変更があるとJavascript側から通知を受け取るonUrlChange関数と、Javascript側へURLの変更を通知するpushUrl関数が定義されています。link関数は先程の説明の通り、内部向けのリンクのためのaタグの代わりに使う関数です。

最後のlocationHrefToRoute関数ではJavascript側から受け取った文字列としてのURLを、Url.fromString関数を使ってUrl.Urlに変換しますが、URLとして不正な文字列を受け取った場合にNothingを返します。サーバーが不正なURLでのアクセスに対してこのSPAを呼び出していることになるため、これはサーバー側の問題であり本来はありえないはずなので、404なページなどに誘導することになります。

Javascript側

<html>
<head>
  <style>
    /* you can style your program here */
  </style>
</head>
<body>
  <main></main>
  <script>
    
    var app = Elm.Main.init({
        flags: location.href,
        node: document.querySelector('main') 
    });

    // you can use ports and stuff here

    // Inform app of browser navigation (the BACK and FORWARD buttons)
    window.addEventListener('popstate', function () {
        app.ports.onUrlChange.send(location.href);
    });

    // Change the URL upon request, inform app of the change.
    app.ports.pushUrl.subscribe(function(url) {
        history.pushState({}, '', url);
        app.ports.onUrlChange.send(location.href);
    });    
  </script>
</body>
</html>

Javascript側のコードは解説のとおりとなります。elm reactorなどで開くと、Browser.applicationと同じように動作するはずです。サーバーが必要となるためellieでは動きませんのでご注意ください。

全てまとめめたコードをこちらのgistに置いておきます。

終わりに

ちょっとの変更でBrowser.elementでも問題なくナビゲーションができ、マルチページシングルページアプリケーションを構成できることがわかりました。複数のElmアプリケーションを同じページに置くこともできますが、URLの管理に間違いがあると意図しない動作になる可能性があります。

  • 内部リンクを呼び出す場合は全てのページで、LinkClickedメッセージと対応するコードをupdate関数に用意し、port pushUrlを呼び出す必要があります。
  • linkport pushUrlは複数のモジュールから参照する可能性があるので、別のモジュールに分けておくとよいです。
Tags: Elm
相場のお問い合わせはお電話・メール・LINEにてお気軽にどうぞ!
 tel:0120-41-1578
 email:info@officeiko.co.jp
 LINE ID:@oue6072d