写真のギャラリーや無限スクロールのページで、JavaScript から画像を次々に読み込んでいると、スマートフォンのブラウザーが突然落ちたり、ページが勝手に読み込み直されたりすることがあります。多くの場合、原因はメモリの使いすぎです。ここでは、画像がどれくらいメモリを使うのかと、落ちないようにするための対策を説明します。

画像のメモリは、ファイルサイズでは決まらない

JPEG や PNG は圧縮された形式なので、ファイルは小さく済みます。しかしブラウザーは、画像を画面に表示するときに、圧縮を解いて 1 画素 (ピクセル) ごとの色のデータに展開します。1 画素あたり、赤・緑・青・透明度の 4 バイト程度を使うので、展開したあとの大きさはおおよそ次の式で決まります。

使うメモリ ≒ 幅 (px) × 高さ (px) × 4 バイト

たとえば、スマートフォンのカメラで撮った 4032 × 3024 の写真は、JPEG のファイルとしては数 MB でも、展開すると約 49 MB になります。

4032 × 3024 × 4 = 48,771,072 バイト (約 49 MB)

こうした写真を 20 枚読み込めば、それだけで 1 GB 近くになります。画面に小さく表示していても、元の画像が大きければ、ブラウザーは大きなまま展開することがあります。

スマートフォンのブラウザーが 1 つのタブで使えるメモリは、パソコンより少なめです。上限を超えると、OS はブラウザーのタブを止めてしまいます。昔の iOS では、展開できる画像の大きさにも上限が決められていて、それを超えた画像は縮小して表示されたり、表示されなかったりしました。今の端末はメモリが増えましたが、写真の画素数も増えているので、同じ問題は起きます。

表示する大きさに合った画像を用意する

いちばん効くのは、表示する大きさに合わせて縮小した画像を用意することです。幅 400px で表示する場所に 4000px の画像を読み込む理由はありません。img 要素の srcset 属性と sizes 属性を使うと、画面の大きさや画素の密度に合わせて、ブラウザーが適した大きさの画像を選びます。

<img
  src="/images/photo-800.jpg"
  srcset="/images/photo-400.jpg 400w,
          /images/photo-800.jpg 800w,
          /images/photo-1600.jpg 1600w"
  sizes="(min-width: 768px) 50vw, 100vw"
  width="800"
  height="600"
  loading="lazy"
  decoding="async"
  alt="夕方の海辺の写真">
  • srcset には、画像の候補と、それぞれの実際の幅 (400w など) を並べます
  • sizes には、画像を表示する幅を書きます。この例では、画面の幅が 768px 以上なら画面の半分、それ未満なら画面の幅いっぱいです
  • loading="lazy" を付けると、画面に近づくまで読み込みを遅らせます。画面の外の画像をいっぺんに読み込まずに済みます
  • width と height を書いておくと、読み込む前から表示の場所が確保され、レイアウトがずれません

JavaScript で読み込むときは、順番に、必要な分だけ

JavaScript で new Image() を使って画像を先読みするときは、何百枚もいっぺんに読み込まず、順番に読み込みます。decode() メソッドは、画像の展開が終わると解決する Promise を返すので、表示の準備ができてから画面に入れられます。

async function loadImage(url) {
  const img = new Image();
  img.src = url;
  await img.decode(); // 展開が終わるまで待つ (読み込めなければ例外になる)
  return img;
}

async function showGallery(urls, container) {
  // 一度に全部ではなく、1 枚ずつ順番に読み込む
  for (const url of urls) {
    try {
      container.append(await loadImage(url));
    } catch {
      console.warn("読み込めませんでした:", url);
    }
  }
}

decode() を使っても、使うメモリの量は変わりません。表示の途中で画面が固まるのを防ぐための仕組みです。メモリを減らすには、読み込む枚数と大きさを減らします。

見えなくなった画像は手放す

無限スクロールやスライドショーでは、画面から外れた画像を持ち続けると、メモリが増える一方になります。見えなくなった画像は、要素を取り除くか、src を外してブラウザーが解放できるようにします。JavaScript の配列などに Image を入れて取っておくと、画面から消しても解放されないので、参照も一緒に外します。

// スライドショーで、表示中の 1 枚と前後の 1 枚ずつだけに画像を読み込む
function keepNearby(slides, current) {
  slides.forEach((slide, index) => {
    const img = slide.querySelector("img");
    if (Math.abs(index - current) <= 1) {
      if (!img.getAttribute("src")) {
        img.src = img.dataset.src; // data-src に入れておいた URL を設定する
      }
    } else if (img.getAttribute("src")) {
      img.removeAttribute("src"); // 遠いスライドの画像は手放す
    }
  });
}

canvas で画像を加工する場合も同じです。canvas も幅 × 高さ × 4 バイト程度のメモリを使うので、加工のたびに新しく作らず、1 つを使い回します。ブラウザーによっては、canvas の大きさや合計のメモリにも上限があり、超えると何も描かれないことがあります。使い終わった canvas は、width と height を 0 にしておくと、メモリが早く解放されることがあります。

メモリの増え方は、Safari の Web インスペクタや Chrome の DevTools で確かめられます。スマートフォンの実機をパソコンにつなぐと、実機のページを調べられます。

まとめ

  • 画像は展開すると、ファイルサイズに関係なく、幅 × 高さ × 4 バイト程度のメモリを使う
  • スマートフォンのブラウザーは使えるメモリが少なく、使いすぎるとタブが止められる
  • 表示する大きさに合わせた画像を用意し、srcset と sizes でブラウザーに選ばせる。画面の外の画像は loading="lazy" で遅らせる
  • JavaScript で読み込むときは順番に読み込み、decode() で展開を待ってから表示する
  • 見えなくなった画像は src を外し、参照も手放す。canvas は使い回す

参考