サイドバーを開いたら、グラフの置き場所だけが狭くなった。画面全体の幅は同じなので、window.resizeを待つ実装ではcanvasを描き直せません。JavaScriptで要素自身のサイズ変化を知りたいときは、ResizeObserverでその要素を監視します。
ただし、通知を受けるたびに監視対象を大きくすると、寸法変更と通知が循環します。ここでは、要素の幅に合うcanvasの描画と、ResizeObserver loop completed with undelivered notifications.が出る仕組みを、動くコードで確認します。
画面の幅か、要素の幅か。まず監視対象を選ぶ
ResizeObserverの基本は、コールバックを作り、対象にobserve()することです。通知のentry.targetが変化した要素、entry.contentRectがその内容領域の寸法を示します。複数要素を監視する場合は、渡されたentriesを処理するか、対象のentryを探します。
const target = document.querySelector('#panel');
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
console.log(entry.target, entry.contentRect.width);
}
});
observer.observe(target);
// この監視を使い終えたときに実行する。
// observer.disconnect();
対象が表示されて寸法を持つ場合は、監視を始めた際にも通知されます。以後は監視しているボックスのサイズ変化が対象です。1フレームごとに必ず呼ばれる処理としては使いません。基本動作はMDNのResizeObserverでも確認できます。
| やりたいこと | 選ぶ仕組み |
|---|---|
| 画面全体の幅でメニューを切り替える | windowのresizeやmatchMedia |
| カードが置かれた幅でCSSレイアウトを変える | CSSコンテナクエリ |
| 利用可能な幅を使ってcanvasの絵を描き直す | ResizeObserverと描画処理 |
文字の大きさやカードの並び方だけを変えたいなら、CSSコンテナクエリで済むかを先に確かめます。画面幅をVueの状態へ反映する例は、Vueでウィンドウサイズの変更を検知する方法を参照してください。
読む寸法と、通知を受けるボックスを揃える
幅の数字を比較する前に、余白や枠を含むか、どのピクセル単位かを確認します。
| 値 | 表す寸法 | 使う場面・注意 |
|---|---|---|
contentRect.width / height |
内容領域の物理方向の幅・高さ、CSS px | 今回の横書きcanvasの表示寸法。paddingとborderは含まない |
contentBoxSize[0] |
内容領域のinlineSize / blockSize、CSS px | 書字方向に沿った寸法を扱う |
borderBoxSize[0] |
paddingとborderを含むinlineSize / blockSize、CSS px | 枠まで含む寸法を使う。marginは含まない |
devicePixelContentBoxSize[0] |
内容領域のinlineSize / blockSize、device pixel | canvasの描画バッファ。未対応時の処理も用意する |
inlineSizeが横幅になるのは横書きの場合です。縦書きなら対応する方向が変わります。配列の先頭を使う以下の例は、横書きで複数カラムへ分断しない要素が対象です。定義はMDNのborderBoxSizeを参照してください。
borderBoxSizeを読むだけでは、border-box監視にならない
observe(target)の既定はcontent-boxです。枠まで含むサイズ変化で通知を受けたいなら、監視開始時にobserver.observe(target, { box: 'border-box' })と指定します。
幅200px、左右paddingが各10px、左右borderが各2pxのcontent-box要素で試すと、内容幅は200px、枠までの幅は224pxでした。paddingを各20pxへ変えると枠幅は244pxになり、border-box監視に通知が来ました。一方、content-box監視のコールバックでborderBoxSizeを読んでいても、内容幅が200pxのままなので新しい通知は来ませんでした。
同じ要素にtransform: scale(1.5)を加えた実験でも、新たなResizeObserver通知はありませんでした。レイアウト上の寸法を変えず、見た目だけ拡大する処理の追跡とは分けてください。変形後の外接矩形を読むgetBoundingClientRect()と、監視しているボックスは同じ数字になるとは限りません。
サイドバー開閉に合わせてcanvasを描き直す
次の5ファイルを同じフォルダーに保存し、ローカルHTTPサーバーで開きます。例えばPython 3を利用できる環境なら、そのフォルダーでpython3 -m http.server 5208 --bind 127.0.0.1を実行し、ブラウザでhttp://127.0.0.1:5208/を開きます。JavaScriptモジュールを使うため、HTMLファイルの直接ダブルクリックではなくHTTP経由で確認してください。
「サイドバーを開閉」を押すと、画面の幅を変えずにグラフ領域だけが100px狭くなります。棒の数値は30・70・50の固定値です。下部には、後で比較する循環更新の実験も置いてあります。
index.html
<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>要素の幅でcanvasを描き直す</title>
<link rel="stylesheet" href="style.css">
<main>
<h1>画面幅が変わらなくても、描き直す</h1>
<button id="toggle" aria-expanded="false">サイドバーを開閉</button>
<button id="stop">描画の監視を終了</button>
<p id="size" aria-live="polite">計測中</p>
<div id="workspace">
<aside>補足メニュー</aside>
<section id="panel" aria-label="グラフ領域">
<canvas id="chart" role="img" aria-label="値が30、70、50の棒グラフ"></canvas>
</section>
</div>
<h2>300ミリ秒だけ循環更新を比べる</h2>
<p>各ボタンで幅を120pxへ戻してから実験します。</p>
<button data-mode="direct">直接 +1px</button>
<button data-mode="raf">rAFで +1px</button>
<button data-mode="fixed">180pxへ一度だけ</button>
<div id="probe" aria-hidden="true"></div>
<output id="loop-result">未実行</output>
</main>
<script type="module" src="main.js"></script>
</html>
style.css
* { box-sizing: border-box; }
body { margin: 0; color: #17324d; font: 16px/1.7 system-ui, sans-serif; }
main { max-width: 900px; margin: auto; padding: 20px; }
h1 { font-size: 1.6rem; }
button { margin: 4px 4px 4px 0; padding: 8px 12px; font: inherit; }
#workspace { display: grid; grid-template-columns: 0 minmax(0, 1fr); }
#workspace.open { grid-template-columns: 100px minmax(0, 1fr); }
aside { overflow: hidden; background: #e7edf2; }
#panel { min-width: 0; padding: 16px; border: 2px solid #9db2c4; }
#chart { display: block; width: 100%; height: 180px; writing-mode: horizontal-tb; }
#probe { box-sizing: content-box; width: 120px; height: 20px; background: #0f766e; margin-top: 12px; }
#size, #loop-result { overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
表示寸法はCSS、描画バッファはJavaScriptで決める
canvas.widthとcanvas.heightは描画バッファの寸法です。CSSの表示幅・高さとは分けます。ここではCSS側で幅100%・高さ180pxを確保し、バッファを変えてもcanvasのレイアウト寸法が変わらない形にしています。
device pixelの値が使えるときはその値を採用し、未対応なら内容幅・高さにdevicePixelRatioを掛けて丸めます。後者は近似で、端数の位置や表示倍率によって、ブラウザが実際に割り当てるピクセル数と完全一致するとは限りません。devicePixelContentBoxSizeの対応状況も対象環境で確認してください。
描画関数では、バッファサイズの設定後に変換行列を設定し直し、CSS pxを基準に棒を描きます。幅・高さが0のときは描画を保留します。DPRの変更はresolutionのメディアクエリでも監視し、古いdevice pixelの値を使わず再計算するようにしています。
chart.js
export function mountChart(canvas, onDraw, { preferDevicePixels = true } = {}) {
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2D canvasを利用できません');
let active = true;
let frame = 0;
let latest;
let dprQuery;
let box = 'content-box';
function draw() {
frame = 0;
if (!active || !latest) return;
const { width, height, pixels } = latest;
if (width <= 0 || height <= 0) return;
const pixelWidth = pixels?.inlineSize ?? Math.round(width * devicePixelRatio);
const pixelHeight = pixels?.blockSize ?? Math.round(height * devicePixelRatio);
// CSSで表示寸法を固定し、描画バッファだけを変更する。
canvas.width = Math.max(1, pixelWidth);
canvas.height = Math.max(1, pixelHeight);
ctx.setTransform(canvas.width / width, 0, 0, canvas.height / height, 0, 0);
ctx.fillStyle = '#eef5fa';
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#0f766e';
[30, 70, 50].forEach((value, i) => {
const barHeight = value / 100 * (height - 20);
ctx.fillRect(width * (i + 0.15) / 3, height - barHeight,
width * 0.7 / 3, barHeight);
});
onDraw({ width, height, pixelWidth: canvas.width, pixelHeight: canvas.height, box });
}
function schedule() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(draw);
}
const observer = new ResizeObserver(entries => {
const entry = entries.find(item => item.target === canvas);
if (!entry) return;
latest = {
width: entry.contentRect.width,
height: entry.contentRect.height,
pixels: box === 'device-pixel-content-box' ? entry.devicePixelContentBoxSize?.[0] : null,
};
schedule();
});
if (preferDevicePixels && 'devicePixelContentBoxSize' in ResizeObserverEntry.prototype) {
try {
observer.observe(canvas, { box: 'device-pixel-content-box' });
box = 'device-pixel-content-box';
} catch {
observer.observe(canvas);
}
} else {
observer.observe(canvas);
}
function watchDpr() {
dprQuery?.removeEventListener('change', onDprChange);
dprQuery = matchMedia(`(resolution: ${devicePixelRatio}dppx)`);
dprQuery.addEventListener('change', onDprChange);
}
function onDprChange() {
// 次の通知前に古いdevice pixel値を使わない。
if (latest) latest.pixels = null;
schedule();
watchDpr();
}
watchDpr();
return () => {
active = false;
observer.disconnect();
cancelAnimationFrame(frame);
dprQuery.removeEventListener('change', onDprChange);
};
}
この例のrAFは描画をまとめるためのものです。循環が起きない主な理由は、canvasのバッファを変えてもCSSの幅・高さが変わらないことにあります。内容に応じて親の幅が決まるレイアウトへそのまま移す場合は、この前提が保たれるかを確認してください。
loopエラーは、rAFだけで解決したか判断しない
通知の中で監視対象の幅を1px増やすと、次も幅が変わり、また通知されます。ブラウザは同じ描画更新の中ですべてを処理し続けず、届けられなかった通知があるときにloopエラーを通知します。詳細はResize Observer仕様のエラー通知に定義されています。
書き込みをrequestAnimationFrameへ移すと、このエラーが出なくても、毎フレーム1px増える動きは続きます。警告が消えたことと、不要な更新が止まったことを別に確認します。
次の実験コードは、対象の幅を120pxへ戻してから300ミリ秒だけ動かします。直接加算、rAFで加算、180pxへ揃える処理の3つです。最後の処理では、すでに180pxなら書き込まず戻ります。自動停止用タイマーは実験を短時間で終えるためのもので、タイマーで止めることを本番の修正方法にはしていません。
loop.js
export function runLoopProbe(target, mode, report) {
if (!['direct', 'raf', 'fixed'].includes(mode)) throw new Error('Unknown mode');
target.style.width = '120px';
let frame = 0;
let active = true;
let notifications = 0;
let writes = 0;
let errors = 0;
const expectedWidth = 180;
const onError = event => {
if (event.message === 'ResizeObserver loop completed with undelivered notifications.') errors++;
};
window.addEventListener('error', onError);
const observer = new ResizeObserver(([entry]) => {
notifications++;
const width = entry.contentRect.width;
if (mode === 'fixed' && Math.abs(width - expectedWidth) < 0.5) return;
const nextWidth = mode === 'fixed' ? expectedWidth : width + 1;
const write = () => {
if (!active) return;
target.style.width = `${nextWidth}px`;
writes++;
};
if (mode === 'direct') write();
else {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(write);
}
});
observer.observe(target);
function stop() {
if (!active) return;
active = false;
observer.disconnect();
cancelAnimationFrame(frame);
clearTimeout(timer);
window.removeEventListener('error', onError);
report({ mode, notifications, writes, errors, width: target.getBoundingClientRect().width });
}
const timer = setTimeout(stop, 300);
return stop;
}
ボタンと描画処理は次のファイルでつなぎます。「描画の監視を終了」を押した後もサイドバーは動きますが、バッファの再描画は止まります。ページを離れると監視と実験を解除し、ページがBFCacheから復元された場合は、手動停止していなければ描画の監視を作り直します。
main.js
import { mountChart } from './chart.js';
import { runLoopProbe } from './loop.js';
const canvas = document.querySelector('#chart');
const size = document.querySelector('#size');
const startChart = () => mountChart(canvas, result => {
size.textContent = `CSS: ${result.width} × ${result.height} / 描画バッファ: ${result.pixelWidth} × ${result.pixelHeight} / ${result.box}`;
});
let stopChart = startChart();
let manuallyStopped = false;
const toggle = document.querySelector('#toggle');
toggle.addEventListener('click', () => {
const open = document.querySelector('#workspace').classList.toggle('open');
toggle.setAttribute('aria-expanded', String(open));
});
document.querySelector('#stop').addEventListener('click', event => {
stopChart();
manuallyStopped = true;
event.currentTarget.disabled = true;
});
let stopProbe = () => {};
document.querySelectorAll('[data-mode]').forEach(button => {
button.addEventListener('click', () => {
stopProbe();
document.querySelector('#loop-result').textContent = '実験中';
stopProbe = runLoopProbe(document.querySelector('#probe'), button.dataset.mode, result => {
document.querySelector('#loop-result').textContent = JSON.stringify(result);
});
});
});
window.addEventListener('pagehide', () => {
stopChart();
stopProbe();
});
window.addEventListener('pageshow', event => {
if (event.persisted && !manuallyStopped) stopChart = startChart();
});
警告の回数より、寸法と書き込み回数を見る
Chrome 152.0.7977.84で実行した結果です。実行時間や環境によって通知回数は変わるので、18回そのものを正解にはしません。比較するのは、加算が続いているか、目標の寸法で書き込みが止まるかです。
| 300msの実験 | 書き込み | loopエラー | 停止時の幅 |
|---|---|---|---|
| 通知内で +1px | 18回 | 18回 | 138px。加算中に実験タイマーで停止 |
| rAF内で +1px | 17回 | 0回 | 137px。加算中に実験タイマーで停止 |
| rAF内で180pxへ揃える | 1回 | 0回 | 180px。以後は同じ寸法なので書き込まない |
修正では、まず監視対象へ寸法を書き戻す必要があるかを見ます。読むだけで済むなら、その書き込みを外します。描画が必要なら、今回のcanvasのようにレイアウト寸法へ影響しない更新にできるかを考えます。寸法変更が必要な場合は、監視値から毎回増減させるだけの式ではなく、変更後に安定する目標と終了条件を決めます。
外部ライブラリが関係する場合も、エラーメッセージを一律に隠す前に、どの要素の寸法が変わり続けているかを追ってください。単発の通知だけでは無限更新と断定できません。表示のちらつきや、入力していない間の更新回数も合わせて確認します。
部品を外すときは、監視と予約済みの処理を両方止める
1要素だけ監視から外すのがunobserve(target)、そのobserverの対象をすべて外すのがdisconnect()です。すでに自分で予約したrAFは別に管理します。今回のmountChart()が返す関数は、disconnect、cancelAnimationFrame、DPR変更リスナーの解除をまとめています。
ReactならDOMの用意後に監視を開始し、Effectのcleanupからこの終了関数を呼ぶ形にします。Vueならマウント後に開始し、部品を外す際に終了させます。対象DOMが差し替わる場合は、古い要素の監視を止めてから新しい要素へ付け直してください。どちらも描画のたびにobserverを追加し続けないことが大切です。
今回の検証では、通知後に予約された描画が実行される前に終了関数を呼び、描画が0回で終わることも確かめました。「DOMを削除したから、そのうち止まるはず」とせず、部品の終了処理として扱えます。
自分の画面へ移す前に確認すること
まずウィンドウを動かさず、サイドバーや親カラムだけを変更します。次に、計測しているのがcontent-boxかborder-boxかを確認し、canvasのCSS寸法と描画バッファを別々に表示してみてください。
今回のローカル検証は、画面幅390px・1440pxとDPR 1・2の4条件です。サイドバー開閉による幅100pxの減少、window.resizeが0回のままの再描画、device pixel取得と近似への切り替え、終了後の描画停止を確認しました。Chromeの起動時倍率とブラウザコンテキストのDPRを揃えた自動検証であり、実機のモニター移動や操作中のズーム、他ブラウザ、実際のBFCache往復は未検証です。
実装先では、非表示からの再表示、部品の付け外し、対象ブラウザでの倍率変更も確認します。loopエラーが出たら、rAFの有無だけを見るのではなく、「この書き込み後、寸法はどこで安定するか」を説明できる状態にしてから組み込んでください。