自作フレームワークに静的サイトジェネレータを組み込んだ
はじめに
以前から作っている自作フレームワーク Jelly の v0.5.0 をリリースしました
SSG 機能や、クライアントサイドでのルーティングなどの機能追加を行いました。
また、Jelly 以下のモジュールを Jelly.Core
、Jelly.Router
、Jelly.Generator
に分割しました。Router
は Core
に依存し、Generator
は Core
と Router
に依存しています。
それぞれの役割は以下の通りです。
-
Core
-
Signal
、Component
などの基本的な機能の提供 - これがあれば単一ページのアプリケーションは作れます
-
-
Router
- ルーターの提供
- ページ全体を再読み込みするのでなく、CSR でページ遷移を行うことができます
-
Generator
- SSG の提供
- 静的な HTML の生成や、静的なデータをとってきてアプリに埋め込むことができます
また、Jelly のホームページも作成しました。もちろん Jelly で作られています。
記事を書くのが苦手なので要所要所を箇条書きのような形で解説していきます
Signal を Monad にする
FRP についてあんまり詳しく無いので間違っていたら恐縮です。
Jelly の Signal
は purescript-signal を参考に作っています。
そもそもこの Signal
は FRP の Event と Behavior を 組み合わせたものです。(そう理解しています)すなわち、変化する値と、値の変化を通知するような仕組みを両方もっています。
purescript-signal の Signal は Applicative ではあるのですが Monad ではなく、その理由として、結構 Event 寄りの API を目指しているからだと思います。
Behavior の join
関数を考えると join :: Behavior (Behavior a) -> Behavior a
ですが、これは自然に考えられそうです。(現在の Behavior の現在の値を取得する的な感じで)
実際 haskell の reflex では Behavior は Monad になっているようです。
Jelly では Signal
をより Behavior
寄りの API にすることで Monad
にすることが出来ました。すなわち、基本的には連続的な値で、値が変更されるときに Event が発火するといった感じです。
犠牲になったのは、merge
、 filter
関数などです。
得られたのは Signal (Effect Unit)
を動かしたあと、それを止められる事です。
join :: Signal (Signal a) -> Signal a
を実装するに当たって、値の変更の検知を止める必要があるので、それの副作用というか、当然の帰結という感じでした。
Signal でアプリ全体を動かすぞというモチベは捨てて、あくまでコンポーネントの状態を管理したいという方針で実装することにしました。
実装は以下
自由モナドを使ったコンポーネント定義
いままであまり自由モナドに触れて来なかったのですが、やりたいことが結構自由モナドで綺麗に出来そうだったのでやってみました。
具体的には Component
は様々な利用方法があって、例えば静的なサイトを作るときに String
にしたい、とかブラウザ内で動かすため実 DOM にしたいとか、そういったものがあります。
これら String
や実 DOM を Component
に内蔵すると、実装的に汚い感じになります。
また、Component
を Monad で書きたいというモチベがあり(下の例を参照)、これも自由モナドと相性が良かったです。
component = el_ "div" do
el_ "div" do
text $ pure "Hello"
el_ "div" do
text $ pure "World"
実装は以下
TypeScript を使った外部インポート
PureScript の FFI は JavaScript しかサポートしていませんが、TypeScript のコードを JavaScript に変換して使うようにしました。
tsconfig の target
と module
の値によって動いたり動かなかったりします。結局 target
は es2017
にして module
は es2015
にしたら上手く動きました。(今後動かなくなるかもしれない)
クライアントサイドでのルーティング
ルーターの動作は意外と面倒です。Jelly.Router
では Router
を Context
に載せてどのコンポーネントからでも参照できるようにしています。
肝心の Router
の型は次のようになっています
type Router =
{ basePath :: Path
, currentUrlSig :: Signal Url
, temporaryUrlSig :: Signal Url
, isTransitioningSig :: Signal Boolean
, pushUrl :: Url -> Effect Unit
, replaceUrl :: Url -> Effect Unit
}
basePath
はページのルートパスです。https://xxx.xxx/hoge/
にページがあるなら [ "hoge" ]
になります。Path
型は Array String
で、これは /
で URL を区切ったものです。/
などの処理がめんどくさいのでそれなら最初から入れなければ良いという発想です。
currentUrlSig
と temporaryUrlSig
は役割が似ていて、現在の URL を示します。違いについては後述します。 Url
型は
{ path :: Array String
, query :: Object String
, hash :: String
}
で、path
と query
と hash
の組です。#
やら ?
やら /
やら &
やらが入っていないので、そこら辺を意識せずとも使えるようになっています。
isTransitioningSig
は現在遷移中であるかを示す Signal
です。Jelly Router は Vue Router における beforeEach
のような物をサポートしていて、それが実行中であるかどうかを示しています。
pushUrl
は Url
を pushState
で遷移させる関数です。
replaceUrl
は Url
を replaceState
で遷移させる関数です。
Router
の動作を解説します。
- 初期化 (
basePath :: Path
とbeforeEach :: Url -> Aff Url
が渡されます)
url <- 現在の ブラウザの Url
temporaryUrlSig <- url
isTransitioning <- true
newUrl <- beforeEach url
isTransitioning <- false
temporaryUrlSig <- newUrl
currentUrlSig <- newUrl
このように、temporaryUrlSig
は beforeEach の前に変わるのに対し、currentUrlSig
は beforeEach の後に変わります。
例えばナビゲーションバーの現在のページをハイライトするといった、レスポンスが大事な場面では前者を使い、ページの中身といったデータをとってきてから表示したい場面では後者を使うなどの使い分けができます。
- 遷移 (
url :: Url
が渡されます)
temporaryUrlSig <- url
isTransitioning <- true
newUrl <- beforeEach url
ブラウザの History に url を push
isTransitioning <- false
temporaryUrlSig <- newUrl
currentUrlSig <- newUrl
初期化とほぼ同じですね。
続いて、routerLink
コンポーネントです。ただ単に a
要素を置いただけではページ全体が読み込まれてしまいますので、preventDefault
で遷移を止めて、 Router
の pushUrl
を呼び出すようにしています。
routerLink
:: forall context
. Url
-> Array Prop
-> Component (RouterContext context)
-> Component (RouterContext context)
routerLink url props component = hooks do
{ pushUrl, basePath } <- useRouter
let
handleClick e = do
preventDefault e
pushUrl url
pure $ el "a" (props <> [ on click handleClick, "href" := urlToString basePath url ]) component
静的サイトジェネレータ
ジェネレータの動作は色々と面倒なところがあるので関数一つで呼び出せるようにしています。
generate
:: forall context
. Record context
-> Config context
-> Aff Unit
type Config context =
{ output :: Array String
, clientMain :: String
, paths :: Array Path
, getStaticData :: Path -> Aff String
, globalData :: String
, component :: Component (RouterContext (StaticDataContext context))
}
第一引数 Record context
は内部で使う任意のコンテキストです。
第二引数 Config context
には Config
型を投げます
output
は出力先ディレクトリ、
clientMain
はクライアントサイドのコードのエントリポイント、
paths
は生成する対象のパスの配列
getStaticData
はページ毎の静的なデータを取得する関数
globalData
はアプリ全体で使える静的なデータ
component
はルートレベルのコンポーネントです
静的サイトジェネレータの動作
- 生成時
paths <- 生成したいページ一覧を取得
ファイル {output}/paths.json <- paths
globalData <- グローバルなデータを取得
ファイル {output}/global <- globalData
for path of paths:
staticData <- ページ毎のデータを取得
ファイル {output}/{path}/data <- staticData
rendered <- ページをレンダリング (with staticData)
ファイル {output}/{path}/index.html <- rendered
ファイル {output}/index.js <- クライアントサイドのコードをビルド
- ブラウザでの動作
paths <- ファイル {basePath}/paths.json
globalData <- {basePath}/global
関数 beforeEach:
ファイル {basePath}/{url.path}/data のデータを取得
ルータの初期化 (with beforeEach)
Hydration
これによって毎ページ遷移時にそのページに必要な静的なデータを取得することになります
Hydration について
Hydration は静的な HTML から生成された DOM に ハンドラなどを付与していくプロセスです。1 から DOM を構築して HTML を書き直すと、 CSS が再読み込みされたりされなかったりするので、すでに DOM がある場合はそれを再利用し、さらには初期化をスキップするような処理が必要になります。
Jelly の Hydration のコードはかなり汚いです。どうしようもなさそうですが。
genLink
コンポーネント
routerLink
に、ホバー時に静的なデータを読み込む機能をつけたものです
genLink
:: forall context
. Url
-> Array Prop
-> Component (RouterContext (StaticDataContext context))
-> Component (RouterContext (StaticDataContext context))
genLink url props cmp = hooks do
{ loadData } <- useStaticData
let
handleHover _ = launchAff_ $ void $ loadData url.path
pure $ routerLink url (props <> [ on mouseover handleHover ]) cmp
実際に、https://jelly.yukikurage.net ではナビゲーションバーに使用しているので、Devtool か何かで通信を監視しつつ、サイドバーの項目をホバーすれば遷移先のページに必要なデータが読み込まれていることがわかると思います。
まとめ
色々と大変でしたが、ひと区切りついたので記事にしました。
静的サイトジェネレータを使う事で、CSR のみで動かしていた時にはできなかった、ページによって OGP を変えることが可能になりました。(OGP を読み込むとき基本的に JavaScript を実行しないため、静的な HTML を配信する必要があります。)
また、静的なページを配信しているのでパフォーマンスもそこそこ出ました。(Google font の読み込みがちょっと遅そうですが……)
参考までに、Lighthouse の結果を貼っておきます。
そもそもスマホ対応してないのでそれもしないとですね……
Discussion