Astro の <Image />, <Picture /> コンポーネントから余計な属性ぜんぶ抜く

本ブログ blog.w0s.jp および個人サイト w0s.jp では先日まで画像リソースを media.w0s.jp(GitHub) に配置し、自動的にサムネイル生成を行っていました。古くは Photoshop のバッチ機能を使っていたのですが、高画素密度ディスプレイの普及と WebP、AVIF フォーマットの登場により、さまざまな環境に最適化した多くのバリエーションを生成することがツラくなってきたために専用のプログラムで自動生成を行うようにしていたものでした。

しかし以下に挙げる理由から、事前に静的画像を生成する方式に変更した次第です。

  • AVIF 対応ブラウザが充分に普及した(Can I use...)ことでフォールバックとしての WebP や JPEG の必要性が薄くなってきた
  • 画像最適化があらかじめ組み込まれているフレームワークも多くなってきた(自作のプログラムをメンテナンスし続けるモチベーションが低下)
  • 画像 URL にアクセスされる度にサムネイル生成の判定と生成実施処理が走るのはパフォーマンス上の問題もある

このうち Astro フレームワークを利用している個人サイト w0s.jp では組み込みで用意されている <Image /> コンポーネント(docs.astro.build)を使用しているのですが、類似の <Picture /> コンポーネント(docs.astro.build)ともども、そのまま使うと困ったことになります。

---
import { Image, Picture } from 'astro:assets';
import image1 from './image1.png';
---

<p><Image src={image1} alt="代替テキスト" width="360" height="240" densities={[2]} format="avif" /></p>
<p><Picture src={image1} alt="代替テキスト" width="360" height="240" densities={[2]} formats={['avif', 'webp']} /></p>
<p>
  <img
    src="/_astro/image1.BbDOn4li_1nmizy.avif"
    srcset="/_astro/image1.BbDOn4li_ZpiN3G.avif 2x"
    alt="代替テキスト"
    loading="lazy"
    decoding="async"
    width="360"
    height="240"
  />
</p>
<p>
  <picture>
    <source srcset="/_astro/image1.BbDOn4li_1nmizy.avif, /_astro/image1.BbDOn4li_ZpiN3G.avif 2x" type="image/avif" />
    <source srcset="/_astro/image1.BbDOn4li_2vWLev.webp, /_astro/image1.BbDOn4li_IhEAg.webp 2x" type="image/webp" />
    <img
      src="/_astro/image1.BbDOn4li_Z1nA1fY.png"
      srcset="/_astro/image1.BbDOn4li_1SV0TH.png 2x"
      alt="代替テキスト"
      loading="lazy"
      decoding="async"
      width="360"
      height="240"
    />
  </picture>
</p>

まず画像のファイル名にランダム文字列のハッシュ値が付くのが困ります。これはそのまま URL の一部になるものですから、本来はユーザーにとって分かりやすくかつクールな URL(W3C) にするのが理想だからです。しかし元画像にリサイズとフォーマット変換を施して静的ファイルとして生成する以上、そのファイル名に関してもなにかしらの自動処理を挟むことは避けられませんから、妥協して受け入れることにしました。

一方で loading="lazy"decoding="async" が付いているのは理解に苦しみます。とくに loading="lazy" に関しては、ヒーローイメージに付けるべきではないのはもちろん、それ以外の画像でもたとえば飛行機に搭乗する直前にターミナル内でページをロードしておいて、機内で(電波を切った状態で)ゆっくりスクロールしながら読むような状況で支障が出るデメリットもありますから、その属性を付けるかどうかはページコンテンツや画像ごとの性質によって個別に判断するべきであって、一律で付けてよいものではないはずです。

  • さらに言うと HTML 仕様では Its purpose is to indicate the policy(WHATWG)(その目的はポリシーを示すことだ)とある一方で The attribute directs the user agent(WHATWG)(ユーザーエージェントに指示する)とも書かれており、実際のブラウザの挙動を見ても提示されたポリシーに従って(ユーザーの意志を確認するなどして)判断するのではなく、読み込みの挙動を強制的に決めるものとなっているのが実情です。loading="lazy" の付いた画像はユーザーの意志に関わらず遅延読み込みが避けられなくなってしまうので(たとえ飛行機搭乗前の状況だとしても!)、その採用は慎重に考慮すべき問題だと思います。

そのうえで Astro の公式マニュアルを一見しただけではこれらをオフにする(両属性を出力しないようにする)方法が存在しないように思えるのですが、属性値を上書きすることはできるので、loading="eager"decoding="auto" を付けてしまえば事実上解除が可能です。同種の要望に対してメンテナーがそのように回答(GitHub)しています。逆にいうと priority 属性(docs.astro.build)で例があるように、コンポーネント独自の属性を設定することで簡単に変更できるようにするつもりはないのだと受け取って良いでしょう。

もしもサイト内の画像の大半が loading="eager" かつ decoding="auto" であることが適切ならば、組み込みの <Image> をラップしたコンポーネントを作ることでデフォルト値を変更する(HTML 仕様と同一に戻す)ことが可能です。

---
import type { ImageMetadata } from 'astro';
import { Image } from 'astro:assets';

interface Props {
	image: ImageMetadata;
	alt: string;
	width: number;
	height: number;
	loading?: 'eager' | 'lazy';
	decoding?: 'async' | 'auto' | 'sync';
}

const { image, alt, width, height, loading = 'eager', decoding = 'auto' } = Astro.props;
---

<Image src={image} alt={alt} width={width} height={height} loading={loading} decoding={decoding} />

さらにミドルウェア(docs.astro.build)の設定に手を加えて、ビルドされる HTML からデフォルト値の属性を除去するとなお良いでしょう。プロジェクトで統一的なルールが定められているのであればそれに従うべきですが、「<form action="get"><button type="submit"> の属性を明示する」はあり得るとしても「<img loading="eager"><img decoding="auto"> の属性を明示する」ルールを課しているケースはなかなか見ないですから、特段の理由がなければ消した方が良いと思います。

以下に Cheerio(cheerio.js.org) を使ってデフォルト値の属性を除去した例を記します。

/* `src/middleware.js|ts` ないし `src/middleware/index.js|ts` */
import { defineMiddleware } from 'astro:middleware';
import * as cheerio from 'cheerio';

export const onRequest = defineMiddleware(async (context, next) => {
  const response = await next();
  const { status, statusText, headers, body } = response;

  if (context.isPrerendered) {
    const $ = cheerio.load(await response.text());

    const $images = $('img');
    $images.each((_index, image) => {
      const $image = $(image);
      if ($image.attr('decoding') === 'auto') {
        $image.removeAttr('decoding');
      }
      if ($image.attr('loading') === 'eager') {
        $image.removeAttr('loading');
      }
    });

    return new Response($.html(), {
      status: status,
      statusText: statusText,
      headers: headers,
    });
  }

  return new Response(body, {
    status: status,
    statusText: statusText,
    headers: headers,
  });
});