📝

自作フレームワークに静的サイトジェネレータを組み込んだ

2022/10/01に公開

はじめに

以前から作っている自作フレームワーク Jelly の v0.5.0 をリリースしました

https://github.com/yukikurage/purescript-jelly

SSG 機能や、クライアントサイドでのルーティングなどの機能追加を行いました。
また、Jelly 以下のモジュールを Jelly.CoreJelly.RouterJelly.Generator に分割しました。RouterCore に依存し、GeneratorCoreRouter に依存しています。
それぞれの役割は以下の通りです。

  • Core
    • SignalComponent などの基本的な機能の提供
    • これがあれば単一ページのアプリケーションは作れます
  • Router
    • ルーターの提供
    • ページ全体を再読み込みするのでなく、CSR でページ遷移を行うことができます
  • Generator
    • SSG の提供
    • 静的な HTML の生成や、静的なデータをとってきてアプリに埋め込むことができます

また、Jelly のホームページも作成しました。もちろん Jelly で作られています。

https://jelly.yukikurage.net/

記事を書くのが苦手なので要所要所を箇条書きのような形で解説していきます

Signal を Monad にする

FRP についてあんまり詳しく無いので間違っていたら恐縮です。

Jelly の Signalpurescript-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 が発火するといった感じです。
犠牲になったのは、mergefilter 関数などです。
得られたのは Signal (Effect Unit) を動かしたあと、それを止められる事です。
join :: Signal (Signal a) -> Signal a を実装するに当たって、値の変更の検知を止める必要があるので、それの副作用というか、当然の帰結という感じでした。

Signal でアプリ全体を動かすぞというモチベは捨てて、あくまでコンポーネントの状態を管理したいという方針で実装することにしました。

実装は以下

https://github.com/yukikurage/purescript-jelly/blob/master/src/Jelly/Core/Data/Signal.purs
https://github.com/yukikurage/purescript-jelly/blob/master/src/Jelly/Core/Data/Signal.ts

自由モナドを使ったコンポーネント定義

いままであまり自由モナドに触れて来なかったのですが、やりたいことが結構自由モナドで綺麗に出来そうだったのでやってみました。
具体的には Component は様々な利用方法があって、例えば静的なサイトを作るときに String にしたい、とかブラウザ内で動かすため実 DOM にしたいとか、そういったものがあります。
これら String や実 DOM を Component に内蔵すると、実装的に汚い感じになります。

また、Component を Monad で書きたいというモチベがあり(下の例を参照)、これも自由モナドと相性が良かったです。

component = el_ "div" do
  el_ "div" do
    text $ pure "Hello"
  el_ "div" do
    text $ pure "World"

実装は以下

https://github.com/yukikurage/purescript-jelly/blob/master/src/Jelly/Core/Data/Component.purs

TypeScript を使った外部インポート

PureScript の FFI は JavaScript しかサポートしていませんが、TypeScript のコードを JavaScript に変換して使うようにしました。
tsconfig の targetmodule の値によって動いたり動かなかったりします。結局 targetes2017 にして modulees2015 にしたら上手く動きました。(今後動かなくなるかもしれない)

クライアントサイドでのルーティング

ルーターの動作は意外と面倒です。Jelly.Router では RouterContext に載せてどのコンポーネントからでも参照できるようにしています。

肝心の 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 を区切ったものです。/ などの処理がめんどくさいのでそれなら最初から入れなければ良いという発想です。
currentUrlSigtemporaryUrlSig は役割が似ていて、現在の URL を示します。違いについては後述します。 Url 型は

{ path :: Array String
, query :: Object String
, hash :: String
}

で、pathqueryhash の組です。# やら ? やら / やら & やらが入っていないので、そこら辺を意識せずとも使えるようになっています。
isTransitioningSig は現在遷移中であるかを示す Signal です。Jelly Router は Vue Router における beforeEach のような物をサポートしていて、それが実行中であるかどうかを示しています。
pushUrlUrl を pushState で遷移させる関数です。
replaceUrlUrlreplaceState で遷移させる関数です。

Router の動作を解説します。

  • 初期化 (basePath :: PathbeforeEach :: 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 で遷移を止めて、 RouterpushUrl を呼び出すようにしています。

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 のコードはかなり汚いです。どうしようもなさそうですが。

https://github.com/yukikurage/purescript-jelly/blob/master/src/Jelly/Core/Hydrate.purs

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 の結果を貼っておきます。


そもそもスマホ対応してないのでそれもしないとですね……

GitHubで編集を提案

Discussion