Astro 2.x から 3.0 へアップグレードした際に、レスポンシブイメージは対応していないため、ビルトインされている <Image /> コンポーネントの導入は見合わせることにしました。

しかし、本サイトのブログ記事において、パフォーマンスのボトルネックとなっているのは画像ファイルです。また、別のプロジェクトで Astro を使用するときにも、この課題に向き合わなければなりません。

そのため、この記事では Astro でレスポンシブイメージを実装する方法を考えていきます。

  • 以降、Astro の getImage() 関数でレスポンシブイメージを実装する方法を解説していますが、問題点も見つかりました。特に、ホスティングサービス側でビルドする場合には注意が必要です。
  • 本記事における Astro のバージョンは 3.1.4 を前提としています。

まず、候補として挙がるのは「Astro ImageTools」のようなインテグレーションの導入ですが、できるだけライブラリへの依存度は下げたいので、選択肢には入れませんでした。

ほかに方法はないものかと、Astro のドキュメントを読んでいくと、画像(<Images />)のページで以下の記述がありました。

現在、組み込みのassets機能には<Picture />コンポーネントは含まれていません。

代わりに、アートディレクションやレスポンシブ画像を作成するために、HTMLの画像属性srcsetsizesまたは<picture />タグを使用して、getImage()により画像やカスタムコンポーネントを生成できます。

<Image /> (astro:assets) | Astro ドキュメント

この getImage() 関数を使えば、レスポンシブイメージを実現できるようなので、こちらを使って実装方法を考えていきます。

Astro コンポーネントの構成

見出し「Astro コンポーネントの構成」

まずは、本サイトのブログ記事の構成です。

ブログ記事は MDX ファイルで管理されており、content/blog/yyyy 以下にコンテンツコレクションとして格納しています。

ブログのコンテンツコレクションと画像コンポーネントの構成
src
   # コンテンツコレクション(ブログ記事)
├── content/blog/2023
   └── article.mdx

   # 画像コンポーネント
├── components/blog
   ├── Figure.astro
   └── Picture.astro

   # 画像ファイル
└── assets/img/blog
    └── article
        ├── img-01.webp
        ├── img-02.webp
        └── img-03.webp

MDX ファイルでは <Figure /> コンポーネントをインポートしますが、そのとき、<img> 要素に指定する srcalt の値を指定します。

MDX ファイルの例
---
---

import Figure from '@components/blog/Figure.astro';

<Figure
  src="/assets/img/blog/article/img-01.webp"
  alt="代替テキスト"
  wh={[1280, 720]}
  loading="eager"
>
  <figcaption slot="figcaption">画像のキャプション</figcaption>
</Figure>

wh は、widthheight を明示するために使用する任意の属性です。指定しない場合には、画像ファイルから自動的にサイズを取得します。loading も任意ですが、明示しないと loading="lazy" が設定されます。

それでは、次に <Figure /> コンポーネントを見ていきます。

<Figure /> コンポーネント
---
import Picture from '@components/blog/Picture.astro';

interface Props {
  src: string;
  alt: string;
  wh?: [number, number];
  loading: 'lazy' | 'eager';
  noborder: boolean;
}

const { src, alt = '', loading = 'lazy', wh, noborder = false } = Astro.props;
---

<figure class:list={['image', { "--noborder": noborder }]}>
  <Picture src={src} alt={alt} wh={wh} loading={loading} />
  <slot name="figcaption" />
</figure>

<style>
/* ... */
</style>

MDX で指定したほとんどの props を <Picture /> コンポーネントに渡しています。

noborder では罫線の有無の指定を、<slot name="figcaption"> は、画像のキャプションを指定したときに表示されます。

最後に、レスポンシブイメージを実装している <Picture /> コンポーネントを見ていきます。

<Picture /> コンポーネント
---
import { getImage } from "astro:assets";

interface Props {
  src: string;
  alt: string;
  wh?: [number, number];
  loading: 'lazy' | 'eager';
}

const { src, alt = '', wh, loading = 'lazy' } = Astro.props;
const fetchpriority = (loading === 'eager') ? 'high' : null;

const images = import.meta.glob('/src/assets/img/blog/**/*');
const target = await images[`/src${src}`]();
const defaultImage = target.default;

if (wh) {
  defaultImage.width = wh[0];
  defaultImage.height = wh[1];
}

const isSvg = defaultImage.format === 'svg';

const image = {
  avif: {
    small: (isSvg ? defaultImage : await getImage({src: defaultImage, format: 'avif', width: 570})),
    large: (isSvg ? defaultImage : await getImage({src: defaultImage, format: 'avif', width: 1280}))
  },
  webp: {
    small: (isSvg ? defaultImage : await getImage({src: defaultImage, format: 'webp', width: 570})),
    large: (isSvg ? defaultImage : await getImage({src: defaultImage, format: 'webp', width: 1280}))
  }
}

// https://github.com/ausi/respimagelint
const sizes = `
  (min-width: 940px) 638px,
  (min-width: 780px) 71.43vw,
  calc(93.04vw - 69px)
`;
---

{isSvg ? (
  <img
    src={defaultImage.src}
    alt={alt}
    width={defaultImage.width}
    height={defaultImage.height}
    loading={loading}
    fetchpriority={fetchpriority}
  />
) : (
  <picture>
    <source
      type="image/avif"
      srcset={`
        ${image.avif.small.src} 570w,
        ${image.avif.large.src} 1280w
      `}
      sizes={sizes}
    />
    <img
      src={image.webp.large.src}
      alt={alt}
      width={defaultImage.width}
      height={defaultImage.height}
      srcset={`
        ${image.webp.small.src} 570w,
        ${image.webp.large.src} 1280w
      `}
      sizes={sizes}
      loading={loading}
      fetchpriority={fetchpriority}
    />
  </picture>
)}

ちょっと長いですが、このコードでは以下の処理をしています。

  1. Props の取得
  2. 対象となる画像のインポート
  3. デフォルト値の設定
  4. SVG の判定
  5. getImage() 関数を使用した画像の変換
  6. HTML コードの記述

それでは、ひとつずつ見ていきましょう。

<Picture /> コンポーネント
---
interface Props {
  src: string;
  alt: string;
  wh?: [number, number];
  loading: 'lazy' | 'eager';
}

const { src, alt = '', wh, loading = 'lazy' } = Astro.props;
const fetchpriority = (loading === 'eager') ? 'high' : null;
---

まず、<Figure /> コンポーネントから受け取った props を変数に格納しています。

このとき、loading="eager" が指定されている場合には、画像を優先的に取得させるために fetchpriority="high" を指定しています。

2. 対象となる画像のインポート

見出し「2. 対象となる画像のインポート」
<Picture /> コンポーネント
---
const images = import.meta.glob('/src/assets/img/blog/**/*');
const target = await images[`/src${src}`]();
---

ここでは、Vite の機能である import.meta.glob() 関数を使用して、ブログ記事の画像ファイル一覧を取得しています。

この結果として、変数 images の中身は以下のようなオブジェクトになります。

const images = {
  '/src/assets/img/blog/article/img-01.webp': () => import('/src/assets/img/blog/article/img-01.webp'),
  '/src/assets/img/blog/article/img-02.webp': () => import('/src/assets/img/blog/article/img-02.webp'),
  '/src/assets/img/blog/article/img-03.webp': () => import('/src/assets/img/blog/article/img-03.webp'),
};

続く const target = ... では、MDX ファイルで指定した画像ファイルのパスをキーに指定して、対象となる画像ファイルをインポートしています。

ここでインポートした画像をもとに、レスポンシブイメージ用に変換・加工していきます。

3. デフォルト値の設定

見出し「3. デフォルト値の設定」
<Picture /> コンポーネント
---
const defaultImage = target.default;

if (wh) {
  defaultImage.width = wh[0];
  defaultImage.height = wh[1];
}
---

ここでは、defaultImage 変数に画像のデフォルトデータを格納しています。

基本的にはインポートした画像のデータをそのまま指定しているのですが、任意の wh プロパティが指定されている場合には、widthheight に対して、その値を割り当てています。

<Picture /> コンポーネント
---
const isSvg = defaultImage.format === 'svg';
---

SVG 形式の画像はレスポンシブイメージにする必要がありません。

さきほど取得した画像ファイルからフォーマットの情報を取得できるので、ここでは SVG 形式かどうかを判定しています。

この真偽値は、その後のコードの条件分岐で使用します。

5. getImage() 関数を使用した画像の変換

見出し「5. getImage() 関数を使用した画像の変換」
<Picture /> コンポーネント
---
const image = {
  avif: {
    small: (isSvg ? defaultImage : await getImage({src: defaultImage, format: 'avif', width: 570})),
    large: (isSvg ? defaultImage : await getImage({src: defaultImage, format: 'avif', width: 1280}))
  },
  webp: {
    small: (isSvg ? defaultImage : await getImage({src: defaultImage, format: 'webp', width: 570})),
    large: (isSvg ? defaultImage : await getImage({src: defaultImage, format: 'webp', width: 1280}))
  }
}
---

ここでは getImage() 関数を使用して、以下の 4 パターンの画像を生成して、image 変数にオブジェクトとして格納しています。

  1. AVIF 形式 / 横幅 570px
  2. AVIF 形式 / 横幅 1280px
  3. WebP 形式 / 横幅 570px
  4. WebP 形式 / 横幅 1280px

横幅 570px はモバイルレイアウト用、1280px はデスクトップレイアウト用です。

画像フォーマットの AVIF は、Safari でサポートされたのは最近で、Microsoft Edge では、いまだにサポートされていません。そのため、フォールバック用の WebP 形式も指定しています。

getImage() 関数では、ビルトインの <Image /> コンポーネントと同じ属性を指定できます(alt 属性を除く)。上記のコードでは src で元画像のソースを指定し、formatwidth で画像を変換・加工しています。

なお、SVG の場合には画像変換は不要なので、処理をスキップさせるためにデフォルト画像を返しています。

以上で、画像の変換や加工の指定が完了したので、HTML にコードを記述していきます。

<Picture /> コンポーネント
---
// https://github.com/ausi/respimagelint
const sizes = `
  (min-width: 940px) 638px,
  (min-width: 780px) 71.43vw,
  calc(93.04vw - 69px)
`;
---

{isSvg ? (
  <img
    src={defaultImage.src}
    alt={alt}
    width={defaultImage.width}
    height={defaultImage.height}
    loading={loading}
    fetchpriority={fetchpriority}
  />
) : (
  <picture>
    <source
      type="image/avif"
      srcset={`
        ${image.avif.small.src} 570w,
        ${image.avif.large.src} 1280w
      `}
      sizes={sizes}
    />
    <img
      src={image.webp.large.src}
      alt={alt}
      width={defaultImage.width}
      height={defaultImage.height}
      srcset={`
        ${image.webp.small.src} 570w,
        ${image.webp.large.src} 1280w
      `}
      sizes={sizes}
      loading={loading}
      fetchpriority={fetchpriority}
    />
  </picture>
)}

まず、isSvg の条件分岐で、SVG の場合には <img> 要素を指定し、それ以外は <picture> 要素を使用したレスポンシブイメージを実装しています。

レスポンシブイメージでは、<picture> 要素による、画像フォーマット(AVIF or WebP)の分岐と、srcset 属性と sizes 属性による画像の出し分けを実装しています。

sizes 属性は、srcset 属性で w ディスクリプタを指定した場合には必須で、メディア条件に応じてどのサイズの画像を表示させたいかを示すために使用します。ただ、正確な値を計算するのは困難なので、以下の Bookmarklet で取得した値を指定しています。

これで、レスポンシブイメージの実装が完了しました。ビルドすると以下のような HTML コードと画像ファイルが生成されます。

HTML コードの例
<picture>
  <source
    type="image/avif"
    srcset="
      /_astro/img-01.9e4001bd_Z1r5XkR.avif 570w,
      /_astro/img-01.9e4001bd_Z1IG4On.avif 1280w
    "
    sizes="
      (min-width: 940px) 638px,
      (min-width: 780px) 71.43vw,
      calc(93.04vw - 69px)
    "
  >
  <img 
    src="/_astro/img-01.9e4001bd_Z29uRjV.webp"
    alt="代替テキスト"
    width="1280"
    height="640"
    srcset="
      /_astro/img-01.9e4001bd_ZiS1PT.webp 570w,
      /_astro/img-01.9e4001bd_Z29uRjV.webp 1280w
    "
    sizes="
      (min-width: 940px) 638px,
      (min-width: 780px) 71.43vw,
      calc(93.04vw - 69px)
    "
    loading="eager"
    fetchpriority="high"
  >
</picture>

パフォーマンスの測定

見出し「パフォーマンスの測定」

レスポンシブイメージの実装ができましたので、PageSpeed Insights を使用して前後のパフォーマンスを測定します。対象ページは、記事内に画像を多く含む(14 点)「ウェブサイトの健康診断」とします。

レスポンシブイメージ実装前は、モバイルでのパフォーマンスのスコアは 88 でした。

PageSpeed Insights のスクリーンショット

レスポンシブイメージ実装後のパフォーマンスのスコアは 94 まで改善しました。

PageSpeed Insights のスクリーンショット

上記のコードによって、カスタムコンポーネントでレスポンシブイメージを実装できましたが、この方法にはいくつか問題点があります。

ホスティングでのビルド時間

見出し「ホスティングでのビルド時間」

まず、getImage() 関数を使用して画像を生成すると、その画像の数に比例してビルド時間が長くなります。以下は、本サイトで使用しているホスティングサービス、Cloudflare Pages でのビルド結果で、生成した画像は 524 点です。

Cloudflare Pages のデプロイの詳細のスクリーンショット。ビルド時間は 5 分 19 秒と表示されている
画像生成を含むビルドに 5 分以上かかってしまう(初回)

ローカル環境ではキャッシュが保持されるので、画像に変更がなければ 2 回目以降の変換処理はスキップされます。Cloudflare Pages でも、ベータ版としてビルドキャッシュの機能が用意されているので、キャッシュが残っている間はビルド時間が短縮されます1

Cloudflare Pages のデプロイの詳細のスクリーンショット。ビルド時間は 47 秒と表示されている
キャッシュされた状態ではビルドにかかる時間は 1 分以内に短縮される

ただ、ベータ期間中の制約として、キャッシュの有効期間は 7 日間で、データ量の上限はプロジェクト単位で 10 GB です2

当然ながら、画像が増えるとビルド時間も長くなるので、タイムアウトするリスクもあります3。そのため、今後も様子を見ながら、場合によってはレスポンシブイメージの実装方法や方針の変更、もしくは中止する必要があるかもしれません。

getImage() 関数は、ビルド時にのみ実行されるため、開発モード(astro dev)では処理が異なります。

例えば、開発モードでは AVIF 形式で指定した画像は HEIF 形式で表示され、ファイルサイズも大きくなるため、パフォーマンス(表示速度)に大きな違いが生まれます。

デベロッパーツールの「Network」タブを表示している状態のスクリーンショット。いくつかの画像のフォーマットが heif と表示されている
開発モードでは AVIF 形式で指定した画像が HEIF 形式になり、読み込み時間も長い

この、開発モードで AVIF 形式が HEIF 形式で表示される現象は、どうやら内部的に使用されている Sharp が関係しているようです。画像処理を Squoosh に変更することで、AVIF 形式のまま表示させることができますが、ファイルサイズが大きく表示速度に影響を及ぼす点は変わりません。

この問題は、WebP 形式であれば発生しないので、開発モードでは AVIF 形式を使用しない方法で回避することができます。

<Picture /> コンポーネント
---
// 開発モードでは AVIF 形式の画像が重くなるので WebP 形式とする
const srcFormat = import.meta.env.PROD ? 'avif' : 'webp';

const image = {
  avif: {
    small: (isSvg ? defaultImage : await getImage({src: defaultImage, format: srcFormat, width: 570})),
    large: (isSvg ? defaultImage : await getImage({src: defaultImage, format: srcFormat, width: 1280}))
  },
  webp: {
    small: (isSvg ? defaultImage : await getImage({src: defaultImage, format: 'webp', width: 570})),
    large: (isSvg ? defaultImage : await getImage({src: defaultImage, format: 'webp', width: 1280}))
  }
}
---

ただ、いずれにせよ開発モードとビルドした後では、画像形式が異なり、別物であるという点には注意が必要です。

Astro でレスポンシブイメージを実装するという、当初の目的こそ達成できましたが、いくつか問題点が残りました。

ひとまずこのまま運用していきますが、ビルド時間の問題に対する代替案としては現時点では以下が考えられそうです。

  • Astro とは別の処理で画像を生成する(Sharp などのライブラリを使用)
  • Cloudflare の Wrangler を使ってビルドする(現状は GitHub 連携)
  • 生成する画像のパターンを少なくする

Astro とは別の処理で画像を生成する

見出し「Astro とは別の処理で画像を生成する」

1 番目の案は、Astro の getImage() は使用せずに、別途 Sharp などのライブラリを使ってローカル環境で画像を生成する方法です。

ホスティング環境でのビルド時間は短くなりますが、ビルドコマンドとは分けて画像生成用のコマンドを用意する必要があり、確実にコマンドを実行するといった工夫が必要になります(例: Git の pre-commit フックを使用)。

Cloudflare の Wrangler を使ってビルドする

見出し「Cloudflare の Wrangler を使ってビルドする」

2 番目の案は、現状は GitHub 連携でデプロイしているのですが、Cloudflare の Wrangler を使用したデプロイに変更する方法です。事前にローカルでビルドしたファイルを Wrangler 経由でデプロイするので、今回実装したコードは変更せずに対応できます。

一方で、プルリクエストで事前にレビューしたうえでマージしてデプロイするといった、GitHub 連携の恩恵は受けられなくなります。

生成する画像のパターンを少なくする

見出し「生成する画像のパターンを少なくする」

最後は、代替案というより妥協案ですが、画像ごとに生成している画像のパターン数(現状は 4 種類)を少なくします。

例えば、AVIF 形式が安心して使える程度に各種ブラウザでサポートされるまでは WebP 形式のみとすることで、生成する画像数を半減できます。もしくは、元画像を WebP 形式の横幅 1280px という前提にすれば、生成する画像のパターンを 3 種類に減らせます。

これらのメリット・デメリットを踏まえたうえで、今後の方針を考えていきます。

2023/10/13 追記

Astro 3.3 より、<Picture /> コンポーネントと srcset 属性がサポートされました。

まだ、試験的な段階(Experimental)ですが、これにより、レスポンシブイメージががネイティブな機能として実装できるようになりました。

脚注

  1. Cloudflare Pages のビルドキャッシュ機能は、ビルド時間の解決策を探しているときに偶然見つけたのですが、どうやら本日(2023 年 9 月 28 日)ベータ版がリリースされた機能のようでした。(Race ahead with Cloudflare Pages build caching | The Cloudflare Blog

  2. Build caching | Cloudflare Pages docs

  3. Cloudflare Pages では、ビルドのタイムアウトは 20 分です。(Limits | Cloudflare Pages docs