0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

scrollイベントに依存したヘッダー制御をブラウザAPIへ委譲するまでの設計判断

0
Last updated at Posted at 2026-02-28

はじめに

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 が一番重い。topheight を変えると必ずここが走る。Composite が一番軽く、transformopacity はここだけで済む。

パフォーマンス最適化の基本は「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なのか」という設計そのものだ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?