この記事はElmアドベントカレンダー19日目の記事です。
Elmを完全に理解する
例によって言葉の定義を再確認しておく。
エンジニア用語の「完全に理解した」「何も分からない」「チョットデキル」は「ダニング・クルーガー効果」で簡単に説明ができます。これは一種の認知バイアスで能力の低い段階では自分の能力の低さを認識できないためです(過大評価しがち)。その反面で能力が高くなると過少評価しがちです。 pic.twitter.com/LGaJ4E5hWo
— おちゃめ (@ochame_nako) April 8, 2019
何を作ったのか
Richard Feldman (@rtfeldman)のelm-spa-exampleへの橋渡しになればと思い粗雑かつ些細なSPAということでelm-spa-trivialというレポジトリを作った。
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でも似たような記事もあったりするようなので割とそこに課題感はあるのだと感じた。
なぜ作ったのか
Elmを業務で触り始めて半年が経った。4月に新卒入社をして先輩社員に言われるのがelm-spa-exampleが参考になるということだった。
けれど、チュートリアルとelm-spa-exampleがあまりに飛躍していて理解するのにかなり時間と労力を要したため、半年前の自分と同じ立場にいるような人に向けて少しでもelm-spa-exampleのような規模の大きめなSPAを作るに至るまでの橋渡しになればと思いレポジトリとして公開をした。
コードの解説
まずは全文
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 )
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ではgetViewport
やsetViewport
といったTask
型でスクロール位置にアクセスできるものもあるが、Browser.element
を用いる場合にはナビゲーションをportで自作することになるためそのタイミングでjsでやった方が楽だと感じる。
が、これに関してもBrowser.application
などを使うのであればTask
型でやろうと考えている。
終わりに
これだけでelm-spa-exampleを読み解き、数万行単位の大規模なSPAを作る、というのには不十分であることは重々承知ではあるが、初学者が中級者へステップアップするにあたって少しでも力添えになれたのだとしたら、作者冥利につきるのではないだろうか。
以上。