Astro 2.x から 3.0 へアップグレードした際に、レスポンシブイメージは対応していないため、ビルトインされている <Image />
コンポーネントの導入は見合わせることにしました。
しかし、本サイトのブログ記事において、パフォーマンスのボトルネックとなっているのは画像ファイルです。また、別のプロジェクトで Astro を使用するときにも、この課題に向き合わなければなりません。
そのため、この記事では Astro でレスポンシブイメージを実装する方法を考えていきます。
- 以降、Astro の
getImage()
関数でレスポンシブイメージを実装する方法を解説していますが、問題点も見つかりました。特に、ホスティングサービス側でビルドする場合には注意が必要です。 - 本記事における Astro のバージョンは
3.1.4
を前提としています。
実装方法の検討
見出し「実装方法の検討」まず、候補として挙がるのは「Astro ImageTools」のようなインテグレーションの導入ですが、できるだけライブラリへの依存度は下げたいので、選択肢には入れませんでした。
ほかに方法はないものかと、Astro のドキュメントを読んでいくと、画像(<Images />
)のページで以下の記述がありました。
現在、組み込みのassets機能には
<Picture />
コンポーネントは含まれていません。代わりに、アートディレクションやレスポンシブ画像を作成するために、HTMLの画像属性
srcset
とsizes
または<picture />
タグを使用して、getImage()
により画像やカスタムコンポーネントを生成できます。
この 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>
要素に指定する src
や alt
の値を指定します。
---
---
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
は、width
と height
を明示するために使用する任意の属性です。指定しない場合には、画像ファイルから自動的にサイズを取得します。loading
も任意ですが、明示しないと loading="lazy"
が設定されます。
それでは、次に <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 />
コンポーネントを見ていきます。
---
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>
)}
ちょっと長いですが、このコードでは以下の処理をしています。
- Props の取得
- 対象となる画像のインポート
- デフォルト値の設定
- SVG の判定
getImage()
関数を使用した画像の変換- HTML コードの記述
それでは、ひとつずつ見ていきましょう。
1. Props の取得
見出し「1. Props の取得」---
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. 対象となる画像のインポート」---
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. デフォルト値の設定」---
const defaultImage = target.default;
if (wh) {
defaultImage.width = wh[0];
defaultImage.height = wh[1];
}
---
ここでは、defaultImage
変数に画像のデフォルトデータを格納しています。
基本的にはインポートした画像のデータをそのまま指定しているのですが、任意の wh
プロパティが指定されている場合には、width
と height
に対して、その値を割り当てています。
4. SVG の判定
見出し「4. SVG の判定」---
const isSvg = defaultImage.format === 'svg';
---
SVG 形式の画像はレスポンシブイメージにする必要がありません。
さきほど取得した画像ファイルからフォーマットの情報を取得できるので、ここでは SVG 形式かどうかを判定しています。
この真偽値は、その後のコードの条件分岐で使用します。
5. getImage() 関数を使用した画像の変換
見出し「5. getImage() 関数を使用した画像の変換」---
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
変数にオブジェクトとして格納しています。
- AVIF 形式 / 横幅
570px
- AVIF 形式 / 横幅
1280px
- WebP 形式 / 横幅
570px
- WebP 形式 / 横幅
1280px
横幅 570px
はモバイルレイアウト用、1280px
はデスクトップレイアウト用です。
画像フォーマットの AVIF は、Safari でサポートされたのは最近で、Microsoft Edge では、いまだにサポートされていません。そのため、フォールバック用の WebP 形式も指定しています。
2024/08/31 追記
Microsoft Edge 121 で AVIF 形式がサポートされました。
getImage()
関数では、ビルトインの <Image />
コンポーネントと同じ属性を指定できます(alt
属性を除く)。上記のコードでは src
で元画像のソースを指定し、format
と width
で画像を変換・加工しています。
なお、SVG の場合には画像変換は不要なので、処理をスキップさせるためにデフォルト画像を返しています。
以上で、画像の変換や加工の指定が完了したので、HTML にコードを記述していきます。
6. HTML コードの記述
見出し「6. HTML コードの記述」---
// 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 コードと画像ファイルが生成されます。
<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 点)「ウェブサイトの健康診断」とします。
Before
見出し「Before」レスポンシブイメージ実装前は、モバイルでのパフォーマンスのスコアは 88 でした。

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

問題点
見出し「問題点」上記のコードによって、カスタムコンポーネントでレスポンシブイメージを実装できましたが、この方法にはいくつか問題点があります。
ホスティングでのビルド時間
見出し「ホスティングでのビルド時間」まず、getImage()
関数を使用して画像を生成すると、その画像の数に比例してビルド時間が長くなります。以下は、本サイトで使用しているホスティングサービス、Cloudflare Pages でのビルド結果で、生成した画像は 524 点です。

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

ただ、ベータ期間中の制約として、キャッシュの有効期間は 7 日間で、データ量の上限はプロジェクト単位で 10 GB です2。
当然ながら、画像が増えるとビルド時間も長くなるので、タイムアウトするリスクもあります3。そのため、今後も様子を見ながら、場合によってはレスポンシブイメージの実装方法や方針の変更、もしくは中止する必要があるかもしれません。
開発モードとの差異
見出し「開発モードとの差異」getImage()
関数は、ビルド時にのみ実行されるため、開発モード(astro dev
)では処理が異なります。
例えば、開発モードでは AVIF 形式で指定した画像は HEIF 形式で表示され、ファイルサイズも大きくなるため、パフォーマンス(表示速度)に大きな違いが生まれます。

この、開発モードで AVIF 形式が HEIF 形式で表示される現象は、どうやら内部的に使用されている Sharp が関係しているようです。画像処理を Squoosh に変更することで、AVIF 形式のまま表示させることができますが、ファイルサイズが大きく表示速度に影響を及ぼす点は変わりません。
この問題は、WebP 形式であれば発生しないので、開発モードでは AVIF 形式を使用しない方法で回避することができます。
---
// 開発モードでは 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 種類に減らせます。
これらのメリット・デメリットを踏まえたうえで、今後の方針を考えていきます。
Astro 3.3
見出し「Astro 3.3」2023/10/13 追記
Astro 3.3 より、<Picture />
コンポーネントと srcset
属性がサポートされました。
まだ、試験的な段階(Experimental)ですが、これにより、レスポンシブイメージががネイティブな機能として実装できるようになりました。
脚注
-
Cloudflare Pages のビルドキャッシュ機能は、ビルド時間の解決策を探しているときに偶然見つけたのですが、どうやら本日(2023 年 9 月 28 日)ベータ版がリリースされた機能のようでした。(Race ahead with Cloudflare Pages build caching | The Cloudflare Blog) ↩
-
Cloudflare Pages では、ビルドのタイムアウトは 20 分です。(Limits | Cloudflare Pages docs) ↩