[Astro] 画像最適化 — カスタムrehypeプラグイン

2026-03-29 hit count image

Astroでマークダウン画像を自動的にAVIF/WebPに変換するカスタムrehypeプラグインを実装する方法を共有します。Sharpを利用した画像変換、キャッシング、lazy loadingなどを扱います。

astro

概要

前回の記事SEO実装で検索エンジン最適化を扱いました。今回の記事ではブログ性能の核心である画像最適化のために開発したカスタムrehypeプラグイン(rehype-picture.mjs)を詳しく説明します。

このプラグインはマークダウンに含まれる<img>要素を自動的に<picture>要素に変換してAVIF/WebP形式の画像を提供します。画像最適化についてのより広い視点はLighthouse性能最適化総合ガイドポストを参考にしてください。

なぜカスタムプラグインなのか

Astroには内蔵<Image>コンポーネントがありますが、マークダウンコンテンツでは使用できません。マークダウンで画像を挿入する方法は2つあります。

<!-- 方法1:マークダウン文法 -->

![Alt text](/assets/images/example.jpg)

<!-- 方法2:HTML imgタグ -->
<img src="/assets/images/example.jpg" alt="Alt text" />

どちらの場合も標準<img>タグとしてレンダリングされるため、Astro<Image>コンポーネントの最適化の恩恵を受けることができません。そのためマークダウン処理パイプラインで自動的に画像を最適化するrehypeプラグインを直接開発しました。

プラグインの動作原理

rehype-picture.mjsの全体フローは以下の通りです。

マークダウンレンダリング

rehype AST(HAST)生成

rehype-pictureプラグイン実行

1. <img>要素探索(HAST element + raw HTML)
2. 元画像からAVIF/WebP変換ファイル生成(Sharp)
3. <img>を<picture>に置換

最終HTML出力

astro.config.mjsでの設定

自作のrehype-pictureプラグインを使用するためにastro.config.mjsmarkdown.rehypePluginsオプションに追加します。

markdown: {
  rehypePlugins: [
    [rehypePicture, { publicDir: new URL('./public', import.meta.url).pathname }],
  ],
},

publicDirオプションで画像ファイルの実際のパスを指定します。

画像変換ロジック

rehype-picture.mjsの画像変換に関するコアロジックを見ていきましょう。

対応形式別の変換戦略

rehype-picture.mjsに定義したgetVariants()関数で元の拡張子に応じて生成する変換ファイルを決定します。

function getVariants(ext) {
  const lower = ext.toLowerCase();
  switch (lower) {
    case '.jpg':
    case '.jpeg':
    case '.png':
      return [
        { ext: 'avif', format: 'avif', mime: 'image/avif', animated: false },
        { ext: 'webp', format: 'webp', mime: 'image/webp', animated: false },
      ];
    case '.gif':
      return [
        { ext: 'webp', format: 'webp', mime: 'image/webp', animated: true },
      ];
    default:
      return [];
  }
}
元の形式生成形式説明
JPG/PNGAVIF + WebP静的画像。AVIFの方が小さいが互換性が低いため両方を提供
GIFWebPアニメーションGIFはanimated WebPに変換
SVG-ベクター画像のため変換しない
外部URL-ローカルファイルのみ処理

Sharpを利用した変換

実際の画像変換はSharpライブラリを使用します。ensureVariant()関数は元画像からAVIFまたはWebP変換ファイルを生成しますが、すでに生成されたことがある場合はスキップするキャッシュロジックも含まれています。

async function ensureVariant(srcPath, destPath, format, isAnimated) {
  const key = destPath;
  if (generationCache.has(key)) return;
  generationCache.add(key);

  if (existsSync(destPath)) return;

  try {
    let pipeline = sharp(srcPath, { animated: isAnimated });
    if (format === 'avif') {
      pipeline = pipeline.avif({ quality: 50 });
    } else if (format === 'webp') {
      pipeline = pipeline.webp({ quality: 80 });
    }
    await pipeline.toFile(destPath);
  } catch {
    // 変換失敗時は無視(元画像を使用)
  }
}
  • AVIF:quality 50に設定。AVIFは低品質でも視覚的な差が少なくファイルサイズを大幅に削減可能
  • WebP:quality 80に設定。より広いブラウザサポートのためのフォールバック
  • animated:GIFをWebPに変換する時にアニメーションを維持

スキップする場合

すべての画像を変換する必要はありません。shouldSkip()関数は変換対象から除外する画像を判別します。外部URLはローカルファイルではないため変換できず、data URIはすでにインライン化されたデータであり、SVGはベクター画像なのでラスター形式に変換するとむしろ品質が低下します。

function shouldSkip(src) {
  if (!src) return true;
  if (src.startsWith('http://') || src.startsWith('https://')) return true;
  if (src.startsWith('data:')) return true;
  if (src.toLowerCase().endsWith('.svg')) return true;
  return false;
}

マークダウン内の画像を見つけて変換する方法

rehypeプラグインはマークダウンをHTMLに変換した結果をツリー構造で扱います。このツリーをHAST(Hypertext Abstract Syntax Tree)と呼び、HTMLの各タグがJavaScriptオブジェクト(ノード)として表現されます。プラグインはこのツリーを巡回しながら<img>ノードを見つけて<picture>に置換する方式で動作します。

このときマークダウン内の画像がツリーに表現される方式が2つあるため、それぞれ異なる処理が必要です。

Case 1:マークダウン文法で書かれた画像

![alt](src)文法で書かれた画像はHASTツリーでelementタイプの<img>ノードとしてパースされます。この場合はノードの属性(properties.src)に直接アクセスして処理できます。

if (node.type === 'element' && node.tagName === 'img') {
  // すでに<picture>の中にある場合はスキップ
  if (parent?.type === 'element' && parent.tagName === 'picture') return;

  const src = node.properties?.src;
  // ... 変換処理
  parent.children[idx] = createPictureNode(node, result);
}

Case 2:HTMLで直接書かれた画像

マークダウン内に<img src="...">タグを直接書いた場合、HASTツリーではパースされていないraw文字列ノードとして残っています。この場合はノード属性に直接アクセスできないため、正規表現で<img>タグを抽出して文字列置換方式で処理します。

if (node.type === 'raw' && typeof node.value === 'string') {
  // 正規表現で<img>タグを抽出
  const imgMatches = [...node.value.matchAll(IMG_TAG_RE)];
  // ... 文字列置換で<picture>に変換
}

このように2つのケースを両方処理するため、マークダウン文法でもHTMLタグでも関係なくすべての画像が自動的に最適化されます。

変換結果

変換前:

<img src="/assets/images/example.jpg" alt="" />

変換後:

<picture>
  <source srcset="/assets/images/example.avif" type="image/avif" />
  <source srcset="/assets/images/example.webp" type="image/webp" />
  <img
    src="/assets/images/example.jpg"
    alt=""
    width="800"
    height="600"
    loading="lazy"
    decoding="async"
  />
</picture>

ブラウザは<source>要素を上から下に確認し、サポートする最初の形式を使用します。AVIFをサポートすればAVIFを、そうでなければWebPを、どちらもサポートしなければ元のJPGをロードします。

ビルド性能の最適化

画像変換はCPU集約的な作業のため、ビルド性能のために2つのキャッシュ戦略を使用します。

Dimensionキャッシュ

const dimensionCache = new Map();

async function getDimensions(filePath) {
  if (dimensionCache.has(filePath)) return dimensionCache.get(filePath);
  const meta = await sharp(filePath).metadata();
  const dims = { width: meta.width, height: meta.height };
  dimensionCache.set(filePath, dims);
  return dims;
}

同じ画像のサイズ情報を繰り返し照会しないようにメモリにキャッシュします。多言語ブログでは同じ画像が3言語のファイルで使用されるため効果的です。

Generationキャッシュ

const generationCache = new Set();

async function ensureVariant(srcPath, destPath, format, isAnimated) {
  const key = destPath;
  if (generationCache.has(key)) return; // すでに生成リクエスト済み
  generationCache.add(key);

  if (existsSync(destPath)) return; // すでにファイルが存在
  // ... 変換実行
}

2段階のキャッシュで不必要な変換を防止します。

  1. generationCache:同じビルドですでにリクエストされた変換かを確認(メモリ)
  2. existsSync:以前のビルドですでに生成されたファイルかを確認(ファイルシステム)

LCP画像プリロード

Head.astroでポストの代表画像をLCP(Largest Contentful Paint)画像としてプリロードします。

{image && <link rel="preload" as="image" href="{image}" fetchpriority="high" />}

代表画像はページで最も大きなコンテンツ要素なので、プリロードしてLCP時間を短縮します。fetchpriority="high"でブラウザにこのリソースの優先順位が高いことを伝えます。

残りの画像はプラグインが自動的にloading="lazy"decoding="async"を追加して初期ロードに影響を与えないようにします。

完了

今回の記事ではAstroブログのためのカスタムrehype-pictureプラグインを見てきました。

  • マークダウンの<img>要素を自動的に<picture>要素に変換
  • Sharpを利用したAVIF(quality 50)/WebP(quality 80)画像生成
  • マークダウン文法の画像とHTML直接記述の画像の両方を処理
  • dimensionCachegenerationCacheでビルド性能最適化
  • LCP画像プリロードとlazy loadingでロード性能改善

次の記事コメントシステムと広告連携ではUtterancesコメントシステムとGoogle AdSense広告連携方法を扱います。

シリーズ案内

このポストはJekyllからAstroへのマイグレーションシリーズの一部です。

  1. JekyllからAstroへマイグレーションした理由
  2. Astroのインストールとプロジェクト構成
  3. Content Collectionsとマークダウンマイグレーション
  4. 多言語(i18n)実装
  5. SEO実装
  6. 画像最適化 — カスタムrehypeプラグイン
  7. コメントシステム(Utterances)
  8. 広告連携(Google AdSense)
  9. Pagefindを利用した検索実装
  10. レイアウトとコンポーネントアーキテクチャ
  11. GitHub Pagesデプロイ
  12. ソーシャル共有自動化スクリプト
  13. トラブルシューティングとヒント

私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!

アプリ広報

今見てるブログを作成たDekuが開発したアプリを使ってみてください。
Dekuが開発したアプリはFlutterで開発されています。

興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。



SHARE
Twitter Facebook RSS