🚀

Astroで画像を最適化にして使い分けるの巻

2024/01/16に公開

はじめに

ども、もりみちです。
Astroに組み込まれている、<Image>コンポーネントやgetImage()って画像を最適化できてとても便利ですよね。今回は画像を最適化した後に任意のメディアクエリを使って画像を使い分けたりしていきたいと思います。

対象にする人

  • astroで画像をwebpに変換したい人
  • 暇な人

本編の巻

そもそもなんで今回コンポーネントを作ったかというと、画像を最適化させる度に画像をimportして<Image />もしくはgetImage()もimport。別のファイルでもimport。別のファイルでもimport。別のファイルでもry。といった現象が起こったり、画像を大量にimportした暁にはこのようにフロントマターがアホみたいに埋まってしまいました。

---
import { Image } from 'astro:assets';
import hoge01 from 'src/images/hoge_01.jpg';
import hoge02 from 'src/images/hoge_02.jpg';
import hoge03 from 'src/images/hoge_03.jpg';
import hoge04 from 'src/images/hoge_04.jpg';
import hoge05 from 'src/images/hoge_05.jpg';
import hoge06 from 'src/images/hoge_06.jpg';
import hoge07 from 'src/images/hoge_07.jpg';
import hoge08 from 'src/images/hoge_08.jpg';
import hoge09 from 'src/images/hoge_09.jpg';
import hoge10 from 'src/images/hoge_10.jpg';	
---
<Image src={hoge01} alt=""  />	
<Image src={hoge02} alt=""  />	
<Image src={hoge03} alt=""  />	
<Image src={hoge04} alt=""  />	
<Image src={hoge05} alt=""  />	
<Image src={hoge06} alt=""  />	
<Image src={hoge07} alt=""  />	
<Image src={hoge08} alt=""  />	
<Image src={hoge09} alt=""  />	
<Image src={hoge10} alt=""  />	

画像を最適化する度に使用する画像と<Image />もしくはgetImage()を何度もimportするのが疲れた私が作成したコンポーネントがこちらになります。
今回作成したコンポーネントはpc用、sp用の2枚の画像を切り替えるもしくは最適化した画像を出力させるだけの簡単な構造になっております。
※コンポーネント名は仮にImageFrameとしますね。

コンポーネント
---
import type { ImageMetadata } from 'astro';
import { getImage } from 'astro:assets';

type Props = {
  srcPaths: [string, string?];
  mediaQuery?: number;
  classNames?: string;
  alt?: string;
  loading?: 'lazy' | 'eager';
  format?: 'webp' | 'avif';
};

const { srcPaths, mediaQuery = 768, classNames, alt = '', loading = 'lazy', format = 'webp' } = Astro.props;

const importImage = async (src: string) => {
  const imageFile = import.meta.glob<{ default: ImageMetadata }>('/src/images/**/*');

  if (!imageFile[`/src/images/${src}`]) return;
  const moduleImage = await imageFile[`/src/images/${src}`]();

  const { default: imageSrc } = moduleImage;
  const image = await getImage({ src: imageSrc, format: format });
  const { loading: loadingMode, ...attributes } = image.attributes;
  return { src: image.src, attributes };
};

const importImages = async () => {
  return Promise.all(
    srcPaths.map(async (src) => {
      if (!src) return;
      return importImage(src);
    }),
  );
};

const images = await importImages();
---

{
  (
    <>
      {images.length === 1 ? (
        <img src={images[0]?.src} {...images[0]?.attributes} loading={loading} alt={alt} class:list={[classNames]} />
      ) : (
        <picture class:list={[classNames]}>
          <source media={`(min-width: ${mediaQuery}px)`} srcset={images[0]?.src} />
          <img src={images[1]?.src} {...images[1]?.attributes} loading={loading} alt={alt} />
        </picture>
      )}
    </>
  )
}
使用時
---
import ImageFrame from '../modules/ImageFrame.astro';
---
<ImageFrame srcPaths={['hoge_1.jpg']} />
もしくは
<ImageFrame srcPaths={['hoge_1.jpg','hoge_2.jpg']} mediaQuery='900' />	

解説の巻

一行ずつ説明させていただきます。

import type { ImageMetadata } from 'astro'; 

AstroのImageMetadata型をインポートしています。

import { getImage } from 'astro:assets';

Astroのアセット管理システムからgetImage関数をインポートしています。

type Props = {
  srcPaths: [string, string?];
  mediaQuery?: string;
  classNames?: string;
  alt?: string;
  loading?: 'lazy' | 'eager';
};

こちらはコンポーネントに渡されるプロパティの型定義です。

  • srcPaths: string[];は、画像ファイルのパスを表す文字列を配列で管理します。
  • mediaQuery?: string;は任意のビューポートのサイズで画像を切り替える際に使用します。
  • classNames?: string;はclassをつけたい人用です。お好きにどうぞ。
  • alt?: string | '';は見ての通り、画像の代替テキストです。
  • loading?: 'lazy' | 'eager';はloadingのプロパティを管理してます。
const { srcPaths, mediaQuery = 768, classNames, alt = '', loading = 'lazy', format = 'webp' } = Astro.props;

Astroのpropsから必要なプロパティを抽出し、デフォルト値を設定しています。

const importImage = async (src: string) => { ... };

特定のソースパスから画像をインポートする非同期関数です。

const imageFile = import.meta.glob<{ default: ImageMetadata }>('/src/images/**/*');

import.meta.globを使用して、/src/images/にある全ての画像ファイルを検索し動的にインポート。

if (!imageFile[`/src/images/${src}`]) return;

ここで画像ファイルが存在しない場合は処理を中断させます。

  const moduleImage = await imageFile[`/src/images/${src}`]();
  const { default: imageSrc } = moduleImage;
  const image = await getImage({ src: imageSrc, format: format });
  const { loading: loadingMode, ...attributes } = image.attributes;

画像のソース(URL)を抽出後、getImage関数を使用して、指定されたフォーマットで画像を処理しています。この処理により、最適化された画像が生成します。

const { loading: loadingMode, ...attributes } = image.attributes;

propsの値でloading属性を変更させたいのでここでloadingだけを取り除きます。

return { src: image.src, attributes };

ここで、画像のソース("src")と先ほどloading属性を省いたattributesを返します。

const importImages = async () => { ... };

この関数は、propsのsrcPaths(画像のパス情報)に対してimportImage関数を非同期に実行し、それらの結果を配列として返します。Promise.allを使用することで、複数の非同期処理を並行して行い、すべての処理が完了するのを待たせてます。

const images = await importImages();

最後に、importImages関数を実行して、その結果をimages変数に格納させてます。

{
  (
    <>
      {images.length === 1 ? (
        <img src={images[0]?.src} {...images[0]?.attributes} loading={loading} alt={alt} class:list={[classNames]} />
      ) : (
        <picture class:list={[classNames]}>
          <source media={`(min-width: ${mediaQuery}px)`} srcset={images[0]?.src} />
          <img src={images[1]?.src} {...images[1]?.attributes} loading={loading} alt={alt} />
        </picture>
      )}
    </>
  )
}

そして、srcPathsの値が1つの場合は
<Image />コンポーネントのように出力させます。
srcPathsの値が2つの場合は
<picture>タグを使用してレスポンシブな画像を出力させます。

<ImageFrame srcPaths={['hoge_1.jpg']} />
/* もしくは */
<ImageFrame srcPaths={['hoge_1.jpg','hoge_2.jpg']} mediaQuery={900} />	
<img 
  src="/_astro/hoge_1.webp"
  width="1600"
  height="900"
  decoding="async"
  loading="lazy"
  alt=""
/>
<!-- もしくは -->
<picture >
  <source media="(min-width: 900px)" srcset="/_astro/hoge_1.webp"/>
  <img src="/_astro/hoge_2.webp" width="750" height="1030" decoding="async" loading="lazy" alt="" />
</picture>

課題点

  • 今回、2枚の画像を単純にレスポンシブで切り替えるという簡単な仕様となってる為、画像を3枚もしくは4枚と複数の画像を切り替える場面に対応できてない。(そんなケースあるか?)
  • ビルド時に指定された画像を importして、WebP形式に変換する処理を行っているので大量の画像を変換した場合、ビルド時間の増加につながるのでこのコンポーネント向いてないかもしれない...

まとめ

今まで、最適化する際は対象の画像をその都度importして書いてたので、このやり方だと多少は楽なのかな?って感じました。本記事で誤ってる情報や他に良い方法を知ってる方がいましたら教えてください。
何卒何卒です。

Discussion