スクロールの内部処理(コンポジタスクロールとアンカリング)
スクロールが指に吸い付くように滑らかな理由と、preventDefault付きのリスナがそれを台無しにする仕組みが分かる。コンポジタスクロール・passive必須の根拠・スクロールアンカリングの補正を解説します。
- 1.スクロールはコンポジタスレッドで合成オフセットを更新するだけで完結し、メインスレッドのJSが詰まっても止まらない。ただしブラウザは「リスナがpreventDefaultするか」を実行するまで知れないため、非passiveなwheel/touchstartリスナがあるとスクロールを止めて結果を待ち、滑らかさが失われる。
- 2.passive: true を付けたリスナは preventDefault を呼べないとブラウザが確約するため、コンポジタは結果を待たずに即スクロールできる。だから wheel/touchmove のようなスクロール起点イベントは passive にするのが原則で、現代のブラウザは一部を既定で passive 扱いにしている。
- 3.スクロールアンカリングは、ビューポート内の基準要素(anchor)を選び、上方への要素挿入や画像読み込みで基準が下にずれた分だけスクロール位置を自動補正して、読んでいる箇所が飛ばないようにする。CLSの一因である挿入由来のシフトを体感上ゼロにする仕組みで、overflow-anchorで無効化もできる。
スクロールは「描画」ではなく「合成オフセットの更新」
スクロールバーを動かしたとき、ブラウザはページ全体を描き直しているわけではありません。ページの内容は既にレイヤーのテクスチャとして保持されており、スクロールとは本質的にそのテクスチャをどの位置で切り出して表示するかという合成オフセット(scroll offset)を更新する操作です。レイヤーとテクスチャの土台は ブラウザのレイヤー化とGPUコンポジット を前提にします。
この更新はコンポジタスレッドで駆動できます。メインスレッド(JS実行・スタイル計算・レイアウトが走る場所)とは独立しているため、JSが長タスクで詰まっていても、指やホイールの動きにスクロールが追従し続けます。これを**コンポジタスクロール(compositor-driven scrolling)**と呼びます。
コンポジタスレッドだけで完結できない事情があると、ブラウザはスクロール処理をメインスレッドへ移します。これが**メインスレッドスクロール(main-thread scrolling)**で、background-attachment: fixed、複雑な position: sticky、そして後述の非passiveなスクロール系リスナなどが引き金になります。この状態では、JSの長タスク中にスクロールが固まる「スクロールジャンク」が起きやすくなります。
なぜ passive イベントが必要なのか
ここで wheel / touchstart / touchmove イベントのリスナが問題になります。これらのリスナは内部で event.preventDefault() を呼んでスクロールそのものをキャンセルできます(例 ピンチズームの抑止やカスタムスクロール)。
決定的なのは、ブラウザがそのリスナが preventDefault を呼ぶかどうかを、リスナを実行し終えるまで知り得ないことです。コンポジタスレッドが指の動きを受け取っても、preventDefault される可能性がある限り、勝手にスクロールしてしまうと「キャンセルされたのに動いた」という矛盾が起きます。そこでブラウザは安全側に倒し、スクロールを保留してメインスレッドにリスナを実行させ、その結果(preventDefaultされたか)を待ってからスクロールを進めます。メインスレッドが長タスクで詰まっていれば、その分スクロールが遅れて固まります。
passive: true は、この問題をリスナ側の事前宣言で解く仕組みです。passive 指定したリスナは「自分は preventDefault を呼ばない」とブラウザに確約します。確約があれば、ブラウザは結果を待つ必要がなくなり、コンポジタスレッドで即座にスクロールを進めつつ、リスナは並行して実行できます。リスナ登録のオプション解釈は イベントターゲットとリスナ登録の内部モデル に詳述しています。
| リスナ種別 | preventDefault | スクロール開始 | JS詰まり時 |
|---|---|---|---|
| passive: true | 呼べない(呼ぶと無視+警告) | 結果を待たず即時 | 影響を受けず滑らか |
| passive: false(既定の旧挙動) | 呼べる | リスナ完了を待つ | スクロールが固まる |
| リスナ無し | — | コンポジタで即時 | 影響なし |
// 悪い:非passive。ブラウザは preventDefault の有無を確認するまでスクロールを保留する
el.addEventListener('touchmove', onMove);
// 良い:passive 宣言で「キャンセルしない」と確約。コンポジタが即スクロールできる
el.addEventListener('touchmove', onMove, { passive: true });
現代のブラウザは window / document / document.body に登録された touchstart と touchmove を既定で passive: true として扱います(wheel / mousewheel も同様の方向)。そのため、これらで preventDefault を効かせたい場合は明示的に passive: false を渡す必要があります。逆に言えば、スクロールをキャンセルする意図がないのに非passiveで登録すると、知らぬ間にメインスレッドスクロールへ転落させてしまいます。
ページ全体を非passiveにするのではなく、ピンチ操作やカスタムジェスチャを処理したい特定の要素にだけ passive: false を当て、それ以外は passive のままにします。あるいは CSS の touch-action(例 touch-action: none)で、JSの preventDefault に頼らずブラウザ既定のジェスチャを宣言的に止める方が、コンポジタスクロールを維持できて有利です。
スクロールアンカリングがレイアウトシフトを補正する仕組み
スクロール中(あるいはスクロール位置を保ったまま閲覧中)に、ビューポートより上で要素が挿入されたり、画像が遅れて読み込まれて高さが確定したりすると、その下にあった内容がまるごと下方向へ押し出されます。何も補正しなければ、いま読んでいた箇所が画面外へ飛び、ユーザーは行を見失います。これは Core Web Vitalsの計測アルゴリズム で扱うCLS(Cumulative Layout Shift)の典型的な発生源でもあります。
**スクロールアンカリング(scroll anchoring)**は、これをブラウザが自動補正する機能です。原理は次の通りです。
1. アンカー選定:
レイアウト後、ビューポート内に見えている要素から「基準要素(anchor node)」を1つ選ぶ。
通常はビューポート上端付近にある、深い階層の安定した要素。
2. シフト検出:
次のレイアウトで、アンカーの画面上のY座標が以前と変わったかを比較する。
上方への挿入や画像確定で、アンカーが本来 dy だけ下にずれていたとする。
3. オフセット補正:
スクロール位置を dy だけ加算して、アンカーを元の画面位置へ戻す。
結果として、挿入は「画面外の上側で起きた」ことになり、見ている箇所は1pxも動かない。
つまりアンカリングは、シフトを起こさせないのではなく、シフトと同量だけスクロール位置をずらして相殺します。挿入が画面外(上側)で起きている限り、ユーザーには何も起きていないように見えます。
スクロールアンカリングが相殺するのは、アンカーより上で起きた高さ変化です。ビューポート内やアンカーより下で起きるシフト(例 読んでいる段落の直後に広告が挿入されて本文が動く)は、見ている箇所を直接動かすため補正対象外で、これは依然CLSとして計上されます。アンカリングは「上に積まれても今の場所を保つ」機能であって、あらゆるシフトを消す万能策ではありません。
overflow-anchor プロパティでこの挙動を制御できます。既定は auto(有効)で、none を指定するとそのサブツリーをアンカー選定から除外します。
/* 無限スクロールで上方追記する実装などで、ブラウザの自動補正と自前制御が衝突する場合に無効化 */
.scroll-container { overflow-anchor: none; }
チャットUIや無限スクロールで「上に古いメッセージを挿入したらスクロール位置を維持する」ロジックを自前で書いている場合、ブラウザのアンカリングと二重に補正され、位置が予想外に飛ぶことがあります。自前で scrollTop を調整する区画では overflow-anchor: none を当て、補正の責務をどちらか一方に寄せるのが安全です。逆に、特に何もしないなら既定の auto のまま任せるのが最も滑らかです。
レイアウトシフトを根本から減らす
アンカリングは強力ですが、対象は「アンカーより上の変化」に限られます。読んでいる箇所そのもののシフトを防ぐには、要素の最終サイズを事前に確保するのが王道です。
| 原因 | 症状 | 根本対策 |
|---|---|---|
| 画像の遅延確定 | 読み込み後に下が押される | width/height 属性や aspect-ratio で領域予約 |
| Webフォント差し替え | テキスト量変化で行が動く | size-adjust / フォールバック調整で寸法を揃える |
| 広告・埋め込みの後挿入 | 本文がガクッと動く | プレースホルダで最小高さを確保 |
これらでビューポート内のシフトを抑えたうえで、ビューポート外(上方)の挿入はスクロールアンカリングに任せる、という役割分担が現実的です。プロパティごとのレイアウトコストは リフローとリペイントのコスト を合わせて押さえると、どの変更がシフトを生むかを事前に見積もれます。
まとめ
スクロール問では、(1) スクロールがコンポジタスレッドの合成オフセット更新で完結し、メインスレッドのJSが詰まっても滑らかなこと、(2) 非passiveな wheel / touchstart / touchmove リスナは preventDefault の可能性ゆえブラウザがリスナ完了を待つため滑らかさを損ない、passive: true はその確約で待ちを消すこと、(3) ルート級ターゲットの touch/wheel は既定で passive 扱いで、止めたいなら passive: false が要ること、(4) スクロールアンカリングはアンカーより上の高さ変化を同量のスクロール補正で相殺し、overflow-anchor で制御できること、の4点が頻出です。
スクロールはコンポジタスレッドで合成オフセットを更新するだけで完結し、メインスレッドから独立して滑らかに動きます。これを崩す主因が非passiveなスクロール系リスナで、ブラウザは preventDefault の有無を確かめるためリスナ完了を待ち、JSが詰まればスクロールも固まります。passive: true は「キャンセルしない」確約でこの待ちを消し、コンポジタスクロールを保ちます(ルート級の touch/wheel は既定で passive)。スクロールアンカリングは、アンカーより上で起きた高さ変化を同量のスクロール位置補正で相殺し、読んでいる箇所が飛ばないようにする機能で、overflow-anchor で無効化できます。ビューポート内のシフトは領域予約で根本から抑え、上方の挿入はアンカリングに任せる役割分担が要点です。基盤は ブラウザのレイヤー化とGPUコンポジット、評価軸は Core Web Vitalsの計測アルゴリズム で補強してください。
Web/フロントエンド Article
スクロールの内部処理(コンポジタスクロールとアンカリング)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
スクロール
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
passive: true を付けたリスナは preventDefault を呼べないとブラウザが確約するため、コンポジタは結果を待たずに即スクロールできる。だから wheel/touchmove のようなスクロール起点イベントは passive にするのが原則で、現代のブラウザは一部を既定で passive 扱いにしている。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「スクロール / ブラウザ」に近いか確認する。
- 強みである「スクロールはコンポジタスレッドで合成オフセットを更新するだけで完結し、メインスレッドのJSが詰まっても止まらない。ただしブラウザは「リスナがpreventDefaultするか」を実行するまで知れないため、非passiveなwheel/touchstartリスナがあるとスクロールを止めて結果を待ち、滑らかさが失われる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。