はじめに
scrollイベントでUIを制御する設計は、構造的に不安定になりやすい。
scrollは「量」に依存する。でもUI要件は「意味」に依存する。
要件は「ヒーローを通過したら固定」であって、「scrollYが◯pxを超えたら固定」ではない。
数値は状態の代用品にすぎない。代用品に依存すると設計は歪む。
この記事は、チラつきの原因を構造から理解し、scrollイベント依存の設計をブラウザAPIへ委譲するまでの思考プロセスを残したものです。
何を作っていたか
トップページのヒーローセクションを通過したら、ヘッダーが上部に固定されるUI。スクロールで戻ったらヘッダーも初期位置に戻る。
課題:何が起きていたか
症状は2つ。
- スクロール境界付近でヘッダーが意図せずチラつく
- TOPに戻ったときヘッダーが画面外に消える
原因を探ると、構造上の問題が3つ見えた。
① scrollイベントへの逐次依存
window.addEventListener('scroll', () => {
const heroBottom = hero.getBoundingClientRect().bottom;
if (heroBottom <= headerHeight) { ... }
});
scrollイベントは60fpsなら1秒間に60回以上発火する。その度に getBoundingClientRect() でレイアウト情報を読み取り、style.top を書き換えていた。つまり毎フレームLayoutが走る構造になっていた。
② layout thrashingの発生リスク
const heroBottom = hero.getBoundingClientRect().bottom; // 読む → Layout発生
masthead.style.top = heroBottom + 'px'; // 書く → Layout発生
「読む → 書く」が交互に起きると、ブラウザは「直前の書き込みを反映してから読む」を強制される。これをforced reflow(強制的なレイアウト再計算)と呼ぶ。ループや連続処理の中でこれが起きると処理が急激に重くなる。
③ transitionと位置変更の衝突
position の切り替えや top の変更はLayoutを発生させる。transitionが有効なままこれをやると、意図しないアニメーションが走ってチラつく。
概念を整理する
ブラウザの描画パイプライン
ブラウザは画面を表示するとき、毎回この順番で処理を走らせている。
JavaScript → Style → Layout → Paint → Composite
Layout が一番重い。top や height を変えると必ずここが走る。Composite が一番軽く、transform と opacity はここだけで済む。
パフォーマンス最適化の基本は「Layoutをいかに減らすか」。scrollイベント内で top を毎フレーム書き換えるのが危険な理由はここにある。
rAFとダブルrAF
rAF(requestAnimationFrame)は「次の描画フレームの直前に実行して」とブラウザに予約する仕組み。scrollイベントは描画タイミングと無関係に発火するため、rAFで同期させることで中間状態を減らせる。
ダブルrAFが必要な理由は「transitionの衝突」を防ぐため。
masthead.style.transition = 'none'; // transitionを切る
masthead.style.top = '200px'; // 位置を変える
requestAnimationFrame(() => { // フレームA:位置変更を確定
requestAnimationFrame(() => { // フレームB:transitionを戻す
masthead.style.transition = '';
});
});
1回だけだと位置変更とtransition復活が同じフレームで処理され、意図しないアニメーションが走る。
scrollイベント vs ブラウザAPI
ここが設計の核心。
// scrollイベント:JSが毎フレーム自分で監視・判定する
window.addEventListener('scroll', () => {
const rect = hero.getBoundingClientRect(); // 毎フレームLayout発生
if (rect.bottom <= headerHeight) { ... }
});
// IntersectionObserver:ブラウザが交差を検知して通知する
const observer = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting) { ... } // 交差時のみ発火
});
observer.observe(hero);
| scrollイベント | IntersectionObserver | |
|---|---|---|
| 発火タイミング | 毎フレーム | 交差した瞬間だけ |
| 誰が監視するか | JS(メインスレッド) | ブラウザ内部 |
| Layout発生頻度 | 高 | 最小限 |
ResizeObserverも同じ思想。window.resize はウィンドウ全体を監視するが、ResizeObserverはhero要素自体を監視する。フォント読み込みや画像の遅延表示による高さ変化も検知できる。
設計判断のログ
症状: スクロール境界付近でヘッダーがチラつく。TOPに戻るとヘッダーが画面外に消える。
原因仮説: scroll発火ごとのDOM測定(getBoundingClientRect)とposition切替・top再計算により再描画が頻発。境界値付近で判定が揺れ、状態が連続反転していた可能性。TOPに戻ったとき heroBottom がスクロール途中の値を拾い、topにマイナス値が入っていた。
構造上の問題:
- scrollに逐次依存
- 表示制御とレイアウト制御が混在
- JSがレイアウト変更を直接担っている
選択肢:
| 案 | 内容 | 判断 |
|---|---|---|
| A | scrollイベントのまま最適化 | 根本原因を消せない |
| B | rAFで描画同期 | ダメージ軽減にはなる |
| C | スクロール停止後のみ判定 | DOM操作回数は減るが対症療法 |
| D | IntersectionObserverへ移行 | 監視自体をAPIに委譲できる |
| E | ResizeObserverへ移行 | ウィンドウではなく要素を監視できる |
採用: D+Eを採用。B(ダブルrAF)はtransition衝突対策として維持。
副作用: setInitialPosition内の getBoundingClientRect はIOでは代替できないため残存。ただし呼び出しは状態変化時のみに限定されており、scroll毎の発火は解消している。
コード:変更前後の比較
変更前
document.addEventListener('DOMContentLoaded', function () {
if (!document.body.classList.contains('home')) return;
const masthead = document.getElementById('masthead');
const hero = document.querySelector('.hero-top');
if (!masthead || !hero) return;
const GAP = 40;
function setInitialPosition() {
masthead.style.transition = 'none';
const heroBottom = hero.getBoundingClientRect().bottom;
const headerHeight = masthead.offsetHeight;
masthead.style.top = (heroBottom - headerHeight + GAP * 2) + 'px';
requestAnimationFrame(function() {
requestAnimationFrame(function() {
masthead.style.transition = '';
});
});
}
setInitialPosition();
window.addEventListener('resize', setInitialPosition); // ウィンドウ全体を監視
// scrollイベントで毎フレーム判定・DOM測定
function onScroll() {
const heroBottom = hero.getBoundingClientRect().bottom;
const headerHeight = masthead.offsetHeight;
if (heroBottom <= headerHeight) {
if (!document.body.classList.contains('is-scrolled')) {
document.body.classList.add('is-scrolled');
masthead.style.top = '';
}
} else {
if (document.body.classList.contains('is-scrolled')) {
document.body.classList.remove('is-scrolled');
masthead.style.transition = 'none';
const h = hero.getBoundingClientRect().bottom; // 未使用変数
masthead.style.top = (heroBottom - headerHeight - GAP) + 'px';
requestAnimationFrame(function() {
requestAnimationFrame(function() {
masthead.style.transition = '';
});
});
}
}
}
window.addEventListener('scroll', onScroll);
});
変更後
document.addEventListener('DOMContentLoaded', function () {
// トップページ以外では処理しない
if (!document.body.classList.contains('home')) return;
const masthead = document.getElementById('masthead');
const hero = document.querySelector('.hero-top');
// 要素が存在しない場合はエラーを防ぐために終了
if (!masthead || !hero) return;
const GAP = 40;
// 初期位置の計算を1つの関数にまとめる
// 読み込み時・リサイズ時・スクロールで戻ったときの3箇所から呼ぶ
function setInitialPosition() {
masthead.style.transition = 'none';
// 読み取りをまとめて先にやる(layout thrashing対策)
const heroBottom = hero.getBoundingClientRect().bottom;
const headerHeight = masthead.offsetHeight;
// 書き込みはあとにまとめてやる
masthead.style.top = (heroBottom - headerHeight + GAP * 2) + 'px';
// ダブルrAF:位置確定後にtransitionを戻す(チラつき対策)
requestAnimationFrame(function () {
requestAnimationFrame(function () {
masthead.style.transition = '';
});
});
}
setInitialPosition();
// resizeイベント → ResizeObserverへ移行
// ウィンドウではなくhero要素自体のサイズ変化を監視する
const resizeObserver = new ResizeObserver(function () {
setInitialPosition();
});
resizeObserver.observe(hero);
// scrollイベント → IntersectionObserverへ移行
// ヒーローの交差判定をブラウザに委譲する
const intersectionObserver = new IntersectionObserver(function (entries) {
const isHeroVisible = entries[0].isIntersecting;
if (!isHeroVisible) {
// ヒーローが画面外に出た → ヘッダーを上固定
// containsで確認してから操作:無駄なDOM変更を防ぐ
if (!document.body.classList.contains('is-scrolled')) {
document.body.classList.add('is-scrolled');
masthead.style.top = ''; // インラインstyleを消してCSS制御に戻す
}
} else {
// ヒーローが戻った → setInitialPositionで初期位置に統一
// ここで heroBottom を測り直すとスクロール途中の値を拾うバグがあったため
// setInitialPosition() に一本化した
if (document.body.classList.contains('is-scrolled')) {
document.body.classList.remove('is-scrolled');
setInitialPosition();
}
}
}, {
threshold: 0 // 1pxでも画面外に出たら通知
});
intersectionObserver.observe(hero);
});
変更点のまとめ
| 変更前 | 変更後 | |
|---|---|---|
| ヒーロー通過の検知 | scrollイベント(毎フレーム) | IntersectionObserver(交差時のみ) |
| サイズ変化の検知 | resizeイベント(ウィンドウ全体) | ResizeObserver(hero要素のみ) |
| TOPに戻ったときの位置計算 | heroBottomを再測定(バグあり) | setInitialPositionに統一 |
未使用変数 const h
|
あり | 削除 |
| JSが監視するもの | ほぼ全部 | なし(全部ブラウザに委譲) |
残課題
setInitialPosition 内の getBoundingClientRect は現時点で残っている。IntersectionObserverは「交差したかどうか」しか通知しないため、「ヒーローの具体的な座標」が必要なこの処理は置き換えられない。
ただしこれは現時点で問題ではない。getBoundingClientRect が危険なのは「scroll中に毎フレーム呼ばれるとき」であり、現在の呼び出しは以下の3箇所に限定されている。
- ページ読み込み時(1回)
- hero要素のサイズ変化時(ResizeObserverが検知したとき)
- スクロールで戻ったとき(IntersectionObserverが検知したとき)
scroll中には呼ばれない。当初「将来的に改善できる」と書いたが、それは誤りで、現時点ですでに問題は解消されている。
まとめ
今回の改善で変わったのは「JSが監視するものをゼロにした」という構造。
変更前:JSが毎フレーム自分で測って判断する
変更後:ブラウザに通知してもらい、JSは受け取るだけ
scrollを最適化するのは後。
まず疑うべきは「それは本当にscrollで制御すべきUIなのか」という設計そのものだ。