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/webp か image/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+srcset と picture)を両方まとめて出力するので、プロジェクトのコーディングスタイルに合うほうを貼ればいい。
一番ハマるのは 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 にも同じ内容を投稿しています。