Closed1

【TypeScript】Astroを使って高速なWEBサイトを作る方法

NanaoNanao

Astro を使うメリット

Astro は Javascript を極力 WEB ブラウザに送信しないアプローチを採用した SG/SSR ライブラリです。
そのため Astro を使うだけで高速で軽量な WEB サイトを開発できます。

公式ドキュメントに書いてある通り Astro はコンテンツ重視のライブラリです。
MPA 特有のパフォーマンスとシンプルサを優先しているため SPA 特有の UX とはトレードオフの関係にあります。
そのため Astro はダッシュボードや TODO リストのような「WEB アプリ」よりも「EC サイト」やブログなどの WEB サイト向けの方がパフォーマンスを発揮できます。

また、Astro は Astro インテグレーションにより React、Vue、Svelte、Tailwind CSS、MDX などのメジャーなライブラリと簡単に統合できます。
React や Vue などの慣れ親しんだコンポーネントをそのまま import して使えるので学習コストが低いのも魅力です。

Astro の特徴:

  • ページとコンポーネントは基本的にサーバー側で HTML として出力される
  • 指定した一部のコンポーネントのみをインタラクティブにできる
  • Astro 独自のコンポーネントの他にも React や Vue などメジャーなコンポーネントも import して使える
  • Cloudflare や Netlify などに特化した SSR 用のアダプタが用意されている

Astro アイランドという考え方

Astro のページとコンポーネントはデフォルトで静的 HTML として出力されます。
例え React や Vue のコンポーネントを使用していても Javascript が全て取り除かれて静的 HTML としてレンダリングされます。
そうすることで WEB ブラウザへ軽量で高速な HTML のみが配信されます。

しかし WEB サイトを作る上では検索機能やコメント投稿機能などのインタラクティブなコンポーネントが必要になってきます。
その際はページ全体をインタラクティブにするのではなく、インタラクティブにしたいコンポーネントのみをマークします。
ページの読み込み時、ロードの完了後、スクロールした時などコンポーネント毎に読み込みタイミングを細かく指定できます。

これにより静的 HTML のメリットと動的コンポーネントのメリットを両立した WEB ページが実現できます。
どのコンポーネントを静的にしてどのコンポーネントを動的にするかは開発者次第です。

Astro ではこのように大半のコンポーネントを静的 HTML として出力して一部のみを動的にするパターンを Astro アイランド(Astro Islands)と呼称しています。
静的 HTML の海の上に動的コンポーネントの島々が浮いているイメージですね。
ページの読み込みが高速なのでユーザー体験が良く SEO 的にもメリットがあります。

環境

  • Astro: 1.5.2
  • Astro VS Code 拡張: 0.28.1

Astro のインストール

Astro をインストールするコマンドは以下の通りです。
(※ yarn や pnpm の場合は適宜置き換えてください)

npm create astro@latest

コマンドを実行すると対話式で Astro のインストールを行えます。
入力補完や型チェックなどの恩恵を受けられるので TypeScript に関しては「strictest」を選択することをおすすめします。
その他特にこだわりがない場合は recommended の項目を選択していけば完了です。

また、VS Code を使用している場合は公式の拡張機能をインストールすることをおすすめします。
Astro ファイルのサポートや TypeScript による型チェックが有効になります。
(執筆時点で 65,000 回以上インストールされているので Astro の人気が伺えますね。)

https://marketplace.visualstudio.com/items?itemName=astro-build.astro-vscode

実行コマンド

以下のコマンドを実行すると開発用サーバーがlocalhost:3000で起動します。
ファイルを変更した際はその都度ホットリロードされます。

npm run dev

下記のコマンドを実行すると dist ディレクトリにデプロイ用のファイル一式が生成されます。

npm run build

しかし公式ドキュメントによると astro build は TypeScript の型チェックを行わないようです。
ビルド時にコンパイルエラーを表示するには package.json のビルドスクリプトを以下のように書き換える必要があります。

"scripts": {
  // 中略

  "build": "astro check && tsc --noEmit && astro build",
},

(※ yarn や pnpm の場合は適宜置き換えてください)

Astro ページを作る

Astro のページは基本的に独自形式の.astro ファイルで作成します。
.astro ファイルはマークダウンの FrontMatter のようにスクリプトとマークアップが「---」で区切られています。

.astro ファイルを/src/pages ディレクトリ配下に配置すると自動的にページとして認識されます。
その際にファイル名がルート名に使用されます。

例えば以下の.astro ファイルを「/src/pages/about.astro」として配置すると WEB ブラウザから「/about」でアクセスできます。

/src/pages/about.astro
---
const title = "About";
---

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title}</title>
  </head>
  <body>
    <header>
      <h1>{title}</h1>
    </header>
    <main>
      <p>ここは概要ページです</p>
    </main>
  </body>
</html>

「---」で囲まれたスクリプトはビルド時に実行されます。
先述した通り Astro のページやコンポーネントは特に指定しない限り Javascript を含まない静的 HTML として出力されます。

Astro コンポーネントを作る

Astro ファイルは別の.astro ファイルを import できます。
JSX や TSX のようにコンポーネントを export する必要はありません。

/src/components/Nav.astro
---
---

<nav>
    <a href="/">Home</a>
    <a href="/about">About</a>
</nav>

また、Astro.props で上位コンポーネントから props を受け取れます。
Props の型に関しては Props インタフェースを export するだけで Astro の VS Code 拡張が自動的に型チェックしてくれます。

/src/layouts/layout.astro
---
import Nav from "../components/Nav.astro";

export interface Props {
  title?: string;
}

const { title = "Default Title" } = Astro.props;
---

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="generator" content={Astro.generator} />
    <title>{title}</title>
  </head>
  <body>
    <header>
      <h1>{title}</h1>
      <Nav />
    </header>

    <slot />
  </body>
</html>

呼び出し側は以下のようになります。
この例の場合は Props を省略すると title が「Default Title」になります。

また、コンポーネントをラップした場合は下位コンポーネントが上位コンポーネントの <Slot /> にはめ込まれます。
Slot は React の children のような役割を持ちますが、Astro コンポーネントだけに留まらずどんな UI ライブラリのコンポーネントでも同じように使用できるのが特徴です。

/src/pages/index.astro
---
import Layout from "../layouts/Layout.astro";

const title = "Astro Example";
---

<Layout title={title}>
  <main>
    <p>ここはトップページです</p>
  </main>
</Layout>

React コンポーネントを import して使う

Astro は React などのメジャーなライブラリを公式にサポートしています。
React を Astro プロジェクトに追加するには以下のコマンドを実行するだけです。

npx astro add react

自動的に依存関係が計算されて React のバージョンが表示されます。
問題なければ「y」を入力してインストールします。
たったこれだけの操作で React との統合が完了します!

試しに簡単な React コンポーネントを作成してみます。
以下はよくある Counter コンポーネントです。

/src/components/Counter.tsx
import { useState } from "react";

type Props = {
    defaultCount?: number;
};

export const Counter: React.FC<Props> = ({ defaultCount = 10 }) => {
    const [count, setCount] = useState(defaultCount);

    return (
        <>
            <button onClick={() => setCount(v => v + 1)}>
                {`Count: ${count}`}
            </button>
        </>
    );
};

次に作成した React コンポーネントを Astro ページに import します。
Astro コンポーネントと同じ感覚で普通に import できますね。

/src/pages/index.astro
---
import Layout from "../layouts/Layout.astro";
import { Counter } from "../components/Counter";

const title = "Astro Example";
---

<Layout title={title}>
  <main>
    <article>
      <h2>Counter Example</h2>
      <Counter client:load />
    </article>
  </main>
</Layout>

この例では Counter コンポーネントに対して「client:load」というディレクティブを付与しています。
これは Astro 特有の構文で、コンポーネントがハイドレーションされるタイミングを指定できます。

もしこの client ディレクティブを指定しなかった場合、先述の通りコンポーネントは静的 HTML として出力されます。
今回の Counter コンポーネントのようにページ読み込み時にすぐにインタラクティブにしたい場合は「client:load」を指定します。
その他のタイミングでハイドレートしたい場合は公式ドキュメントに記載があります。

WEB ブラウザでアクセスすると Counter コンポーネントの動作を確認できます。
Astro アイランドによりページ全体ではなく Counter コンポーネントのみがインタラクティブになっています。

Vue コンポーネントを import して使う

もちろん Astro は Vue にも対応しています。
Vue を Astro プロジェクトに統合するには以下のコマンドを実行します。

npx astro add vue

依存関係が自動的に計算されて Vue のバージョンが表示されます。
問題なければ「y」を入力して完了です。

試しに Vue コンポーネントを作ってみます。
こちらもよくある Counter コンポーネントです。

/src/components/Counter.vue

<script setup lang="ts">
import { ref } from 'vue';

const  {defaultCount = 0}= defineProps<{defaultCount?: number;}>();
const count = ref(defaultCount);
const increment = () => count.value +=1;
</script>

<template>
    <button @click="increment">
      <span>Count: {{count}}</span>
    </button>
</template>

上記コンポーネントを Astro ページから import します。
この例でも Counter コンポーネントをインタラクティブにしたいので「client:load」を付与しています。

/src/pages/index.astro
---
import Counter from "../components/Counter.vue";
import Layout from "../layouts/Layout.astro";

const title = "Astro Example";
---

<Layout title={title}>
  <main>
    <article>
      <h2>Counter Example</h2>
      <Counter client:load />
    </article>
  </main>
</Layout>

WEB ブラウザから確認すると問題なく動作していることが確認できます。
このように Astro は特定の UI ライブラリへ依存することなく好きなコンポーネントを使用できます。

その他のライブラリとの統合

その他 astro コマンドを実行するだけで様々なライブラリと簡単に統合できます。
公式対応しているライブラリにはモダンなライブラリが一通り揃っているので試しに触ってみるのも面白いです。

  • Preact: React とほぼ同じ書き方で React よりも軽量なコンポーネントが作成できます
  • SolidJS: 仮想 DOM を使用しない軽量で高速なライブラリ。以前に入門用のスクラップを書いています
  • MDX: マークダウンに JSX を書けるのでブログ等に最適です

コンポーネント間で state を共有する

Astro 上のコンポーネント間で state を共有したい場合があります。
もしコンポーネントに Svelte や SolidJS などを使用している場合は組み込みのグローバル Store を使って対応できます。

その他 React などのグローバル Stor がないライブラリはどうすればいいでしょうか?
Astro の公式ドキュメントによるとコンポーネント間の state の共有にはNano Storesの使用が推奨されています。
Nano Storesは特定の UI ライブラリに依存しない軽量なストレージなので Astro との相性が良いようです。

Nano Stores のインストール

Nano Stores 本体をインストールするコマンドは以下の通りです。

npm install nanostores

また、Nano Stores には React や Vue などのメジャーなライブラリと統合するためのヘルパーライブラリが用意されています。
React 向けのヘルパライブラリをインストールするコマンドは以下の通りです。

npm install @nanostores/react

Nano Stores を使う

Nano Stores の使い方はとてもシンプルです。

Store は Atom と呼ばれる単位で扱われます。Atom は atom()で宣言します。
store.get()で値を取得、store.set(value)で値を変更できます。

Atom に関係するロジックは action()の中に分離できます。
action()を使わずに直接ロジックを書いてもいいのですが、action()を使うとコンポーネントからロジックを切り離せるのでコードの見通しが良くなります。

/src/stores/CounterStores.ts
import { action, atom } from "nanostores";

export const count = atom(0);

export const increment = action(count, "increment", (store) => {
  store.set(store.get() + 1);
  return store.get();
});

Atom をコンポーネント内で呼び出すには useStore()を使います。
useStore()は指定した Atom の値の変化を監視してくれます。

import を見ると分かりますがこの useStore()は React などのライブラリ毎のヘルパーです。
例えば React では Atom が変更されると useStore()の結果も変わり、コンポーネントが再レンダリングされます。

/src/components/Display.tsx
import { useStore } from "@nanostores/react";
import { count } from "../stores/CounterStore";

export const Display: React.FC = () => {
    const $count = useStore(count);

    return (
        <div>{`Count: ${$count}`}</div>
    );
};

Action の方は import するだけで使えます。
コンポーネントからロジックが分離されてコードの見通しが良くなっていますね。

/src/components/Counter.tsx
import { increment } from "../stores/CounterStore";

export const Counter: React.FC = () => {

    return (
        <>
            <button onClick={increment}>
                Click here!!
            </button>
        </>
    );
};

Astro ページから上記 2 つのコンポーネントを呼び出します。
どちらもすぐにインタラクティブにしたいので「client:load」を付与しています。

/src/pages/index.astro
---
import { Counter } from "../components/Counter";
import { Display } from "../components/Display";
import Layout from "../layouts/Layout.astro";

const title = "Astro Example";
---

<Layout title={title}>
  <main>
    <article>
      <h2>Counter Example</h2>
      <Counter client:load />
      <Display client:load />
    </article>
  </main>
</Layout>

WEB ブラウザから確認するとちゃんと store がコンポーネント間で共有されていることが分かります。
今回は React コンポーネントのみを使用しましたが、他の UI ライブラリとも共有できそうですね。

このスクラップは2022/10/22にクローズされました