写真のギャラリーや無限スクロールのページで、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は使い回す