📦

ReactのJSXで書けるSSG を1から作り直した - minista v3

2023/06/08に公開

ReactのJSXで書けるスタティックサイトジェネレーター(以下SSG)ministaのv3をリリースしました!丸1年ぶりのメジャーアップデートです。

v3の目的は「開発環境の改新」と「コード全体のリファクタリングで完成度を上げること」でした。直しやすく安定して使える構成を目指して。

新機能や変更点はドキュメントサイトリリースをご確認ください!この記事では開発のポイントだけをまとめています。

おおまかな流れ

  • 2022/04/12 v2 正式リリース
  • 2022/07/20 v3 構想開始
  • 2022/09/07 [実装] 開発モードの静的HTML配信
  • 2022/09/14 [実装] 本番ビルドのSSGをViteに変更
  • 2022/10/05 モノレポ構成
  • 2022/10/14 v3 alpha版公開
  • 2022/11/20 [実装] 納品リスト・Zip圧縮
  • 2022/12/04 [実装] ダイナミックエントリー
  • 2023/01/23 [実装] 画像最適化
  • 2023/02/04 新しいロゴを作成
  • 2023/02/08 jsxlikeリリース
  • 2023/02/22 v3 機能完成
  • 2023/04/13 svgshowリリース
  • 2023/05/28 create-minista書き直し
  • 2023/05/31 v3 正式リリース

開発モードの静的HTML配信

v2の開発モードはViteのSPAだったのでブラウザ用のJSが動かないことが結構ありました。読み込んで即時実行しても、SPAがまだHTMLをレンダリングしていないので当然ですね。その影響で古いjQuery案件の改修やalpine.js案件はコーディングしづらい状態でした。

何か改善方法がないかとAwesome Viteに掲載されているリポジトリのコードを読んで回っていたところ、Tropicalが開発モードで静的HTMLを配信しているのがわかりました。ローカルのViteでサーバサイドレンダリングすれば良いとのこと。これの採用でSPA問題は解消。

https://tropical.js.org/

本番ビルドのSSGをViteに変更

v2の本番ビルドはesbuildでSSGを行っていました。*.tsx をnode.jsが読み込める *.mjs に変換し renderToStaticMarkup() でHTMLを作るという仕組みですが、Viteと処理方法が異なるため差異が発生します。

開発モードのViteは *.tsx をglobで読み込んででそのまま使う動的処理を書けます。これは本番ビルドで再現できません。依存関係の繋ぎこみもesbuildより安定しています。

その結果「Viteだと上手くいくのにesbuildでは失敗」したり「機能を拡張するときに工数が2倍かかる」問題に苦しみました。そこで、Viteに統一する方法を探しPre-Renderingを発見。

これを用いるとVite専用のglob importを静的に処理して汎用的なスクリプトが出力可能。それをnode.jsで読み込み、開発モードと差異のない本番ビルドを実現したというわけです。

モノレポ構成

v2ではpreactの preact/compat を使ったE2Eテストが失敗していました。しかし、実際にリリースされたものを使うと逆に上手くいきます。これは以下のような構成が問題でした。

├── dist
│   └── minista
├── node_modules
│   └── react
├── playground
│   └── test
│       ├── node_modules
│       │   └── preact
│       └── package.json # { "minista": "../../dist" }
└── package.json

複数のnode_modulesにreactとpreactが散っていますね。更にministaはローカルです。そのため「node_modules内に〇〇があったら××する」という処理を書くと失敗します。preactと同じ場所にreactをイントールすると、ministaが参照するreactと競合して動きません。

このように利用シーンと異なるディレクトリ構造に悩むわけですが、モノレポだと解決します。

├── node_modules
│   ├── minista #alias
│   ├── preact
│   └── react
├── playground
│   └── test
│       └── package.json
└── package.json

playground/test 向けにインストールしたpreactもルートのnode_modulesに配置されています。また、ministaはローカルですがシンボリックリンクでパスが解決されます。モノレポを採用することでE2Eテストがグッと楽に。メリットはsuinさんのスクラップで学びました。

納品リスト・Zip圧縮

Delivery

試作のHTMLを納品するときにページ一覧を作りますが、これを専用コンポーネントを置くだけで生成できるようにしました。また、dist をZip圧縮してダウンロードできます。

下書き機能も作ってあって「まだ納品できないけど開発中はページ一覧にリンクを出したい」を叶えます。個人的な業務効率化が目的で実装しましたが、同業の人の役には立つかも。

ダイナミックエントリー

v2まではコンフィグファイルでエントリーしたCSS・JSをビルドしていました。webpackと同じです。加えてページ毎の <link /> <script /> 出力も制御できるようにしたのですが、テンプレートと遠いため直感性に欠けました。そこで以下の書き方を実装。

import { Head } from "minista"

export default function () {
  return (
    <>
      <Head>
        <link rel="stylesheet" href="/src/assets/entry.css" />
        <script type="module" src="/src/assets/entry.ts" />
      </Head>
      <script type="module" src="/src/assets/entry2.ts" />
    </>
  )
}

ページ内にパスを貼るだけでビルド対象としてエントリーできるようになりました。裏で対象が存在するか確認したり、同名のエントリーを省略または連番で登録するなどをやっています。

画像最適化

GatsbyAstroNext.jsがやっていることをministaで再現してみようと考えました。

まず、どんな画像を生成してタグをどう書けば最適化になるのか。実は2パターンしかありません。「全画面の幅に応じたものを量産する」か「固定幅で解像度毎の画像を用意する」かです。

ブラウザが読み込む画像は、現時点だとCSSレイアウトより先に決定されます。CSSで可変した画像の表示幅に応じて最適な画像を呼ぶことはできません。

可能なのは、全幅画像をたくさん用意してビューポートで「これ以上にはならないだろう」という画像を読み込むか、幅を固定して解像度毎に呼ぶ画像を変えるか。結構アバウトですよね。あとは <source /> を使うと画像形式のフォールバックができます。

仕様を踏まえ画像最適化コンポーネントを作りました。コードのビルド前後は以下の通りです。

import { Image, Picture } from "minista"

export default function () {
  return (
    <>
      <div>
        <Image src="/src/assets/image.png" width={800} />
      </div>
      <div>
        <Picture
          src="/src/assets/image.png"
          width={200}
          layout="fixed"
          formats={["webp", "inherit"]}
        />
      </div>
    </>
  )
}
<div>
  <img
    srcset="
      /assets/images/image-320x157.png    320w,
      /assets/images/image-400x196.png    400w,
      /assets/images/image-640x314.png    640w,
      /assets/images/image-800x392.png    800w,
      /assets/images/image-1024x502.png  1024w,
      /assets/images/image-1280x627.png  1280w,
      /assets/images/image-1440x706.png  1440w,
      /assets/images/image-1920x941.png  1920w,
      /assets/images/image-2208x1080.png 2208w
    "
    src="/assets/images/image-2208x1080.png"
    sizes="(min-width: 2208px) 2208px, 100vw"
    width="800"
    height="392"
    alt=""
    decoding="async"
    loading="lazy"
  />
</div>
<div>
  <picture>
    <source
      srcset="
        /assets/images/image-200x98.webp  1x,
        /assets/images/image-400x196.webp 2x
      "
      sizes="(min-width: 200px) 200px, 100vw"
      width="200"
      height="98"
      type="image/webp"
    />
    <img
      srcset="
        /assets/images/image-200x98.png  1x,
        /assets/images/image-400x196.png 2x
      "
      src="/assets/images/image-200x98.png"
      sizes="(min-width: 200px) 200px, 100vw"
      width="200"
      height="98"
      alt=""
      decoding="async"
      loading="lazy"
    />
  </picture>
</div>

画像が生成されるとともに最適化されたタグを出力。widthheight は自動取得もできます。画像サイズの明記は地味に面倒ですからね。あと、リモート画像のAPIリクエストを切りたい場合もあるのでダウンロード機能も付けてあります。

jsxlike / svgshow

ついでに作った「jsxlike:HTMLをJSX風構文に変換するウェブアプリ」と「svgshow:SVGスプライトの中身を表示するウェブアプリ」もministaと相性が良く役に立っています。

https://jsxlike.qranoko.jp/

https://svgshow.qranoko.jp/

今後について

このような実装を経てminista v3は安定感を増しました。webpackをラップしてなんとかしようとしていた2年前に比べると頼もしくなったものです。

今後についてですが、Storybook風のコンポーネントを同封するかもしれません。業務でStorybookを2ヶ月ほど使ってみましたが、Reactで動かしているとブラウザ用のJSを混ぜたときにministaのv2と同じSPA問題が発生するため理想の形にはなりませんでした。

minista v3でPartial Hydrationを使うと現時点でもStorybook的なものは再現できます。

storyapp

これは試しに作ってみたものですが、静的配信されているHTMLをiframeで切り替えつつ読み取ってシンタックスハイライトしたり、ピューポートを切り替えられます。

*.stories.tsx を読み取って静的HTMLを配信すれば、Storybookと同じ使用感になるかもしれませんね。そこまでやるかは未定ですが。ではまた!

Discussion