はいさい!ちゅらデータぬオースティンやいびーん!
概要
最近、Astro.jsに、Next.jsのWebアプリケーションを移行しましたが、本投稿は、Astroの紹介と、移行作業の感想をまとめるものです。
Astro.jsは欧米ではとても注目されているフレームワークですが、日本ではまだまだ話が広まっていない気がします。
Astro.jsとは
Astro.jsは、MPAの静的サイトをレンダーするJavaScriptフレームワークです。
フレームワークといえども、Next.jsのように、Reactなどのフロントエンドフレームワーク専用ではないのです。
どのようなJavaScriptを使っても大丈夫なのです。
React.js === React.js
、Vue.js => Nuxt.js
などのように、フロントエンドフレームワークに一体化したバックエンドフレームワークという従来の考え方から完全に脱出しています。
あえて、MPAにしているのも、Next.js等の反省を感じさせる意図があります。
まさに、Webアプリケーションのパラダイムシフトを起こしているフレームワーク、Astro.jsなのです。
欧米の巷ではAstro.jsの話で持ちきりです。
移行、Astro.jsの重要な概念と技術について説明します。
Astro Islands
Astro Islandsは、UIの部品を独立した島
に抽出する技術です。
抽出の過程で、不要と判定されたJavaScriptは省かれます。
それにより、ページの読み込み時間を大幅に減らすことができます。
Astro Islandsの主な要点は以下の通り:
-
ランタイム用のJavaScriptは完全に取り去られる
Reactを例に挙げれば、React.jsを動かすための親分のReactパッケージは不要になります。 -
部品の遅延読み込みが可能
内部にIntersection Observer APIを使っており、指定すれば、部品がユーザーのブラウザに見えてくるまで、読み込まないように設定することができる。これによって最初のページの読み込み時間を減らすことができます。 -
完全に独立した部品を読み込ませることができる
とある新規プロジェクトで前に作ったVueの部品が使いたいけれど、Reactだから、書き直すしかない
と言ったような悩みにはサヨーナラ。
SSRも静的ビルドもできる
Next.jsでやっているServer Side RenderingはAstro.jsでも可能です。
例えば、ブログの投稿情報をAPIから取得するページがあれば、Astro.jsをserver
モードにしていれば、サーバー側でAPIから取得して、レンダーした上でユーザーに単なるHTMLを返すことができます。
また、静的サイトを作る場合でも、APIから取得した情報に基づいて、ページを作成することができます。
フレームワークに依存するSSRではない、完全なるSSR、本来のSSRなのです。
HTML、Markdown (.md)、MDXなど、様々な形式のファイルをレンダーできる
Qiitaで使っているようなMarkdownもできます。
従来のHTMLもできます。
筆者がよくわからないMDXもできます。
とにかく、様々なコンテンツをMPAに取り入れることがポイントです。
Markdownファイルは、レイアウトを指定することができるので、Webアプリケーションの基本的な機能をレイアウトで書いていれば、あとは、Web開発がわからない広報の人でもコンテンツを作成することができます。
つまり、やっと脱Wordpressができるのです!!!パチパチ
どのフロントエンドフレームワークも併用できる。
Reactがお好きですか?Reactを使ってください。
Vueがお好きですか?Vueを使ってください。
Litも、Svelteも、Preactも、ともかくサポートされていないフレームワークはほとんどないのですよ!
本稿の本題でもあるのですが、そのおかげで、Astro.jsフレームワーク移行を最小限の力でできるようにしてくれます。
様々なホスティングサービスに対応している
デプロイすることもとても簡単です。
アプリケーションのモードによるのです。
静的サイトとしてデプロイするのか?
SSRのサーバーとしてデプロイするのか?
静的サイトとしてAstroを使うのであれば、S3バケツにだってデプロイできてしまいます。
SSRだと、Vercelとか、DenoとかAWS Flightcontrolなど、様々なサービスがあります。
Next.jsから移行する感想
筆者はこれほど革新的
と言われているAstro.jsを試したく、昔作ったNext.jsのWebアプリケーションを移行することにしました。
正直、Reactがあまり好きではないので、いずれこのアプリケーションの脱React化を図りたかったのでちょうどよかったのです。
まず第一印象、AstroはNext.jsと似て非なるものなのだと思います。
Next.jsと同じ考え方でAstroのアプリケーションを書こうとすると、混乱します。
これについて以下書きます。
.astro
のAstro部品の基本
Astroには.astro
の部品があります。
これらの部品はページを作る時に使うし、ページの構成部品として使うことができます。
.astro
部品の頭
.astro
部品の頭に、サーバー側で行う処理のJavaScriptがきます。---
で分けています。
---
import Layout from "../layouts/Layout.astro";
import Card from "@components/UI/Card";
---
この頭の部分は、サーバー側で実行されるもので、ブラウザで実行されるものではないを強く意識した方がいいです。
筆者は、.astro
部品で、この---
の間に指定した関数をどうやってEventListenerとして使えるかを調べようと、長い時間を無駄に浪費しました。
.astro
部品のボディ
.astro
部品のHTML(ボディ)の部分はJSXに似たような書き方をします。
そのせいで、Reactと同じようにEventListenerをつけたり、JSXらしいロジックを付けることができると勘違いされがちなようです。
ブラウザで実行されるロジックを.astro
では付けることができません。Astro Islandでつけるのか、従来の<script>
要素で書くのか、そのどちらかです。
---
import Layout from "../layouts/Layout.astro";
import Card from "@components/UI/Card";
const greeting = "Hello";
---
<Layout>
<Card client:load>
<h1>{greeting}</h1>
</Card>
</Layout>
Card
はReact部品で、そのまま使うと、CSSなどが付いた静的部品としての<div>
がレンダーされますが、client:load
をつけると、もしその部品にJavaScriptロジックが入っていれば、ページが読み込まれてから、Next.jsと同じように後からHydrate(JavaScriptを読み込む)されます。
ちなみに、このclient
の設定は他に以下のようなものがあります。
設定 | 効果 |
---|---|
client:load | 部品のJavaScriptを後から読み込むようにする。 |
client:idle | 最初のDOMレンダーが終わった後に、読み込む。requestIdleCallback が配信された後に読み込まれる。 |
client:visible | 画面に見えてきたら、読み込むようにする。 |
client:media | CSSのmedia 設定によって、隠されたりする部品を読み込む時に使う。 |
client:only | SSRもしくは静的ビルドでHTMLをサーバーでレンダーしたくない時に、ユーザーだけで読み込むように設定する。Hydrationはないので、ユーザー側でしか実行できないようなコードが入っている部品の時はこの設定を使う。 |
実際の例
実際にこれらの設定と手法を使ってページを書くと、以下のような書き方になります。
---
import { ImageCard } from "@components/lit-components/image-card";
import Card from "@components/UI/Card";
import styles from "../../styles/Home.module.css";
import logo from "/images/original.jpg";
import mame from "/images/mame.jpg";
import coffee from "/images/coffee.jpg";
import art from "/images/art.jpg";
import nae from "../../public/images/nae.jpg";
import flower from "../../public/images/flower.jpg";
import plant from "../../public/images/plant.jpg";
import Layout from "../layouts/Layout.astro";
import { InquiryFooter } from "@components/lit-components/inquiry-footer";
const coeText =
"カップ・オブ・エクセレンス(英語:Cup of Excellence、略称:CoE)はコーヒーの品質審査制度。カップ・オブ・エクセレンスはコーヒー界のアカデミー賞(オスカー)とも例えられる。コーヒーの審査は少なくとも毎回5回行われ、最終的な勝者は「カップ・オブ・エクセレンス」を冠される。";
---
<Layout>
<div class={styles.feed}>
<img src={logo} width="100%" height="100%" />
<div class={styles["main"]}>
<h1>沖縄県産コーヒー農園、Pace</h1>
<h3>コーヒーを通してすべての人に、へいあんを。</h3>
</div>
<ImageCard client:load data-src={mame}>
沖縄県のやんばるでコーヒーの栽培を行っています。
</ImageCard>
<ImageCard client:load data-src={coffee}
>コーヒー豆の加工・販売を行っています。</ImageCard
>
<ImageCard client:load data-src={art}
>障がい者の働く場を提供します。</ImageCard
>
<Card>
<h1>我々の挑戦</h1>
<h2>
沖縄産コーヒーで
<br />
スペシャリティコーヒー
<br />
を目指します
</h2>
<span>{coeText}</span>
</Card>
<div class={styles["life-cycle"]}>
<h1>コーヒーの木の成長過程</h1>
<div class={styles["row-wrap"]}>
<div class={styles["wrap-child"]}>
<ImageCard client:load data-src={nae} class-name="small">
コーヒーはパーチメント付きのコーヒーの種子を発芽用のポットに植えます。種植えしてから約2ヶ月で発芽します。本葉が出てきたら植え替えをします。
</ImageCard>
</div>
<div class={styles["wrap-child"]}>
<ImageCard client:load data-src={flower} class-name="small">
植付けから最初の開花まで、早いところでは18ヶ月、遅くても30ヶ月かかります。最初の花は、幼木なので、数も僅かです。
開花は、一斉に起こるわけでなく、約4ヶ月の間に5〜7回に分けて開花します。
</ImageCard>
</div>
<div class={styles["wrap-child"]}>
<ImageCard client:load data-src={plant} class-name="small">
コーヒーの苗は、1年ほどポットで栽培し、その後、路地植えをします。成木になるには、沖縄では、約3〜5年かかります。
</ImageCard>
</div>
<div class={styles["wrap-child"]}>
<ImageCard client:load data-src={mame} class-name="small">
開花した花の約8割が結実します。結実すると花弁が落ち、小さな胡椒の実のような実が茎の先に見られるようになります。気象条件などで変わりますが、開花から約8ヶ月かけて徐々に大きくなり、完熟豆に成長します。
</ImageCard>
</div>
</div>
</div>
<InquiryFooter></InquiryFooter>
</div>
</Layout>
<script>
const observer = new IntersectionObserver(
entries =>
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const imageCard = entry.target;
if (!(imageCard instanceof HTMLElement))
throw TypeError("Observer Entries must be HTML Elements");
const src = imageCard.dataset.src;
if (!src) throw TypeError("No data-src was provided");
imageCard.setAttribute("src", src);
}),
{
root: null,
rootMargin: "0px",
threshold: 1.0,
}
);
const imageCards = document.querySelectorAll("image-card");
imageCards.forEach(imageCard => observer.observe(imageCard));
</script>
---
import styles from "./AppLayout.module.css";
import { NavBar } from "../components/lit-components/nav-bar";
---
<!DOCTYPE html>
<html lang="ja">
<head>
<meta name="viewport" content="width=device-width" />
<meta charset="utf-8" />
<title>沖縄コーヒー農園 Pace</title>
</head>
<body>
<NavBar />
<main class={styles.feed}>
<slot />
</main>
<footer class={styles.footer}>
<div>Copyright 2022 株式会社Pace(パーチェ)</div>
<small>〒903-0804 沖縄県那覇市首里石嶺町</small>
</footer>
<style is:global>
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
* {
box-sizing: border-box;
}
</style>
</body>
</html>
このページは実際、こちらで見ることができます。
Pluginで違うフレームワークの部品を併用できるようにする
Astroは、Reactなどのフレームワークの部品をレンダーするためには、Pluginをインストールする必要があります。
僕の場合は、LitとReactを使って移行したので、astro.config.mjs
は以下のようにしました。
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import lit from "@astrojs/lit";
// https://astro.build/config
export default defineConfig({
integrations: [react(), lit()],
publicDir: "./public",
output: "static", // これでSSRか静的か指定できる
});
このPluginをセットアップする過程はとてもわかりやすく、ドキュメントもよく書けているので、問題なくできました。
Astroで引っかかったところ
Astroへの移行で概念的に誤解して引っかかったところがいくつかありました。
既に前述でも触れましたが、ここでまとめます。
ユーザー認証
ユーザーがログインしていれば、アクセスできるページ、認証されていなければアクセスできたらいけないページをどう分けたらいいのか苦労しました。
Next.jsでもこういうRoute Guard的なロジックが苦手でした。
どうしてもそのページに入ってからリダイレクトするようなロジックになってしまいました。
僕は、Astroだと、MPAなので、リクエストの度に認証状況を確認してリダイレクトできるのではないかと思っていました。
しかし、Astroでも話はややこしいのです。
AstroをSSRモードで使っていれば、リクエストのクッキーを見ることができ、理論上、auth
などのクッキーがなければリダイレクトすることはできます。
しかし、SSRモードでもそのような使い方はそんなに見かけませんでした。
ましてや、静的モードだと、そもそもサーバーが動いていないので、無理です。
結局、<head>
に入った<script>
に、ページが読み込まれた時点でリダイレクトするロジックを埋め込むしかないという結論に至りました。
そのロジックも、localStorage
にトークンを置くような、セキュリティ上軟弱性があるようなやり方じゃなくて、外部APIなどに認証されているかどうかを確認するリクエストを投げるようなやり方になるかと思います。
こうして、概念的にAstroがどう動いているのかを理解していないと、変なことをやろうと時間を浪費します。
SSRもしくは静的レンダーが不可能な部品をどうしたらいいのか
筆者はまたもう一つ重大な問題に遭いました。
ブログの投稿を/blogs/1
のようにURLに合わせて読み込むページを作ろうとしました。
その/blogs/[id].astro
のページでは、読み込まれてから外部APIに投稿の写真と文章を取得する部品が入っていました。
その部品は<SinglePost client:load>
というように指定していたのですが、Astroが毎回エラーを吐いていました。
エラーの内容は、サーバーでレンダーしたHTMLと、Hydration後のブラウザでレンダーされたHTMLが全然合わねえぜ?
というものでした。
結局、SSR自体が不可能な作りをしていたので、こういう時はやれることが二つ:
- 完全にサーバー側でレンダーする
- 完全にブラウザ側でレンダーする
今回の実装では後者を選びましたが、結局、投稿のid
をクエリパラメーターにしてしまったのです。
本当ならば、client:only="lit"
を使えば、実現可能でした
この記事を書いているこのclient:only
の設定に気が付いたので、Qiita書くの大事だなと思いました。
.astro
部品にはJavaScriptロジックを埋め込めない
基本的に、.astro
部品では.addEventListener
など、JavaScriptロジックを埋め込むことができません。
やるとすれば、<script>
を書きます。
結局、.astro
ファイルは全て、HTMLにレンダーされるので、.astro
部品の結果はJSON.stringify
と一緒で関数は入ってこない、と考えれば楽でしょう。
デプロイの考え方はNextとは全然違う
Astro.jsはSSRの道を行くのか、静的コンテンツの道を行くのか分離します。
現時点で静的コンテンツの方が人気かと思います。
Next.jsは、どちらかといえば、SSRが前提にあるような気がします。実はNext.jsも静的コンテンツを出力することができますが、筆者はごく最近まで知らなかったのです。
移行した後、新しいものをデプロイする時に、全体的に考え直す必要がありました。
筆者は、これまではDockerでNext.jsを動かしていましたが、Astroでは、完全に静的コンテンツとして出すようにしました。
実際、FirebaseのHostingを使いました。とても円滑でデプロイしやすかったです。
しかし、Next.jsのようにサーバーとしてAstroを出すのはやや困難でSSRと言っても、Dockerで出せるような情報は現時点ないので、SSRは断念しました。
まとめ
ここまで、Astro.jsのご紹介と筆者が使った感想について話してきましたが、いかがでしょうか?
Astro.jsは、マジで、すごい。
期待の星になっているのはわかります。
開発している方々も、ものすごい熱意を持って開発しているようです。
また、本当にパラダイムシフトを起こしているようで、筆者のようにAstroを誤解するようなエンジニアが多くいるようです。
個人的には、中央ステート、SPAなどによって、利便性とUXが一部分よくなった面もあれば、開発しづらかったり、メモリリークが発生しやすかったりして、負の面も多いと思います。
それに対して、やはり、MPAって、悪いことじゃないよね?
と思っている同胞たちがいて、彼らはその思いを行動に変えてAstroを作ったのだと思っています。
今現在もAstroは本番で使える状態なのですが、誕生したばかりで開発のペースが早い印象を受けています。
皆様もどうぞ試してみてください!