0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

レスポンシブ画像の srcset を自動生成する。Canvasで多解像度リサイズ+picture/imgコード出力

0
Posted at

3行まとめ

  • 1枚の画像から複数解像度を一括生成し、srcset / picture のHTMLコードまで吐くツールをブラウザ完結で作った
  • リサイズは Canvas でアスペクト比維持。元画像より大きい幅はスキップ(アップスケールしても劣化するだけ)
  • srcset を正しく書いても sizes がレイアウトと合っていないとブラウザは過大な画像を選ぶ。ここが一番の落とし穴

Lighthouse で「適切なサイズの画像で配信してください」と毎回怒られる。<img srcset> を書けば解決するのは知っているが、複数解像度の画像を手で書き出して、srcset 文字列を手で組み立てるのが面倒で後回しになりがちだ。

ぱんだツールズに作ったレスポンシブ画像 srcset 生成ツールは、1枚の画像をドロップすると、複数幅にリサイズした画像と、そのまま貼れる srcset / picture コードをまとめて出力する。Canvas と JSZip だけで完結する。実装の勘所と、srcset で一番ハマる sizes の話をまとめる。

srcset は「W ディスクリプタ」で書く

srcset の書き方は2系統ある。

  • x ディスクリプタ: image.jpg 1x, image@2x.jpg 2x — 固定表示サイズで、Retina など画素密度別に出し分ける用途
  • W ディスクリプタ: image-640w.webp 640w, image-1280w.webp 1280w — 各画像の実ピクセル幅を宣言し、表示幅に応じてブラウザが選ぶ

レスポンシブで「画面幅に応じて最適サイズ」をやりたいなら W ディスクリプタ一択。このツールも W で出力する。{ファイル名} {幅}w を改行で並べるだけ。

const srcset = images
  .map((img) => `${img.filename} ${img.width}w`)
  .join(',\n         ')

640w は「この画像の中身は横640px」という宣言で、表示サイズではない。ブラウザはこの幅の一覧と後述の sizes を突き合わせて、デバイスの解像度・表示幅に最適な1枚を自分で選んでダウンロードする。

Canvas でアスペクト比を保ってリサイズする

各幅へのリサイズは Canvas。目標幅から、元画像のアスペクト比で高さを算出して描画する。

function resizeImageToWidth(
  img: HTMLImageElement, targetWidth: number, format: OutputFormat, quality: number
): Promise<Blob> {
  const aspectRatio = img.naturalWidth / img.naturalHeight
  const targetHeight = Math.round(targetWidth / aspectRatio)
  const canvas = document.createElement('canvas')
  canvas.width = targetWidth
  canvas.height = targetHeight
  const ctx = canvas.getContext('2d')
  if (!ctx) throw new Error('Canvas の初期化に失敗しました')
  ctx.drawImage(img, 0, 0, targetWidth, targetHeight)
  return new Promise<Blob>((resolve, reject) => {
    canvas.toBlob(
      (blob) => (blob ? resolve(blob) : reject(new Error('画像の生成に失敗しました'))),
      format === 'webp' ? 'image/webp' : 'image/jpeg',
      quality / 100,
    )
  })
}

aspectRatio = naturalWidth / naturalHeight を計算し、targetHeight = round(targetWidth / aspectRatio) で高さを決める。これで縦横比が崩れない。出力形式は toBlob の第2引数で image/webpimage/jpeg を切り替え、第3引数の quality / 100(0〜1)で品質を渡す。WebP変換もブラウザ標準の toBlob('image/webp') でできるので、変換ライブラリは要らない。

元画像より大きい幅はスキップする

地味だが重要な判断。生成ループで、目標幅が元画像の幅を超えるサイズは飛ばす

for (const w of sortedWidths) {
  if (w > imgEl.naturalWidth) continue // 元画像より大きくはしない
  const blob = await resizeImageToWidth(imgEl, w, outputFormat, quality)
  // ...
}

1280px の画像から 1920w を作っても、中身は補間で水増しされた 1920px であって、情報量は 1280px のまま。ファイルだけ重くなって画質は上がらない。srcset の意義は「無駄に大きい画像を配らない」ことなので、ここでアップスケールしたら本末転倒。なので元画像幅を上限にして、それを超える幅は生成対象から外している(UI 上でも「元画像より大」と表示し、選んでも生成対象から外れるようにしてある)。

全幅がスキップ対象になった(元画像が小さすぎた)ときは、エラーで「より小さい幅を追加して」と返す。

srcset と picture の両方を出力する

出力コードは2種類を併記する。用途が違うので両方あると便利。

  • img + srcset: 画面幅に応じてサイズだけ出し分ける。シンプル
  • picture + source: それに加えて形式(WebP/JPEG 等)も出し分けられる

フォールバックの src には、生成した中で一番大きい画像を据える。srcset 非対応の古い環境でも最低限表示される。

const fallback = images[images.length - 1] // 最大幅

const imgTag = `<img
  src="${fallback.filename}"
  srcset="${srcset}"
  sizes="(max-width: 768px) 100vw, 50vw"
  alt="${baseName}"
  width="${fallback.width}"
  height="${fallback.height}"
  loading="lazy"
  decoding="async"
>`

ここで width / height を入れているのは CLS(レイアウトシフト)対策。画像の寸法を先に伝えておくと、ブラウザが読み込み前に表示領域を確保でき、画像が後から入ってガクッとレイアウトがずれるのを防げる。loading="lazy"(遅延読み込み)と decoding="async"(非同期デコード)も付けて、貼った時点でパフォーマンス最適化済みのコードになるようにしている。

形式も出し分けたいときは picture 版を使う。source 側に type="image/webp"srcset を載せ、img をフォールバックに置く。

<picture>
  <source
    type="image/webp"
    srcset="photo-640w.webp 640w,
            photo-1280w.webp 1280w"
    sizes="(max-width: 768px) 100vw, 50vw"
  >
  <img
    src="photo-1280w.webp"
    alt="photo"
    width="1280" height="853"
    loading="lazy" decoding="async"
  >
</picture>

ツールはこの2種類(img+srcsetpicture)を両方まとめて出力するので、プロジェクトのコーディングスタイルに合うほうを貼ればいい。

一番ハマるのは srcset ではなく sizes

ここが本題。srcset を正しく書いても、sizes 属性がレイアウトと合っていないと効果が出ない。むしろ過大な画像を選んでしまう。

sizes は「この画像は各ビューポート幅でどれくらいの表示幅になるか」をブラウザに教える属性。ブラウザは srcset(候補の実幅)と sizes(実際の表示幅)を突き合わせて、初めて「じゃあ 640w を使おう」と決められる。sizes が無い/間違っていると、ブラウザは安全側に倒して一番大きい画像を選びがちになる。

このツールが出力するのはあくまで例。

sizes="(max-width: 768px) 100vw, 50vw"

これは「768px以下なら画像はビューポート全幅(100vw)、それ以上なら半分(50vw)で表示される」という意味。自分のレイアウトに合わせて必ず直す必要がある。たとえばコンテンツ幅が常に 800px 上限なら sizes="800px"、サイドバー付きで本文が常に 60% なら sizes="60vw" のように。

srcset の生成はツールで自動化できるが、sizes だけはそのページのCSSレイアウトを知っている人間にしか正しく書けない。ここを「例のままコピペ」してしまうと、せっかく複数解像度を用意しても狙ったサイズが選ばれず、Lighthouse の指摘も消えない。srcset ツールを使うときは、出力された sizes を自分のレイアウトに合わせて直す、まで含めて1セットだと思っておくといい。

画像とコードは JSZip で一括ダウンロード

生成した複数解像度の画像と、HTMLコードを書いた code.txt を、JSZip で1つの zip に固める。

const zip = new JSZip()
const folder = zip.folder('srcset-images')
for (const img of generatedImages) {
  folder.file(img.filename, img.blob) // 各解像度の画像
}
folder.file('code.txt', htmlCode)     // 貼り付け用コード
const blob = await zip.generateAsync({ type: 'blob' })

画像を1枚ずつダウンロードさせると枚数ぶんクリックが要るので、srcset のように複数ファイルがセットで意味を持つ出力は zip にまとめるのが親切。コードも同梱しておけば、ダウンロードした zip だけで「画像 + 貼るコード」が揃う。

まとめ

レスポンシブ画像の srcset を自動生成するときの要点。

  • レスポンシブ目的なら W ディスクリプタimage-640w.webp 640w)。実ピクセル幅を宣言する
  • Canvas で アスペクト比維持リサイズtargetHeight = round(targetWidth / aspectRatio))。WebP も toBlob('image/webp') でライブラリ不要
  • 元画像より大きい幅はスキップ。アップスケールはファイルを重くするだけ
  • img+srcset(サイズ切替)と picture(形式も切替)を併記。width/height で CLS 対策、loading="lazy" / decoding="async" も付ける
  • 最大の落とし穴は sizes。srcset が正しくても sizes がレイアウトと合わないと過大画像を選ぶ。出力例は自分のCSSに合わせて必ず直す

srcset まわりは「画像の量産」より「sizes を正しく書く」ほうが本質的に難しい。画像生成とコードのひな形はツールに任せて、sizes の調整に頭を使うのが、画像最適化の費用対効果が一番高いやり方だと思う。

ぱんだツールズ では他にも画像の圧縮・形式変換・リサイズや、PDF・CSV・テキスト処理など、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?