Skip to content

Instantly share code, notes, and snippets.

@kobitoDevelopment
Created March 28, 2026 12:49
Show Gist options
  • Select an option

  • Save kobitoDevelopment/e39dcbf365fdde61d7c8dfaffbfdb82f to your computer and use it in GitHub Desktop.

Select an option

Save kobitoDevelopment/e39dcbf365fdde61d7c8dfaffbfdb82f to your computer and use it in GitHub Desktop.
.grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
.item {
width: calc((100% - 2rem) / 3);
aspect-ratio: 16 / 9;
background: #ddd;
}
<div class="grid" id="grid"></div>
/**
* @file Scroll Performance Pattern (Canvas版)
*
* canvasで画像を描画する場合、ブラウザはcanvasのバッキングストア(ピクセルバッファ)を
* 自動的にメモリ管理しない。imgタグであればブラウザがビューポート外の画像のデコード済み
* ピクセルデータを破棄し、必要時にディスクキャッシュから再デコードするが、canvasにはこの
* 仕組みがない。
*
* そのため、無限スクロールでcanvas要素が増え続けると、全canvasのバッキングストアが
* メモリに蓄積される。モバイル端末ではWebプロセスに割り当てられるメモリが限定的
* (数百MB程度)であり、devicePixelRatio 2〜3の高解像度canvasが数十枚蓄積されると
* メモリ上限を超えてブラウザがクラッシュする(「繰り返し問題が起きました」)。
*
* 対策として以下の3つの仕組みを組み合わせる:
*
* 1. 遅延描画: IntersectionObserverでビューポート付近に入ったcanvasだけを描画する
* 2. メモリ解放: ビューポートから離れたcanvasは width=0, height=0 でバッファを解放する
* (HTML仕様上、canvasのwidth/heightを変更するとバッキングストアが再初期化される)
* 3. 同時実行数制限: 描画キューで同時処理数を制限し、スクロール復帰時の
* 一斉再描画によるメモリスパイクを防ぐ
*/
/** @type {number} 1回の読み込みで追加する要素数 */
const BATCH_SIZE = 9;
/** @type {number} 同時に描画処理を実行する最大数 */
const MAX_CONCURRENT = 3;
/** @type {string} IntersectionObserverのrootMargin(ビューポート外のバッファ領域) */
const ROOT_MARGIN = "500px";
/** @type {Array<{id: number, url: string, title: string}>} */
const images = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
url: "https://picsum.photos/seed/" + (i + 1) + "/800/450",
title: "Image " + (i + 1),
}));
/**
* canvasに画像を描画する
* @param {HTMLCanvasElement} canvas - 描画先のcanvas要素
* @param {string} imageUrl - 画像のURL
* @returns {Promise<void>}
*/
const drawCanvas = function (canvas, imageUrl) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = function () {
const dpr = window.devicePixelRatio || 1;
const ctx = canvas.getContext("2d");
canvas.width = Math.floor(img.width * dpr);
canvas.height = Math.floor(img.height * dpr);
ctx.scale(dpr, dpr);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(img, 0, 0, img.width, img.height);
img.src = "";
resolve();
};
img.onerror = function () {
reject(new Error("画像の読み込みに失敗"));
};
img.src = imageUrl;
});
};
/** @type {HTMLCanvasElement[]} 描画待ちのcanvas要素のキュー */
const drawQueue = [];
/** @type {number} 現在描画処理中のcanvas数 */
let activeCount = 0;
/**
* キューからcanvasを取り出して描画する
* 同時実行数が MAX_CONCURRENT 未満の間、キューから順に処理する
* @returns {void}
*/
const processQueue = function () {
while (activeCount < MAX_CONCURRENT && drawQueue.length > 0) {
const canvas = drawQueue.shift();
if (canvas.dataset.drawState !== "pending") continue;
canvas.dataset.drawState = "loading";
activeCount++;
drawCanvas(canvas, canvas.dataset.src)
.then(() => {
if (canvas.dataset.drawState === "loading") {
canvas.dataset.drawState = "drawn";
}
})
.catch(() => {
canvas.dataset.drawState = "false";
})
.finally(() => {
activeCount--;
processQueue();
});
}
};
/**
* ビューポート進入時に描画キューへ追加、退出時にバッファを解放する
* @type {IntersectionObserver}
*/
const visibilityObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const canvas = entry.target;
if (entry.isIntersecting) {
if (!canvas.dataset.drawState || canvas.dataset.drawState === "false") {
canvas.dataset.drawState = "pending";
drawQueue.push(canvas);
}
} else {
if (canvas.dataset.drawState === "pending") {
const idx = drawQueue.indexOf(canvas);
if (idx !== -1) drawQueue.splice(idx, 1);
}
canvas.dataset.drawState = "false";
canvas.width = 0;
canvas.height = 0;
}
});
processQueue();
},
{ rootMargin: ROOT_MARGIN },
);
/** @type {HTMLElement} */
const grid = document.getElementById("grid");
/** @type {number} 次に読み込むimages配列のインデックス */
let offset = 0;
/** @type {boolean} 読み込み中フラグ(多重実行防止) */
let loading = false;
/**
* 最後の要素がビューポートに入ったら次のバッチを読み込む
* @type {IntersectionObserver}
*/
const scrollObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
scrollObserver.unobserve(entry.target);
loadBatch();
}
});
},
{ rootMargin: "200px" },
);
/**
* BATCH_SIZE個のcanvas要素をDOMに追加し、visibilityObserverに登録する
* @returns {void}
*/
const loadBatch = function () {
if (loading || offset >= images.length) return;
loading = true;
const batch = images.slice(offset, offset + BATCH_SIZE);
batch.forEach((data) => {
const canvas = document.createElement("canvas");
canvas.className = "item";
canvas.dataset.src = data.url;
canvas.dataset.drawState = "false";
grid.appendChild(canvas);
visibilityObserver.observe(canvas);
});
offset += batch.length;
loading = false;
if (offset < images.length) {
const items = grid.querySelectorAll(".item");
scrollObserver.observe(items[items.length - 1]);
}
};
loadBatch();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment