Astro

Astroについて

https://astro.build/

特徴としては

  • zero-JS frontend architecture
    • クライアントでJSをゼロにできる
    • ハイパフォーマンス
    • SSRでのMPAが基本
  • オールインワンフレームワーク
    • astroというファイル拡張子だけでなく、コンポーネントごとにReactを使ったりVueを使ったりすることができる
      • Astro Island
        • アイランドアーキテクチャ
  • コンテンツ重視
    • マーケティングサイト、出版サイト、ドキュメントサイト、ブログ、ポートフォリオに特化
    • インタラクティブなアプリケーション(いわゆるSPAが活きるような)には向いていない
    • markdownのファイルをそのままレンダリングできる

先に言っておくと、Next.jsやNuxt.jsとは違いMPAフレームワークという構造上、どんな種類のアプリケーションでも適している訳ではありません。

目的によってはNextやNuxtよりハイパフォーマンスな場合があります。

Astroが解決する課題

最近のWebフレームワークでは

  • 読み込むJSサイズが大きくハイドレーション完了後のインタラクティブになるまでの時間(TTI)がかかってしまう場合がある
    • クライアントのネットワークが貧弱な場合など
  • コアライブラリ部分のJS読み込みも必要になりそれらは小さくない

とJSサイズが問題になるケースが多々あります。

そこでAstroではデフォルトではJSをアウトプットしないようになっています。

そしてもしハイドレーションが必要になる場合でも、Astroアイランドという仕組みによって高速なかつ無駄のないハイドレーションができるようになっています。

Astroアイランド

astroのファイル上の各コンポーネントをそれぞれ島として捉えて、それぞれの島で

  • 使うフレームワークの選択
  • ハイドレーションのタイミング

をコントロールできます。

Astro Islands Architecture

ref: https://docs.astro.build/ja/concepts/islands/

そして各アイランドは並列ロードされます。

そのため上記の例でいうとページ下部の重いイメージカルーセルがヘッダーのアイランドをブロックせず、ヘッダーはすぐにインタラクティブにすることができます。

この思想はアイランドアーキテクチャから来ており、パーシャルハイドレーションという手法を用いています。

ハンズオン

Astroの特徴をハンズオンを進めながら確認していきます。

環境構築

任意のワークスペースで以下を実行

$ yarn create astro

するといくつか設定を聞かれますが今回は全てリコメンドで大丈夫です。

dir Where should we create your new project? 好きなディレクトリ名 tmpl How would you like to start your new project? ● Include sample files (recommended) ○ Use blog template ○ Empty deps Install dependencies? (recommended) ● Yes ○ No ts Do you plan to write TypeScript? ● Yes ○ No use How strict should TypeScript be? ● Strict (recommended) ○ Strictest ○ Relaxed git Initialize a new git repository? (optional) ● Yes ○ No ✨ Done in 48.94s.

作られたプロジェクトのフォルダに移動し起動します。

$ cd [作ったディレクトリ] $ yarn dev yarn run v1.22.19 warning package.json: No license field $ astro dev 🚀 astro v2.5.0 started in 72ms ┃ Local <http://localhost:3000/> ┃ Network use --host to expose

ポート使っていなければ http://localhost:3000/ でアクセス。

デフォルトのページ確認できました。

Astro Development Starter Page

追加準備

VScode使用している方は以下のextension入れとくと良きです。

Astro - Visual Studio Marketplace

SSGを確認

AstroはSSGとSSRに対応しているため、まずはSSGの挙動を確認していきます。

ページの作成

src/types/api.ts を作成

export type Photo = { albumId: number id: number title: string url: string thumbnailUrl: string } export type Photos = Photo[]

src/components/ListItem.astro を作成

--- import type { Photo } from '../types/api' type Props = { id: Photo['id'] title: Photo['title'] url: Photo['url'] } const { id, title, url } = Astro.props --- <li> <a href={`/photo/${id}`}> <img src={url} alt={title} /> <div class="title-wrapper"> <h2>{title}</h2> </div> </a> </li> <style> li { width: 30%; border: 1px solid #ddd; border-radius: 4px; list-style: none; } img { width: 100%; height: auto; aspect-ratio: 1/1; background: #eee; } .title-wrapper { padding: 16px 8px; } h2 { font-size: 16px; margin: 0; } </style>

src/pages/index.astro を以下に書き換え

--- import Layout from '../layouts/Layout.astro' import ListItem from '../components/ListItem.astro' import type { Photos } from '../types/api' const getRandomNumber = () => Math.floor(Math.random() * 2) + 1; const response = await fetch( `https://jsonplaceholder.typicode.com/albums/${getRandomNumber()}/photos` ) const photos: Photos = await response.json() --- <Layout title="Welcome to Astro."> <main> <ul> { photos.map((photo) => ( <ListItem id={photo.id} title={photo.title} url={photo.thumbnailUrl} /> )) } </ul> </main> </Layout> <style> main { margin: auto; padding: 1.5rem; max-width: 780px; } h1 { font-size: 3rem; font-weight: 800; margin: 0; } ul { display: flex; flex-wrap: wrap; gap: 16px; } </style>

すると以下が表示されます。

そしてAPIは https://jsonplaceholder.typicode.com/albums/1/photos or https://jsonplaceholder.typicode.com/albums/2/photos が叩かれる形になっており、何回かリロードすると内容が変わるようになっています。 (後でSSGとSSRの挙動を確認するためこうしています。)

app image

astroでは .astro という拡張子が使用されます。

--- のコードフェンスのブロック、エレメントブロック、styleブロック、scriptブロックと分かれます。

--- のコードフェンスのブロックはサーバーサイドで実行されるスクリプトでバニラJS(TS)で処理を記載していきます。(一部astroでグローバルに登録されている関数などもあり)

ここで適した変数はエレメントブロックで参照できます。

今回のコードではAPIからデータの取得を書いています。

--- import Layout from '../layouts/Layout.astro' import ListItem from '../components/ListItem.astro' import type { Photos } from '../types/api' const getRandomNumber = () => Math.floor(Math.random() * 2) + 1; const response = await fetch( `https://jsonplaceholder.typicode.com/albums/${getRandomNumber()}/photos` ) const photos: Photos = await response.json() ---

コードフェンスはクライアント(ブラウザ)側では実行されないため window オブジェクトの参照や document.querySelector('ul') などでDOMを検索、操作することもできません。

クライアント側で実行したいスクリプトがある場合は script タグを使用すれば使用できます。

<script> const ul = document.querySelector('ul') console.log(ul) </script>

エレメントブロックはほぼJSXelementのような記述ができます。

仮想DOMではないのでループしてるエレメント(<ListItem>)keyを渡す必要はありません。

<Layout title="Welcome to Astro."> <main> <ul> { photos.map((photo) => ( <ListItem id={photo.id} title={photo.title} url={photo.thumbnailUrl} /> )) } </ul> </main> </Layout>

styleブロックはVueのSFCなどのように自動的にスコープされたスタイルで記述できます。

<style> main { margin: auto; padding: 1.5rem; max-width: 780px; } h1 { font-size: 3rem; font-weight: 800; margin: 0; } ul { display: flex; flex-wrap: wrap; gap: 16px; } </style> /* グローバルにも設定可能 <style is:global> */ /* 変数を渡すことも可能 --- const foregroundColor = "rgb(221 243 228)"; const backgroundColor = "rgb(24 121 78)"; --- <style define:vars={{ foregroundColor, backgroundColor }}> h1 { background-color: var(--backgroundColor); color: var(--foregroundColor); } </style> */

詳細ページの作成

src/pages/photo/[id].astro を作成

--- import type { Photos } from '../../types/api' import Layout from '../../layouts/Layout.astro' export async function getStaticPaths() { const response = await fetch( '<https://jsonplaceholder.typicode.com/photos?albumId=1&albumId=2>' ) const photos: Photos = await response.json() const paths = photos.map((photo) => { return { params: { id: photo.id.toString() }, props: { photo } } }) return paths } const { title, url } = Astro.props.photo const { id } = Astro.params; --- <Layout title={title}> <div class="wrapper"> <img src={url} alt={title} /> <p>id: {id}</p> <h1>{title}</h1> <a href="/">Back to top</a> </div> </Layout> <style> .wrapper { width: 400px; margin: 0 auto; } img { width: 100%; aspect-ratio: 1/1; background: #eee; } h1 { font-size: 32px; margin-top: 24px; } </style>

すると一覧からのリンクから詳細に飛べるようになります。

Detail page image

動的ページで場合は [id].astro[] を用いて宣言し、パスパラメーターは const { id } = Astro.params; のように取得できます。

getStaticPaths はNextでSSGやISRを実装したことある方ならわかると思いますが、

SSGの場合、ビルド時にHTMLを生成する必要があります。

動的なページの場合はビルド時にどのページをビルドする必要があるかということを伝えてあげる必要があります。

以下のコードを言葉にすると

  • albumId=1とalbumId=2のレスポンスの計100枚のページをビルド時に生成して
  • その際propsはここで渡しているものを使用して

ということになります。

export async function getStaticPaths() { const response = await fetch( '<https://jsonplaceholder.typicode.com/photos?albumId=1&albumId=2>' ) const photos: Photos = await response.json() const paths = photos.map((photo) => { return { params: { id: photo.id.toString() }, props: { photo } } }) return paths }

Vercelにデプロイ

Vercelにアプリケーションをデプロイします。

成果物の確認

デプロイされた一覧ページを何回かリロードしてみます。

SSGではビルド時にHTMLが生成されその成果物がデプロイされているため、何回もリロードしても内容は変わりません。

またシークレットモードでページを開きデベロッパーツールのネットワークタブを見てみてもJSファイルはなくHTML, CSSのみで、不要なJSは全て取り除かれていることがわかります。

Confirmation that JS file does not exist in developer tool

またSPAではなくMPAフレームワークということで、詳細ページに遷移すると新しいHTMLがリクエスト&レスポンスされていることがわかります。

Detail page and Developer tool

ちなみにVercelのデプロイのサマリをみてみるとSSGで静的リソースがデプロイされているのがわかります。

Build resouses in vercel

SSRに変更してデプロイ

次にSSRに変更してデプロイしていきます。

以下をターミナルで入力します。

$ yarn astro add vercel

するといくつかconfigを自動で追加するかなど選択が出ますが全てyesで大丈夫です。

@astrojs/vercel/serverless パッケージが追加され astro.config.mjs に設定が追加されました。

import { defineConfig } from 'astro/config'; + import vercel from "@astrojs/vercel/serverless"; // <https://astro.build/config> export default defineConfig({ + output: "server", + adapter: vercel() });

これでSSRに変わったのですが、詳細ページにアクセスするとエラーがでます。

getStaticProps はSSGのための処理の為、書き方を変えてあげる必要があります。

src/pages/photo/[id].astro のコードフェンス内を以下に書き換えます。

--- import type { Photo } from '../../types/api' import Layout from '../../layouts/Layout.astro' const { id } = Astro.params; const response = await fetch( `https://jsonplaceholder.typicode.com/photos/${id}` ) const { title, url, }: Photo = await response.json() ---

パスパラメーターを取得してその内容から単体のデータ取得をするようになりました。

ではこのこの内容をコミットしてpushします。

するとvercelのデプロイが自動で走って最新にデプロイされ直します。

ちなみにデプロイサマリーを見るとSSGと違いHTMLはデプロイされておらず、その代わりにNode.jsのServerless functionsが作られていることが確認できます。

Build resouces in vercel

成果物の確認

では今度デプロイされた一覧ページに進み、何回かリロードしてみます。

するとページの内容がリロードすると変わります。

その為リクエストがあると毎回サーバー側でHTMLを生成し返していることが確認できました。

そしてSSGの時と同様、JSファイルは存在せず、HTML、CSSファイルのみであること、一覧ページから詳細ページに遷移するとHTMLのリクエスト&レスポンスが起きているのがわかります。

Confirmation that JS file does not exist in developer tool

Detail page and Developer tool

この点がSPAフレームワークとMPAフレームワーク(Astro)の大きく違う点で、SPAだとページ遷移時にはHTMLの取得は行わずJSでページコンテンツの一部が再レンダリングをされる為、体験としてはMPAより良く感じることが多いです。

その代わり、SPAだと初期表示までに必要なJSリソースが多くなる傾向がありますが、AstroでのMPAはその点高速な初期表示が実現できます。

こういったそれぞれのメリデメがあるためAstroでもそれはトレードオフの関係であり、アプリケーションに沿って適切な技術選択をするべき主張しています。

MPA vs. SPA

Astro Island で React を使用する

オールインワンフレームワークということでReactのコンポーネントを使用していきます。

以下でインテグレーションを追加します。聞かれる内容に対しては先ほどと同じく全てyesで大丈夫です。

$ yarn astro add react

するとastro.config.mjs に設定が追加されます。

import { defineConfig } from 'astro/config'; import vercel from "@astrojs/vercel/serverless"; + import react from "@astrojs/react"; // <https://astro.build/config> export default defineConfig({ output: "server", adapter: vercel(), + integrations: [react()] });

またパッケージの追加、tsconfigの設定が追加されます。

src/components/MoveTopButton.tsx を作成します。

import type React from 'react' const MoveTopButton = (): React.JSX.Element => { const handleClick = () => { window.scrollTo({ top: 0, behavior: 'smooth' }) } return ( <button type="button" onClick={handleClick}> Go Page Top(react component) </button> ) } export default MoveTopButton

そしてそれを src/pages/index.astro にインポートして、ページ下部に配置します。

--- import Layout from '../layouts/Layout.astro' import ListItem from '../components/ListItem.astro' + import MoveTopButton from '../components/MoveTopButton' import type { Photos } from '../types/api' const getRandomNumber = () => Math.floor(Math.random() * 2) + 1; ~略~ )) } </ul> + <MoveTopButton client:visible /> </main> </Layout>

するとページ下部にボタンが表示され押下でページトップまでスクロールすることが確認できます。

Index page

astro上でReactコンポーネントとastroコンポーネントが共存できていることが確認できました。

ここまでをコミットしてpushします。

デプロイが完了したらデベロッパーツールからエレメントで先ほどのボタンを確認します。

そうすると astro-island というタグにbuttonタグが囲まれてレンダリングされているのがわかります。

Element tab in developer tool

今度は、一旦リロードし、ネットワークタブでを開きながらボタン位置までスクロールします。

するとボタン関連のJSがリクエストされたことがわかるかと思います。

Network tab in developer tool

つまり要素が現れる際までボタンのハイドレーションは遅延させていることになります。

これは先ほどの <MoveTopButton client:visible /> のclientディレクティブが作用しているもので、どのタイミングでハイドレードさせるかをコントロールできます。

今回は動作がわかりやすいようにvisibleにしましたが、idleを設定する機会がかなり多くなるかなと思います。

  • load: ページロード時に即座に
  • idle: 初期表示などは終えてアイドル状態になったら
  • visible: ページの可視範囲に入ってきたら
  • media: 特定のメディアクエリが充された時
  • only: サーバーでのレンダリングはスキップしてクライアントでのみレンダリングする。ハイドレーションのタイミングはloadと同じ

ref: https://docs.astro.build/en/reference/directives-reference/#client-directives

ちなみにclientディレクティブがないとそのコンポーネント内にJSは全て排除される動きになりインタラクティブな動作ができなくなるためご注意ください。

Astroではこのようにして初期ロード時のパフォーマンスを操作、チューニングすることが可能となります。

まとめ

フレームワークライクに書けてかつ無駄なJSをアウトプットしないといういいところだったり、インテグレーションが用意されていてDX出来にも快適だったり良さは多くあるものの、MPAというところを加味した上で選ぶ必要はあるかなと思います。(Astroでもコンテンツ重視と謳っている通り)

技術記事だったり、「〇〇年収は?彼女はいるの?」みたいなサイト内回遊をKPIとしないような特性のサイトではかなりいいのでは思ったりもしますが、CDNでのキャッシュなどを活かすことをプラスして考えるとこういった初期レンダリングのパフォーマンスの高いアーキテクチャでのMPAでも大きな問題なくトータルとしては良い場面が多いのかもしれません。

SPAでモダン化したフロントエンドがMPAに戻るという部分はとても面白い流れに感じます。

余談

qwik

ちなみに今回のAstroのようなハイドレーションによるパフォーマンス悪化を解決することを特徴に置いたフレームワークは他にもあり、その一つが qwik です

Framework reimagined for the edge! - Qwik

Resumableという、実際のイベントが発火されてからそのハンドラに必要なJSがロードされるアプローチを採用しています。

以下にサンプルを置いておきます。

https://qwik-sample-9s1i4jarq-kskymst.vercel.app/