画像のデコードとレンダリング(loading/decodingとIntrinsic Size)
画像でメインスレッドが固まる理由とCLSの原因を内部から理解し、decoding=async・loading=lazy・aspect-ratioを正しく使い分けられる。スレッド配分と発火境界、Intrinsic Sizeを原理から解説。
- 1.画像のデコード(圧縮データからRGBAビットマップへの展開)は重い処理で、放置するとフレーム生成と同じメインスレッドで走り操作が固まる。decoding=asyncやImage.decode()は展開を別スレッドへ寄せ、メインスレッドのブロックを避ける指示になる。
- 2.loading=lazyはビューポートに近づいた時点で取得を始める仕組みで、IntersectionObserver相当のルートマージン(実装依存)を発火境界に持つ。画面外の画像取得を遅らせ初期ロードを軽くするが、LCP対象の画像に付けると表示が遅れる。
- 3.CLSは画像の実寸(Intrinsic Size)が後から判明して箱が広がるために起きる。width/heightまたはaspect-ratioで縦横比を先に渡せば、ブラウザがプレースホルダの高さを確保し、画像到着で行が押し下がらない。
画像が遅さとガタつきを生む2つの理由
<img> 1つを表示するまでに、ブラウザは大きく分けて3つの仕事をします。取得(ネットワークからバイト列を受け取る)、デコード(PNG/JPEG/WebP などの圧縮データを画素の並んだビットマップに展開する)、ペイント(そのビットマップをレイアウト済みの箱へ描く)です。実務でつまずくのは後半2つで、症状は2系統に分かれます。1つはデコードが重くて操作が固まること、もう1つは画像が後から届いて箱が広がりレイアウトがずれることです。前者は decoding 属性とスレッド配分の問題、後者は Intrinsic Size と CLS の問題で、原因も対策も別物です。本稿はこの2つを内部動作から分けて整理します。画像のペイントが描画全体のどこに位置するかは レンダリングパイプラインの内部動作 を合わせて押さえてください。
デコードのスレッド配分とブロッキング
デコードは見た目以上に重い処理です。たとえば 2000×1500 の写真は約300万画素あり、展開後は1画素4バイト(RGBA)で約12MBのビットマップになります。この展開をどのスレッドで走らせるかが体感を左右します。
問題は、ブラウザのメインスレッドがJavaScript実行・スタイル計算・レイアウト・フレーム生成をすべて担う単一スレッドだという点です。ここで同期的に大きな画像をデコードすると、その間フレームが生成されず、スクロールやクリックへの応答が止まります。これが「画像が出る瞬間にカクつく」現象の正体です。
decoding 属性は、このデコードをいつ・どこで行うかの指示です。
| decoding値 | デコードの走り方 | 表示タイミング | 向く用途 |
|---|---|---|---|
| sync | メインスレッドで同期的に展開 | デコード完了まで描画を待つ | 確実に同時表示したい少数の画像 |
| async | 可能なら別スレッドへ寄せる | デコードを待たず先に他を描く | 本文中の多数の画像 |
| auto(既定) | ブラウザが状況で判断 | 実装依存 | 通常はこれで足りる |
decoding="async" は「この画像のデコードでメインスレッドのフレーム生成を止めないでほしい」という意思表示です。多くのブラウザは画像デコードをラスタライズ用の別スレッド(デコーダ/ラスタスレッド)で実行でき、async はその経路を後押しします。ただし保証ではなくヒントで、最終判断はブラウザにあります。
より明示的に制御したいときは HTMLImageElement.decode() を使います。これは取得とデコードをPromiseで待てるAPIで、完了してからDOMへ挿入すれば、挿入フレームでの同期デコードを避けられます。
// デコードを済ませてから挿入する。挿入フレームが詰まらない
const img = new Image();
img.src = "/photo.avif";
img.decode().then(() => {
document.querySelector("#gallery").append(img);
}).catch(() => {
// デコード失敗(壊れた画像など)はここで握る
});
decoding="async" が動かせるのはデコード処理のスレッド配分だけです。取得(ネットワーク)の優先度やレイアウトの確定とは無関係で、これを付けても取得が速くなるわけでも CLS が消えるわけでもありません。固まり対策とガタつき対策は混同しないでください。
loading=lazyの発火境界
loading="lazy" は、画像がビューポートに近づくまで取得自体を始めない遅延読み込みです。画面外にある大量の画像のリクエストを初期ロードから外せるため、回線と接続数を本当に必要な画像へ回せます。
重要なのはいつ取得が始まるか(発火境界)です。仕様は厳密なピクセル距離を定めておらず、実装は内部的に IntersectionObserverとResizeObserverの内部動作 と同種の交差判定を使い、ビューポートを一定のルートマージン分だけ広げた領域に画像が入った時点で取得を始めます。このマージンは固定値ではなく、実効的な接続速度に応じて変わるのが実装の通例です(遅い回線ほど早めに取りに行く)。つまり lazy の発火点は「スクロールで画像が見えた瞬間」より手前で、間に合うよう先読みされます。
<!-- 画面外の本文画像。ビューポート手前まで取得を遅らせる -->
<img src="/figure.avif" width="800" height="450"
loading="lazy" decoding="async" alt="図">
ファーストビューに入る画像、とりわけ LCP(Largest Contentful Paint)要素になる主役画像に loading="lazy" を付けると、取得開始が後ろにずれて LCP が悪化します。lazy は画面外の画像専用です。ファーストビューの主役は逆に fetchpriority="high" や preload で前倒しします。LCP がどう確定するかは Core Web Vitalsの計測アルゴリズム を参照してください。
loading と decoding は直交します。loading は取得を始めるか否かのタイミング、decoding は展開をどのスレッドでやるかで、別々の段を制御します。両者を取り違えると、固まり対策のつもりで lazy を付けて LCP を落とす、といった事故が起きます。リソースの取得優先度そのものは クリティカルレンダリングパスの最適化原理 で扱っています。
Intrinsic SizeとCLS抑止
画像由来のレイアウトシフトは、画像の実寸(Intrinsic Size、本来の幅×高さ)がデコードヘッダを読むまで分からないことから起きます。寸法指定のない <img> は、画像が届く前は高さ0の箱として扱われ、画像到着で本来の高さに広がります。その瞬間、後続の段落や画像が下へ押しやられ、これが CLS として加算されます。スコアの計算式は Core Web Vitalsの計測アルゴリズム の通り、ずれた領域の割合×移動量で決まるため、大きな画像ほど打撃が大きくなります。
対策は、画像が届く前にブラウザへ縦横比を教えておくことに尽きます。手段は2つあり、内部的には同じ「比率からプレースホルダ高さを先に確保する」仕組みに帰着します。
| 手段 | 書き方 | ブラウザ内部の扱い |
|---|---|---|
| width/height属性 | width="800" height="450" | 属性比から aspect-ratio を内部生成し箱高を予約 |
| aspect-ratio | CSSで aspect-ratio: 16 / 9 | 比率を直接指定し箱高を予約 |
| どちらも無し | 指定なし | 実寸判明まで高さ0 → 到着で広がりCLS |
注意したいのは、現代のレスポンシブ実装では width: 100% のように CSSで表示幅を可変にするのが普通だという点です。このとき height 属性を併記してもピクセル高で固定されてしまいそうに見えますが、実際は違います。ブラウザは width/height の両属性から縦横比だけを取り出し、表示幅に応じた高さを比率で自動計算します(この挙動を以前は CSS の aspect-ratio で手当てしていました)。
/* 属性の縦横比を尊重しつつ表示幅は可変にする現代の定石 */
img {
height: auto; /* 高さは比率から自動算出させる */
max-width: 100%;
}
<!-- 属性で 16:9 を伝える。CSSで幅可変でも高さは比率で予約される -->
<img src="/hero.avif" width="1600" height="900" alt="図">
予約に必要なのは縦横比であって正確な実寸ではありません。width="1600" height="900" と width="16" height="9" は箱の予約という点では等価です(比が同じため)。属性に入れる値は実ファイルの比率と一致させることだけが重要で、表示サイズは CSS が決めます。
object-fit を使うときも比率予約は有効です。aspect-ratio で箱を確保し、object-fit: cover で中身を切り抜けば、実寸が来る前から最終的な箱サイズが固定され、シフトが起きません。
取得・展開・予約をまとめて効かせる
3つの段はそれぞれ独立の対策を持ちます。実務では役割で属性を選び分けるのが要点です。
| 目的 | 使うもの | 効く段 |
|---|---|---|
| 初期ロードを軽くする | loading=lazy(画面外のみ) | 取得タイミング |
| 主役画像を速く出す | fetchpriority=high / preload | 取得優先度 |
| スクロールの固まりを防ぐ | decoding=async / decode() | デコードのスレッド |
| ガタつき(CLS)を防ぐ | width/height または aspect-ratio | レイアウト予約 |
| 転送量を減らす | AVIF/WebP・srcset・sizes | 取得サイズ |
loading="lazy" は取得を遅らせ、fetchpriority="high" は取得を急がせます。同じ画像に両方付けると意図が衝突し、遅延読み込みが優先度指定を相殺します。主役画像は high(場合により preload)、脇役画像は lazy、と1枚ごとに役割を決めてから属性を付けてください。
まとめ
画像表示は取得・デコード・ペイントの3段からなり、つまずきは「デコードでメインスレッドが固まる」問題と「実寸が後から判明して箱が広がる CLS」問題に分かれます。decoding="async"(および Image.decode())は展開を別スレッドへ寄せメインスレッドのフレーム生成を止めないヒントで、取得や CLS には無関係です。loading="lazy" はビューポート手前のルートマージン(実装・回線依存)を発火境界に取得を遅らせる仕組みで、画面外専用です。LCP になる主役画像に付けると表示が遅れるため、そこは fetchpriority="high" や preload で前倒しします。CLS は Intrinsic Size 確定前に高さ0の箱が広がって起きるので、width/height 属性か aspect-ratio で縦横比だけ先に渡し、箱高を予約すれば、表示幅が可変でもレイアウトは動きません。各段がパイプラインのどこに当たるかは レンダリングパイプラインの内部動作、CLS の採点は Core Web Vitalsの計測アルゴリズム で補完してください。
Web/フロントエンド Article
画像のデコードとレンダリング(loading/decodingとIntrinsic Size)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
画像
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 6
導入後に効く点
loading=lazyはビューポートに近づいた時点で取得を始める仕組みで、IntersectionObserver相当のルートマージン(実装依存)を発火境界に持つ。画面外の画像取得を遅らせ初期ロードを軽くするが、LCP対象の画像に付けると表示が遅れる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 6
判断チェックリスト
- 自社の用途が「画像 / レンダリング」に近いか確認する。
- 強みである「画像のデコード(圧縮データからRGBAビットマップへの展開)は重い処理で、放置するとフレーム生成と同じメインスレッドで走り操作が固まる。decoding=asyncやImage.decode()は展開を別スレッドへ寄せ、メインスレッドのブロックを避ける指示になる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。