概要
本記事では、サーバーサイドの情報を Elm に渡す手法として、HTMLにJSONを埋め込む手法を紹介し、JSON Web API 経由で情報を渡す場合との比較を行います。
ぐぐってみるとこの手法自体は Elm 以外の文脈でもずいぶん昔から使われているようですが、なぜか「俺はこうやって実現したぜ」系の記事ばかりで、「なぜそれをやるの?」とか「既存手法と比べてどういうメリットやデメリットがあるの?」といったことにまともに言及している記事がほとんど見つからなくて、「マジ世の中ちょろいな」って感じなので、僕が勝手にまとめます。
問題提起
Elm で書かれたプログラムがサーバーサイドの情報を取得する際、多くの場合は JSON 形式の Web API を呼ぶことになると思います。
ただそのやり方だと、特にページ読み込み時の初期情報の取得において下記のデメリットがあります。
- HTML をリクエストして、JSコードを読み込んで、その後またサーバーに Web API でアクセスするのってなんか無駄っぽくね?
- Elm 側で読み込み時にリクエスト飛ばすのめんどくさくね?
- Elm で JSON デコードするのめんどくさくね?
(あくまでも問題提起であって、あとで「まぁ必ずしも悪ではないよね」って補足するので許してください)
HTMLへのJSON埋め込み
そこで、上記の課題を解決できるのが「HTMLへのJSON埋め込み」という今回の手法です。
この手法のアイディアはいたって単純で、HTMLファイルのリクエストがあった際に、サーバーサイドがHTML内に JSON エンコードした情報を埋め込み、Flags を使って Elm にその情報を渡すだけです。
以下がサーバーサイドが返すHTMLのサンプルです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta content="IE=edge" http-equiv="X-UA-Compatible">
...
</head>
<body id="body">
<script type="application/json" id="json-data">
{"data":{"age":23,"posts":[{"title":"タイトル","body":"本文"},{"title":"タイトル2","body":"本文2"}]},"error":null}
</script>
<script type="text/javascript" src="/static/index.js?2228072e39a34fbd7660"></script>
</body>
</html>
type="application/json"
の script
タグに、JSONエンコード後HTMLエスケープされたデータがサーバーサイドによって埋め込まれています。
HTMLエスケープをしない方が主流っぽいですが、いろいろ罠が多いようなので僕は安全のためHTMLエスケープしてしまっています。
HTMLエスケープされる前のデータをわかりやすいように整形したものが下記の内容です。
{ "data":
{ "age": 23
, "posts":
[ { "title": "タイトル"
, "body": "本文"
}
, { "title": "タイトル2"
, "body": "本文2"
}
]
}
, "error": null
}
このデータを実際に読み取っているのが、その後読み込まれている /static/index.js
です。
"use strict";
var json = JSON.parse(
document.getElementById('json-data').textContent
) || {};
var Elm = require('./Main');
Elm.Main.embed(
document.getElementById('body'),
{ loadedTime: Date.now()
, onLoad: json || null
}
);
あとは、Elm 側で下記の用に対応した方を定義して、自動的にデコードされたデータを programWithFlags
で init
に渡してやればOKです。
module Main exposing (..)
import Html
main : Program Flags Model Msg
main =
Html.programWithFlags
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
type alias Model =
{ loadedTime : Time
, data : Maybe Data
, error : Maybe Error
, ...
}
type alias Flags =
{ loadedTime : Time
, onLoad :
{ data : Maybe Data
, error : Maybe Error
}
}
type alias Data =
{ age : Int
, posts : List
{ title : Maybe String
, body : String
}
}
init : Flags -> ( Model, Cmd Msg )
init flags =
( { loadedTime = flags.loadedTime
, data = flags.data
, error = flags.error
, ...
}
, Cmd.none
)
ということで、さくっと前述の問題(下記に再掲)を解決できました。
- HTML をリクエストして、JSコードを読み込んで、その後またサーバーに Web API でアクセスするのってなんか無駄っぽくね?
- Elm 側で読み込み時にリクエスト飛ばすのめんどくさくね?
- Elm で JSON デコードするのめんどくさくね?
Web API でアクセスするよりもページ情報を描画できるまでの時間が早くなるはずですが、特に計測していないので気になる方は計測して教えてください m(_ _)m
デメリット
もちろんデメリットもけっこうあります。
ページ自体の最初の描画は遅くなる
実データを取得して描画するまでの時間は提案手法のほうが早いはずですが、HTMLにJSONを埋め込むタスクがサーバーサイドで走る関係上、HTMLファイルを取得できるまでにかかる時間が増えてしまいます。
そのため、取得してElmプログラムが動き出すまでの時間が遅くなり、最初のページ描画までの時間は従来の Web API を使った手法の方が早くすることができます。
とくに体感速度を重視する場ではファーストビューだけを早く描画する手法もとられるため、実データの取得よりも最初のページ描画までの速度にシビアな状況であれば、必ずしも本手法が優れているとは言えません。
HTMLにキャッシュをきかせられない
1つめのデメリットとも重なりますが、HTMLファイルに個別の情報が含まれているため CDN などを使うことができません。
誤って使うと メルカリの事故 みたいなことが起きます。
そのため、CDN を使える Web API を使った従来手法に比べて、HTMLファイルの取得をするのにより多くの時間がかかることになります。
デコード相当の作業も必要なことがある
たとえば Elm 側で性別を型で管理したい場合を考えましょう。
type Gender
= Male
| Female
もし初期読み込み時の JSON に性別を含めたい場合、おそらく下記のような形式でサーバーサイドが JSON を埋め込むと思います。
{ "gender": "Male"
}
{ "gender": 0
}
{ "isMale": true
}
どのパターンにしても、そのままでは Elm で定義した Gender
型に自動で変換されることはありませんから、例えば下記のような変換用関数を定義する必要があります。
parseGender : String -> Maybe Gender
parseGender str =
case str of
"Male" ->
Just Male
"Female" ->
Just Female
_ ->
Nothing
さらに Parse 結果が Nothing
になった場合の処理なども必要なため、デコーダーを書くのとそんなに変わらない手間がかかる可能性があります。
JSON を自動でElmの型に変換したいという話がよくありますが、その場合もまぁ同じような追加工数がかかるので実際どうなんでしょうね...
ルーティングつきSPAでページ遷移時に情報が更新されない
ルーティングもElm側で管理するせまい意味でのSPAにおいては、ページ遷移時にHTMLを再度サーバー側にリクエストすることをしません。
すると、提案手法を使った場合にはページ遷移時にデータが更新されることなく、最初に読み込まれた内容が使い続けられることになります。
設計をうまくやらないと、「あれ?ページを移動したのに情報が更新されないのおかしくない?」と利用者を混乱させる原因になります。
SPAで本手法を使う際には、そのあたりの混乱をうまない工夫が必要になるでしょう。
まとめ
デメリットもいくつかあるため万能な手法ではありませんが、使いどころによっては非常に有用です。
意識低い感じで書けます。
内容的にオレオレ手法的なものなので、「これマズイでしょ」みたいなツッコミがあったらぜひおねがいします。