症状:スクロールに処理を連動させたらカクついた
ページのスクロールに合わせて何かを動かす実装はよくあります。目次の「今どこを読んでいるか」をハイライトするスクロールスパイ、上部の進捗バー、要素をふわっと動かす視差効果などです。こうした処理を window や要素の scroll イベントに書くと、スクロールがなめらかに動かず、指で弾いた勢いにハイライトの更新が遅れてついてくる、いわゆる「カクつき」が起きることがあります。特にスマートフォンや、要素数の多い長いページで顕在化します。
原因を一言でまとめると、scrollイベントは1回のスクロールで大量に発火し、そのハンドラの中に「重い処理」を書いてしまっていることにあります。ただし「発火が多い」ことと「1回が重い」ことは別の問題で、効く対策も違います。まずはこの2つを切り分けます。
原因:「発火が多い」×「1回が重い」の掛け算
scrollのカクつきは、次の要因が重なって起きます。原因は大きく分けて次の4つです。
原因1:scrollイベントは1操作で数十〜数百回発火する(発火が多い)
scrollイベントは「スクロール位置が変わるたび」に発火します。マウスホイールを一度回す、指で一度弾くだけでも、位置は連続的に変わるため、ハンドラは数十回から数百回呼ばれます。ここに処理を書くと、その回数ぶんだけ処理が実行されます。
原因2:ハンドラ内でレイアウト値を読むと強制同期レイアウトが起きる(1回が重い)
getBoundingClientRect()・offsetTop・scrollHeight といった「要素の位置やサイズ」を読み取ると、ブラウザは最新の値を返すためにその場でレイアウト計算を確定させます。これが強制同期レイアウト(forced synchronous layout)で、1回あたりのコストが高い処理です。スクロールスパイのように全セクションの位置を読むと、これが要素数ぶん積み重なります。
原因3:読み取りと書き込みを交互にやると倍増する(レイアウトスラッシング)
位置を読む→スタイルを書き換える→また別の要素の位置を読む、と読み書きを交互に繰り返すと、書き込みでレイアウトが「汚れた」状態になり、次の読み取りのたびに再計算が走ります。これがレイアウトスラッシングで、原因2の強制同期レイアウトが何度も繰り返されて一気に重くなります。
原因4:リスナーがpassiveでないとスクロール自体を待たせる
scrollやtouchのリスナーを普通に登録すると、ブラウザは「ハンドラが preventDefault() でスクロールを止めるかもしれない」と考え、ハンドラの実行を待ってからスクロールを反映することがあります。ハンドラが重いほど、スクロールそのものが引っかかります。
つまりカクつきは「発火が多い」×「1回が重い」の掛け算です。発火回数を減らす(間引く)だけでも、1回を軽くする(レイアウト読み取りを減らす)だけでも効きますが、両輪でやるのが基本です。そしてもう一段上に、そもそもscrollイベントを使わずに済ませられないかという選択肢があります。多くのケースは、これが最も効きます。
デモ:発火回数を見比べる
下は目次付きの本文(スクロールスパイ)です。右側をスクロールすると、左のナビが現在地に合わせてハイライトされます。上のタブで実装を切り替えられます。「scrollイベント発火」と「現在地の測定(レイアウト読み取り)」の回数がそれぞれカウントされるので、スクロールしながら数字を見比べてください。
1. 受付
申請フォームから届いた内容を受け付けるフェーズです。入力の抜け漏れや形式のチェックはこの段階でまとめて行い、後工程に不備を持ち越さないようにします。スクロールして次のセクションへ進んでください。
2. 審査
受け付けた内容を担当者が確認します。金額や権限に応じて確認する項目が変わり、条件を満たさないものはここで差し戻されます。ハイライトが「2. 審査」に移ったでしょうか。
3. 承認
審査を通った申請を承認します。承認者が複数人いる多段承認では、全員の承認がそろって初めて次に進みます。この行がハイライトされているとき、右側の見出しも「3. 承認」のはずです。
4. 発送
承認済みの案件を実際の処理(発送・登録・通知など)に回します。ここまで来ると差し戻しはできず、取り消しには別の手続きが必要になります。もう少しで最後です。
5. 完了
すべての処理が終わり、記録として残るフェーズです。完了した案件は一覧から検索できるようになり、あとから経緯を追えます。いちばん下まで来ると、ナビは「5. 完了」を指します。
タブを切り替えるとカウンターは0に戻ります。ガードなしとthrottleは、モダンブラウザではscrollがもともと1フレームに約1回しか発火しないため、2つの数字が近い値で並走します(throttleで回数が減らないのは正しい挙動です。理由は下の「対策1」で解説)。IntersectionObserverだけはscroll発火が0のまま現在地が更新されます。
ガードなしでは、scrollが発火するたびに全セクションの getBoundingClientRect() を読んで現在地を計算しているため、2つの数字がぴったり同じペースで増えます。この測定が原因2の強制同期レイアウトで、要素が多いページほど1回のコストが上がり、発火回数と掛け算されてカクつきになります。
対策1:処理を間引く(throttle+requestAnimationFrame)
scrollイベント自体を減らすことはできませんが、ハンドラの中身を毎回動かさないことはできます。画面の更新は1フレーム(約16ミリ秒)に1回で十分なので、requestAnimationFrame で「次の描画タイミングまでに1回だけ」実行するようまとめます。これがスクロール連動でのthrottle(間引き)の定番です。
// scrollは何度でも発火してよい。実処理はrAFで1フレーム1回に間引く
var ticking = false;
scrollEl.addEventListener('scroll', function () {
if (ticking) return; // 予約済みなら何もしない
ticking = true;
requestAnimationFrame(function () {
updateActive(); // 重い処理(現在地の測定)はここで1回だけ
ticking = false;
});
}, { passive: true }); // ← passiveも付ける(対策3)
ここで大事な注意点があります。モダンブラウザのscrollイベントは、もともと描画の直前にまとめて発火するため、実質1フレームに約1回しか飛びません。そのためデモのthrottleタブでも、scroll発火と測定の回数はガードなしと近い値で並走します。「rAFで間引いたのに回数が減らない」のは正しい挙動で、ブラウザがすでにフレーム単位まで間引いているからです。
では何のためにrAFで包むのか。ひとつは上限の保証です。古いブラウザや一部のデバイスがscrollを1フレームに何度も飛ばす環境でも、測定を1フレーム1回に抑えられます。もうひとつは実行タイミングで、レイアウトの読み取りをscrollイベント内で同期的に走らせず、描画の直前にまとめて行うことで、対策2のスラッシングを避けられます。requestAnimationFrame は画面のリフレッシュと歩調が合うため、スクロール連動の見た目更新にはこれが素直です。
逆に、回数そのものを目に見えて減らしたい場面は、ブラウザがフレーム同期させない resize や mousemove/pointermove、あるいは setTimeout で「100msに1回」のようにフレームより粗く間引くケースです。そこではthrottleの効果がカウントにはっきり表れます。scrollに関しては、回数はもとからフレーム同期なので、狙いは「回数を減らす」より「重い処理を正しいタイミングで1回だけ動かす」に置きます。
対策2:1回を軽くする(読み取りと書き込みを分ける)
間引いても、1回の処理が重ければカクつきは残ります。原因3のレイアウトスラッシングを避けるには、「読む」フェーズと「書く」フェーズを分けるのが基本です。位置の読み取りを先にまとめて済ませ、そのあとにスタイルの書き換えをまとめて行います。
// NG: 読む→書く→読む…で毎回レイアウトが再計算される
items.forEach(function (el) {
var top = el.getBoundingClientRect().top; // 読む
el.style.opacity = top < 300 ? 1 : 0.3; // 書く(次の読み取りを重くする)
});
// OK: 先に全部読んでから、まとめて書く
var tops = items.map(function (el) {
return el.getBoundingClientRect().top; // 読むだけ
});
items.forEach(function (el, i) {
el.style.opacity = tops[i] < 300 ? 1 : 0.3; // 書くだけ
});
そもそもレイアウト値を読まずに済むなら、それがいちばん軽くなります。スクロール量だけが必要なら scrollEl.scrollTop(や window.scrollY)で足り、要素ごとの getBoundingClientRect() は不要なことも多いです。次の対策4のIntersectionObserverは、この「位置の読み取り自体をやめる」を仕組みで実現します。
対策3:passiveリスナーにする(ただし誤解に注意)
scrollやtouchのリスナーには { passive: true } を付けます。これは「このハンドラは preventDefault() を呼びません」という宣言で、ブラウザはハンドラの完了を待たずにスクロールを進められます。スクロールの引っかかり(原因4)を防ぐ効果があります。
scrollEl.addEventListener('scroll', onScroll, { passive: true });
passiveはスクロールを待たせないための宣言であって、ハンドラの中身を軽くするものではない。
ここが誤解されがちな点です。passiveを付けても、ハンドラ内の強制同期レイアウトや重い計算はそのままのコストで走ります。つまりpassiveは「対策1(間引き)」「対策2(軽くする)」の代わりにはなりません。3つはそれぞれ別の効き方をするので、併用します。なお touchstart / touchmove / wheel は主要ブラウザでは既定がpassive寄りに変わっている場合がありますが、明示しておくと意図が読み手に伝わり、環境差にも左右されません。挙動の最新の扱いはMDNのaddEventListenerで確認できます。
対策4:そもそもscrollイベントをやめる
ここまでは「scrollイベントを使い続ける前提」での手当てでした。ですが多くのUIは、scrollイベントを使わずに書けます。やりたいことから逆算して手段を選ぶと、そもそも重いハンドラを書かずに済みます。
| やりたいこと | 最適な手段 | scrollイベント |
|---|---|---|
| ヘッダーやサイドバーを追従させる | position: sticky(CSSのみ) |
不要 |
| 要素の出現・画面内を検知する(遅延読み込み・無限スクロール・現在地ハイライト) | IntersectionObserver |
不要 |
| スクロール量に連動させる(進捗バー・視差効果) | scroll+throttle(rAF)+passive | 必要(間引く) |
| 入力やリサイズが落ち着いてから1回だけ実行する | debounce | 入力系で使用 |
追従だけなら position: sticky でJavaScriptは要りません(追従ヘッダーの事例)。要素が画面に入ったかを知りたいだけなら IntersectionObserver が最適で、遅延読み込みや無限スクロール(無限スクロールの事例)、デモのスクロールスパイもこれで書けます。scrollイベントが本当に必要なのは、進捗バーのように「スクロール量そのもの」に連続的に連動させたい場合に絞られます。
ここで throttleとdebounceの違いにも触れておきます。throttleは「一定間隔で定期的に実行する」間引きで、スクロール連動のように動いている最中も更新したい処理に向きます。debounceは「動きが止まってから1回だけ実行する」間引きで、検索入力やリサイズ完了後の処理に向きます(インクリメンタル検索の事例でdebounceを使っています)。スクロール中に更新したいのか、止まってからでよいのかで選び分けます。
修正デモの答え合わせ:IntersectionObserverで書く
デモのIntersectionObserverタブは、次のように書いています。scrollイベントを一切登録せず、各セクションを監視して、表示領域の指定した位置に入ったものを現在地にしています。rootMargin で「上のほうに来たら」というバンドを作るのがポイントです。
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) {
setActive(sections.indexOf(e.target)); // 現在地を更新
}
});
}, {
root: scrollEl, // スクロールする親(ページ全体ならnull)
rootMargin: '-10% 0px -80% 0px', // 上端から10%〜20%の細いバンドで判定
threshold: 0
});
sections.forEach(function (s) { io.observe(s); });
scrollイベントを使わないので、発火回数は0のまま。ブラウザが交差の監視を最適化してくれるため、こちらが位置を読み取る必要もありません。強制同期レイアウトもレイアウトスラッシングも、そもそも書きようがない構造になります。これが「重いハンドラを書かない」の意味です。
まとめ
scrollイベントが重い・カクつくのは、「発火が多い」ことと「ハンドラ1回が重い」ことの掛け算です。順番に手を打ちます。
まず「scrollイベントを使わずに済むか」を考える。使うなら、rAFで間引き、レイアウト読み取りを減らし、passiveを付ける。
最初に検討するのは、追従なら position: sticky、出現検知なら IntersectionObserver への置き換えです。scrollイベントがどうしても必要な処理だけを残し、そこに requestAnimationFrame での間引き、読み取りと書き込みの分離、{ passive: true } の3つを組み合わせます。passiveは万能薬ではなく、間引き・軽量化と役割が違う点だけ押さえておけば、たいていのカクつきは避けられます。
参考資料
スクロールとパフォーマンス周りは、次の一次情報で挙動を確認できます。