-
-
Save kobitoDevelopment/e39dcbf365fdde61d7c8dfaffbfdb82f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| .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; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <div class="grid" id="grid"></div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * @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