🎉

Elmの最先端 elm-pages(v3) を触れてみている

2022/12/11に公開

いわゆるJamstack+ちょっとしたSPA的なものを触る必要があり、Elmの書き味でいけるならいってみたいなあと思い、elm-pages v3でかなり展望が開ける感じだったので、今の感動を書き留めてみます。

ちょうどよく時期なので、Elm Advent Calendar 2022の11日目の記事とさせてください。

elm-pages v3は、現時点でβ版であり 私自身も完全にキャッチアップはできていない状態のため、誤りや今後破壊的変更も十分にあり得ることをご注意ください。

elm-pages v3は、とても最先端のつくりをしており、不安もある一方で、とっても便利で刺激的なものになっているので、たくさん触ってフィードバックする人が増えると僕や他の使っている人も作者のdillonkearnsさんもきっと喜ぶ上 最高の開発体験が得られると思うので、開発者人口を増えることを願って、この記事を執筆しています。

Jamstackとは

elm-pagesのつくりを抑えるためには、Jamstackの知識が必要不可欠のため、ごくごく簡単に触れておこうと思います。

あるメディアサイトを立ち上げようとします。その場合、任意のCMS(Content Management System)やDBによって管理された記事の集まりから、記事の一覧を取得したり、記事を選んだのち、記事の詳細を取得する必要があります。

SPA的な思考では、ページにアクセスされた時点でCMSのAPIを叩き、一覧や記事の詳細を動的に取得してページを構成しますが、このやり方にはいくつか欠点が存在します。

  • ローディングが発生し、パフォーマンスが劣化する
  • クローラが記事の内容を正しく取得できない
    • OGPが表示されない
  • CMSとサービスとの中間にサーバなどがある場合、
    • 管理コストが高まる
    • セキュリティリスクが高まる
  • サービス構築の複雑性が上がり
    • メンテナンスコストが高まる
    • 柔軟性が低くなる

Jamstack的な思考では、ビルド時にCMSのAPIを叩き、コンテンツを事前に収集し、それをもとに静的コンテンツを生成しデプロイ・配信することで上記に挙げた欠点を大きく改善するというものです。そういった成果物を生成するために使うのが静的サイトジェネレータ elm-pagesのようなフレームワークです。似たようなフレームワークとして、hugo, Next.js, Gatsbyなどが存在します。私の感覚で言うと、一番近しいのはGatsbyのような気がします。

elm-pagesの基礎

この記事の解説は、elm-pages-3-alpha-starter
Public
を元に解説をしていきます。大枠の哲学は、elm-pages v2のドキュメントで補えますが、違いもところどころあるのでご注意ください。

リポジトリを見ると、さまざまなディレクトリ・ファイルから構成されていますが、実際にelm-pages v3がリリースされる時点では、この全てのファイル群が公開されるわけではないような気がします。また、これらを全て把握しようとすると、なかなか難しいのです(私も理解していません)。そのため、主に、appディレクトリ配下の書き方の紹介とさせていただきます。

ファイルベースルーティング

elm-pages v3では、app/Route配下、のファイルによってルーティングが行われています。

イメージは、以下のドキュメントと同じですが、ディレクトリ構成が変化しているのでご注意ください。
file-based-routing

リポジトリのRoute配下を抜き出すと以下のような形になります。

app/Route
├── Blog
│   └── Slug_.elm
├── Greet.elm
└── Index.elm

これらをルーティングに当てはめると、以下のようになります。(クエリはファイルの中身を覗かないとわかりませんが) :name:slug は、任意の値になります。

ファイル ルーティング クエリ
Index.elm / -
Greet /greet ?name=:name
Blog/Slug_.elm /blog/:sulg -

Route以下のファイルの主な構成

それでは、ファイルの中身について具体的に見ていきましょう。一番シンプルなファイルが、Index.elmになるので、まずはこれをベースとしてみていきましょう。

Routeファイルのmainと呼ぶべき関数は、route関数になります。この関数が読めれば、elm-pagesの基本は掌握できたといっても過言ではありません。

RouteBuilder.singleは、Index.elmのように特に動的なセグメント(blog/:slugのslugの部分)がない、完全に静的なルーティングの場合に採用されるものです。この関数に渡されるのは、data, headになります。

route : StatelessRoute RouteParams Data ActionData
route =
    RouteBuilder.single
        { data = data
	, head = head
        }
        |> RouteBuilder.buildNoState { view = view }

Jamstackは、静的にHTMLを生成します。そのためのデータはまず用意するよ、と言うのが基本になります。BackendTask error value型は、そんなデータを抽象化した型になります。BackendTask、以下のモジュールによって構成されます。データをファイルから用意したり、Httpから用意したり、環境変数から用意したり、Port(JavaScript)から用意したりすることができるようです。それを繋げるのが、BackendTask APIでここバラバラにあるわけではなく、これらを合成したりすることができるわけです。これは、コアElmで言う、Taskのような感覚で使えるものです。

  • BackendTask
  • BackendTask.Custom
  • BackendTask.Env
  • BackendTask.File
  • BackendTask.Glob
  • BackendTask.Http
  • BackendTask.Random
  • BackendTask.Time

これは、Index.elmのdataですが、言語化すると、文字列 "Hello!"をDataレコードにliftしているといった感じでしょうか。

type alias Data =
    { message : String
    }
    

data : BackendTask FatalError Data
data =
    BackendTask.succeed Data
        |> BackendTask.andMap
            (BackendTask.succeed "Hello!")

dataが用意されれば、あとはheadやviewを構築できます。

head関数は、HTMLのheadタグにメタ情報を埋め込み、OGPやSEO対策ができるようになるものです。以下の例では、dataは使っていませんが、もし使う場合には、appという変数からアクセスすることが可能です。

head :
    StaticPayload Data ActionData RouteParams
    -> List Head.Tag
head app =
    Seo.summary
        { canonicalUrlOverride = Nothing
        , siteName = "elm-pages"
        , image =
            { url = [ "images", "icon-png.png" ] |> Path.join |> Pages.Url.fromPath
            , alt = "elm-pages logo"
            , dimensions = Nothing
            , mimeType = Nothing
            }
        , description = "Welcome to elm-pages!"
        , locale = Nothing
        , title = "elm-pages is running"
        }
        |> Seo.website

app(もしくは、staticと書かれることがある)変数は、StaticPayloadという型ですが、実態は以下のようなレコードです。app.data.messageとアクセスすることで、先ほどの"Hello!"にアクセスすることができます。そのほかにも、Shared情報、ルーティング情報やフォーム情報にアクセスできるようですが、ここらに関する知識はありません・・・.(何か分かりましたら、教えてください。)

type alias StaticPayload data action routeParams =
    { data : data
    , sharedData : Shared.Data
    , routeParams : routeParams
    , path : Path
    , action : Maybe action
    , submit :
        { fields : List ( String, String ), headers : List ( String, String ) }
        -> Pages.Fetcher.Fetcher (Result Http.Error action)
    , transition : Maybe Pages.Transition.Transition
    , fetchers : Dict String (Pages.Transition.FetcherState (Maybe action))
    , pageFormState : Pages.FormState.PageFormState
    }

view関数では、実際にStaticPayloadDataにアクセスしているコードを見ることができます。

view :
    Maybe PageUrl
    -> Shared.Model
    -> StaticPayload Data ActionData RouteParams
    -> View (Pages.Msg.Msg Msg)
view maybeUrl sharedModel app =
    { title = "elm-pages is running"
    , body =
        [ Html.h1 [] [ Html.text "elm-pages is up and running!" ]
        , Html.p []
            [ Html.text <| "The message is: " ++ app.data.message
            ]
        , Route.Blog__Slug_ { slug = "hello" }
            |> Route.link [] [ Html.text "My blog post" ]
        ]
    }

一見すると、ElmのSPAのように、Modelにアクセスしている?と勘違いしてしまいそうなので、elm-pages buildをして、distディレクトリを見てみましょう。これは、index.htmlに直接書き込まれているデータなのです。elm-pagesのドキュメントを借りると、ローディング(スピナーが回ること)無しに、取得できるデータになります。

クエリとサーバサイドレンダリング

ベースを押さえたところで、Greet.elmを見てみましょう。

http://localhost:1234/greet?name=John サーバを起動したら、こちらにアクセスしてみましょう。nameのクエリパラメータがある時だけ、現在の時刻を取得して表示をします。現在の時刻というのは動的な取得です。しかし、デバッガを開くと分かりますが、クライアントサイドでのAPIコールではありません。つまり、SSR(サーバサイドレンダリング)を行なっています!

route関数を見てみると、singleからRouteBuilder.serverRenderに変わっています。この記事では掘り下げませんが、actionはFormをサーバサイドで扱うための仕組みでelm-pagesをフルスタックWebフレームワークとして引き上げているものになります。

route : StatelessRoute RouteParams Data ActionData
route =
    RouteBuilder.serverRender
        { head = head
        , data = data
        , action = \_ -> Request.succeed (BackendTask.fail (FatalError.fromString "No action."))
        }
        |> RouteBuilder.buildNoState { view = view }

data関数を除いてみましょう。何やらすごい型に変貌を遂げていますが、要するに、RouteParamsを見て(今回は代わりにクエリを参照している模様)、サーバサイドで何かを処理する型になっているわけです。外部APIを叩くということは失敗可能性も秘めています。エラーをそのままThrowするために、BackendTask.allowFatalを利用してFatalErrorに変換します。

具体的に追うと、エラーの場合は、エラーページへ。nameパラメータがない場合には、DataのnameはMaybe Stringになっているため、それで分岐をするようです。JsonのDecode処理を書くあたりは、普段のコードと変わらないので書きやすいでしょう。

data : RouteParams -> Request.Parser (BackendTask FatalError (Response Data ErrorPage))
data routeParams =
    Request.oneOf
        [ Request.expectQueryParam "name"
            |> Request.map
                (\name ->
                    BackendTask.Http.getJson "http://worldtimeapi.org/api/timezone/America/Los_Angeles"
                        (Decode.field "utc_datetime" Decode.string)
                        |> BackendTask.allowFatal
                        |> BackendTask.map
                            (\dateTimeString ->
                                Response.render
                                    { name = Just dateTimeString }
                            )
                )
        , Request.succeed
            (BackendTask.succeed
                (Response.render
                    { name = Nothing }
                )
            )
        ]

viewは特段Idex.elmと変わりはありません。

動的セグメント

最後は、Blog/Slug.elmです。例によって、route関数を見てみましょう。RouteBuilder.preRenderとなっています。headとdataは今までと変わりありません。pagesというものが増えています。

route : StatelessRoute RouteParams Data ActionData
route =
    RouteBuilder.preRender
        { head = head
        , pages = pages
        , data = data
        }
        |> RouteBuilder.buildNoState { view = view }

このpagesを確認するには、動的セグメントを抑えておく必要があります。

このファイルのルーティングは、/blog/:slug のようになるとお伝えしましたが、slugの部分はどんなものでも入るわけではありません。SSRなどをしていない場合(preRender)には、ビルド時に決定していなければなりません。つまり、/blog/hello-elm, /blog/subscriptions のように具体的にパスが決定していなければなりません。それが、pagesです。pagesはdata同様にBackendTask FatalError型になっています。違う点としては、List RouteParams型を内包しています。この例ではベタ書きで、"hello"となっているため、/blog/helloのみアクセスが可能ということを示しています。

pages : BackendTask FatalError (List RouteParams)
pages =
    BackendTask.succeed
        [ { slug = "hello" }
        ]

少し応用編

入門編を超えて、すこーしだけ実用な内容を紹介します。本当はリポジトリ丸ごとお渡ししたいのですが、セキュリティ周りの対策が終えていないので、準備が整いましたらソースコードを公開します。

記事一覧

記事の一覧が並んでいるページを想像してみましょう。1記事で申し訳ありませんが、以下のような感じです。

Dataとして、blogList(List Blog)持っており、DataSoureでAPIを叩いてその一覧を取得している様子です。

type alias Data =
    { blogList : List Blog
    }

data : BackendTask FatalError Data
data =
    BackendTask.succeed Data
        |> BackendTask.andMap
            getBlogListRequest


getBlogListRequest : BackendTask FatalError (List Blog)
getBlogListRequest =
    BackendTask.Http.request
        { url = "https://ababupdownba.microcms.io/api/v1/blogs"
        , method = "GET"
        , headers =
            [ ( "X-MICROCMS-API-KEY", microCMStoken )
            ]
        , body = BackendTask.Http.emptyBody
        }
        (BackendTask.Http.expectJson blogListDecoder)
	|> BackendTask.allowFatal
	

type alias Blog =
    { id : String
    , title : String
    , description: String
    , content: String
    }


blogListDecoder : Decoder (List Blog)
blogListDecoder =
    JD.field "contents" <|
        JD.list blogDecoder


blogDecoder : Decoder Blog
blogDecoder =
    JD.map4 Blog
        (JD.field "id" JD.string)
        (JD.field "title" JD.string)
        (JD.field "description" JD.string)
        (JD.field "content" JD.string)

view関数はこちら。ページのリンクの書き方が少し変わっているのでご注意ください。

view :
    Maybe PageUrl
    -> Shared.Model
    -> StaticPayload Data ActionData RouteParams
    -> View (Pages.Msg.Msg Msg)
view maybeUrl sharedModel app =
    { title = "elm-pages is running"
    , body =
        [ Html.h1 [] [ Html.text "Sample Blog System!" ]
        , Html.ul [] <|
            List.map
                (\blog ->
                    Html.li []
                        [ Route.Blog__Slug_ { slug = blog.id }
                            |> Route.link [] [ Html.text blog.title ]
                        ]
                )
                app.data.blogList
        ]
    }

記事詳細

こちらは、ルーティングとしては先ほどと変わらず /blog/:slugです。

pagesに着目しましょう。先ほどは、slug = "hello"と固定値でしたが、実際にはCMSにアクセスして、その記事の一覧をslugとしたいはずです。DataSouceモジュールのAPIを駆使して、slugのListを用意しましょう。

route : StatelessRoute RouteParams Data ActionData
route =
    RouteBuilder.preRender
        { head = head
        , pages = pages
        , data = data
        }
        |> RouteBuilder.buildNoState { view = view }


pages : BackendTask FatalError (List RouteParams)
pages =
    BackendTask.map
        (\blogList ->
            List.map
                (\blog ->
                    { slug = blog.id }
                )
                blogList
        )
        getBlogListRequest

dataはRequestParams利用して、CMSの記事詳細APIをコールして、内容を取得しましょう。

data : RouteParams -> BackendTask FatalError Data
data routeParams =
    BackendTask.map
        (\blog ->
            { description = blog.description
            , content = blog.content
            }
        )
        (getBlogRequest routeParams.slug)
	
	
getBlogRequest : String -> BackendTask FatalError Blog
getBlogRequest slug =
    BackendTask.Http.request
        { url = "https://ababupdownba.microcms.io/api/v1/blogs/" ++ slug
        , method = "GET"
        , headers =
            [ ( "X-MICROCMS-API-KEY", microCMStoken)
            ]
        , body = BackendTask.Http.emptyBody
        }
        (BackendTask.Http.expectJson blogDecoder)
	|> BackendTask.allowFatal

viewでは、HTMLパーサ等を利用して、Html msgにしてあげましょう。

 case Html.Parser.run static.data.content of
                    Ok nodes ->
                        Html.Parser.Util.toVirtualDom nodes

Sharedとports

需要があるか分かりませんが、ログインしたら記事の続きが読める などの対応です。firebaseを利用しています。コードの一部を抜粋。

各ページ共通処理を書く場合は、Shared.elmを利用します。

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


port signIn : () -> Cmd msg


port signOut : () -> Cmd msg


update : Msg -> Model -> ( Model, Effect Msg )
update msg model =
    case msg of
        SharedMsg globalMsg ->
            ( model, Effect.none )

        MenuClicked ->
            ( { model | showMenu = not model.showMenu }, Effect.none )

        UpdateLoginStatus status ->
            ( { model | isLogin = status == "signIn" }, Effect.none )

        SignIn ->
            ( model, Effect.Cmd <| signIn () )

        SignOut ->
            ( model, Effect.Cmd <| signOut () )


subscriptions : Path -> Model -> Sub Msg
subscriptions _ _ =
    updateLoginStatus UpdateLoginStatus
    
    
    Data
    ->
        { path : Path
        , route : Maybe Route
        }
    -> Model
    -> (Msg -> msg)
    -> View msg
    -> { body : List (Html msg), title : String }
view sharedData page model toMsg pageView =
    { body =
        [ Route.Index
            |> Route.link [] [ Html.text "Top" ]
        , Html.div []
            [ if model.isLogin then
                Html.div []
                    [ Html.button [ onClick <| toMsg SignOut ] [ Html.text "SignOut" ]
                    ]

              else
                Html.button [ onClick <| toMsg SignIn ] [ Html.text "SignIn" ]
            ]
        , Html.main_ [] pageView.body
        ]
    , title = pageView.title
    }

index.tsでは、通常のElmと同じくportsが書けます。

const config: ElmPagesInit = {
  load: async function (elmLoaded) {
    const app = await elmLoaded as any;
    console.log("App loaded", app);

    onAuthStateChanged(auth, (user) => {
      if (user != null) {
        console.log("onAuthStateChanged: user is not null");
        app.ports.updateLoginStatus.send("signIn");
      } else {
        console.log("onAuthStateChanged: user is null");
      }
    });

    app.ports.signIn.subscribe(async () => {
      const provider = new GoogleAuthProvider()
      await signInWithRedirect(auth, provider)
    });

    app.ports.signOut.subscribe(async () => {
      const provider = new GoogleAuthProvider()
      await signOut(auth);
      app.ports.updateLoginStatus.send("signOut");
    });
  },
  flags: function () {
    return "You can decode this in Shared.elm using Json.Decode.string!";
  },
};

export default config;

elm-pages v3で変わったこと

全てを書くと膨大になってしまうので、簡単にお伝えすると、bundlerにviteが採用されています。その影響で、index.jsindex.tsになっています。また、v2では、(おそらく)package.jsonから取り入れた外部ライブラリを利用することができませんでしたが、viteのおかげで利用できるようになっています。cssやscssなども利用可能です。また、クエリとサーバサイドレンダリングでお伝えしているようにSSRが可能になっています。(こちらもおそらくv2では不可能だったはず・・・)

あとは、ディレクトリ構成なども変わって、より直感的に開発ができるようになっています。

v2では、NextなどのJamstackフレームワークに一歩引けを取っている感じでしたが、v3でグッと差を詰めたように感じます。

まとめ

ElmはSPAを書く手段としては、実用レベルが高い言語・フレームワークと思っていましたが、いまいちSEO等が絡んだ案件には弱い状況でした。ですが、それらを補うツールが生まれ始め、成長し、実用化され採用に至るレベルまでになっていると強く感じます。

これは単に足並みが揃ったというわけではありません。もともとものElmのシンプルな考えによる、メンテナンス性能やビルド速度などを保ったまま、高度なアプリケーション開発ができるようになったというわけです。

ただ、それでもまだまだドキュメントや安定性が足りないツール群です。是非多くの開発者が支え、安心して使えるような世界に持っていくべきではないでしょうか? 私は、こういった記事や成果物を通じてよりよい開発体験が得られるように努力してまいります。皆さんもぜひお力添えをお願いいたします!

Discussion