あるサイトで、Lighthouseの TBT(Total Blocking Time)が160ms ありました。パフォーマンスは95点。悪くはないけれど、あと少しが埋まりません。

JavaScriptは自前のものしか入っていません。外部のタグも遅延させています。何が160msも詰まらせているのか、しばらく分かりませんでした。
犯人は、画面の端でスクロール量に合わせて伸びる、細いバー1本でした。
装飾です。あってもなくても情報は変わりません。それが1本で160ms。要素を外して計測したら、はっきり数字が動きました。
何が重かったのか
実装はこうなっていました。よくある形だと思います。
function loop() {
const p = window.scrollY / (document.body.scrollHeight - innerHeight);
bar.style.transform = `scaleY(${p})`;
requestAnimationFrame(loop);
}
loop();
requestAnimationFrame(以下rAF)で毎フレーム呼んで、スクロール量からバーの長さを計算して書き込む。1回あたりの処理はごく小さいものです。
ただ、これは画面が動いていなくても、ページを開いている間ずっと回り続けます。60fpsなら1秒間に60回です。
そして毎回 style.transform に書き込んでいます。この書き込みがメインスレッドの仕事として積み上がります。
Lighthouseは「メインスレッドが50msを超えて塞がった時間」をTBTとして数えます。ページの読み込み直後は、ただでさえHTMLの解析やCSSの適用でメインスレッドが混んでいる時間帯です。そこにrAFの書き込みが割り込むと、超えやすくなります。
rAFを使っていること自体は問題ではありませんでした。 問題は「毎フレーム、DOMに書いていること」のほうです。ここを勘違いしていると、rAFをやめてスクロールイベントに書き換える、という間違った方向に行きます(そちらのほうが荒れます)。
直し方は2つだけ
やったのは次の2つです。両方入れて、TBTは0〜50ms、パフォーマンスは96〜97点になりました。
対策1:画面に入っているときだけ回す
そのバーが画面の外にあるなら、動かす必要はありません。IntersectionObserver で監視して、見えているときだけループを回します。
let running = false;
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting && !running) { running = true; loop(); }
else if (!e.isIntersecting) { running = false; }
});
io.observe(bar);
function loop() {
if (!running) return; // ここで自然に止まる
// …描画…
requestAnimationFrame(loop);
}
ページを開いた直後、いちばんメインスレッドが混んでいる時間帯に、画面外のアニメーションが回っていない。これだけでもかなり効きます。
対策2:書き込みを50msに1回まで減らす

毎フレーム書く必要は、たいていありません。経過時間を溜めて、一定時間ごとにだけ書き込みます。
let acc = 0, last = performance.now();
function loop(now) {
if (!running) return;
const dt = Math.min(now - last, 100); // タブ復帰時の巨大なdtを抑える
last = now;
acc += dt;
if (acc >= 50) { // 50msに1回だけDOMに書く
acc = 0;
bar.style.transform = `scaleY(${progress()})`;
}
requestAnimationFrame(loop);
}
rAF自体は回したままです。回すのは自由で、書くのを減らす。これが要点でした。
50msというのは、装飾のバーなら見た目が変わらない範囲です。指標のほうも50msを境にしているので、区切りとしても分かりやすい数字だと思っています。
Math.min(now - last, 100) は、別のタブを見ていて戻ってきたときの対策です。裏に回っている間はrAFが止まるので、戻った瞬間の dt が数秒ぶんになります。そのまま使うとアニメーションが一気に飛びます。
文字を1文字ずつ動かすアニメは、最初に作らない
同じくTBTに乗りやすいのが、見出しを1文字ずつフェードインさせるタイプのアニメーションです。
これは文字数ぶんの <span> にDOMを割る処理が入ります。見出しが何本もあると、数十から数百のノードになります。この分割処理が、ページ読み込み時のロングタスクそのものになります。
うちは、画面に入るまで分割しません。
const io = new IntersectionObserver(([e]) => {
if (!e.isIntersecting) return;
splitChars(el); // ここで初めて分割する
requestAnimationFrame(() => { // 1フレーム置いてから
el.classList.add('in');
});
io.disconnect();
});
requestAnimationFrame を1回はさんでいるのがポイントです。分割した直後にクラスを付けると、transitionが走りません。 ブラウザから見ると「要素が生まれた瞬間からその状態だった」ことになるからです。1フレーム置くと、ちゃんとアニメーションします。
ついでにもうひとつ。文字を分割するアニメは、flexとgapで組んだ見出しに直接入れてはいけません。全部の文字の間にgapが効いて、間延びした表示になります。文字のspanをもうひとつのspanで包んで、flexから見て1つの要素にしておきます。
横に流れるマーキーは、CSSアニメーションで作らない
ロゴや文字が横に流れ続ける、いわゆるマーキーです。CSSの animation で書くのが簡単なのですが、うちはrAFで書いています。
理由は、iOS Safariでの事故です。
animation のショートハンドの中でCSS変数を使うと、iOS Safariで再生時間が0として扱われることがありました。0秒のアニメーションなので、猛烈な速度で暴走します。他のブラウザでは正常に動くので、実機を触るまで気づけません。
いまは実時間で動かしています。
x -= speed * (dt / 1000);
if (x <= -width) x += width;
track.style.transform = `translate3d(${x}px,0,0)`;
これなら端末による差が出ませんし、上に書いた「画面内のときだけ」「dtの上限」もそのまま適用できます。
マーキーで注意することが2つあります。
1つめ。流す中身をJavaScriptで複製するなら、IntersectionObserverに登録する前にやること。 track.innerHTML += ... のように後から足すと、既存のノードが作り直されて、遅延読み込み用に登録済みのObserverが無効になります。中の画像が読み込まれなくなります。
2つめ。幅の計測は毎フレームやらないこと。 scrollWidth を読むたびにブラウザはレイアウトを計算し直します(レイアウトスラッシングと呼ばれます)。うちは「リサイズしたとき」「document.fonts.ready のとき」「画面に入ったとき」の3回だけ測っています。フォントが差し替わると文字幅が変わるので、fonts.ready は必要です。
なお、content-visibility: auto が効いている区間の中だと、描画前の scrollWidth は 0 を返します。画面に入ったときに測る、という設計はここでも効きます。
「止まっている」ように見えて、測り方のせいのことがある
最後に、検証の落とし穴です。ここで何度か時間を溶かしました。
ウィンドウが他のウィンドウに覆われていると、rAFもIntersectionObserverも描画も止まります。 ブラウザの省電力の仕組みなので正常な動作なのですが、スクリーンショットが白紙になったりズレたりするので、サイトの不具合と見間違えます。
タブが非アクティブだと、visibilityState が hidden になってrAFが完全に止まります。 このときCSSのtransitionだけは進むので、「フェードインは動くのにマーキーだけ止まっている」という、いかにも実装のバグらしい見え方になります。
そして、マーキーは画面外だとIntersectionObserverで意図的に止めているわけです。先にその位置までスクロールしてから測らないと、自分で入れた最適化を「動いていない」と診断することになります。実際にやりました。
確実なのは、puppeteer-core でシステムのChromeを画面ありで起動して、page.bringToFront() を呼んでから測ることでした。この形にしてから、誤診はなくなりました。
まとめ
- rAFを回すこと自体は問題ではない。毎フレームDOMに書くことが問題
- 画面内のときだけ回す(IntersectionObserver)+ 書き込みは50msに1回。これでTBT 160ms→0〜50ms
dtには上限を(タブ復帰の飛びを防ぐ)- 文字分割は画面に入ってから。分割の直後は1フレーム置いてクラスを付ける
- マーキーはrAFの実時間駆動。CSS変数入りの
animationショートハンドはiOS Safariで暴走した - 幅の計測は「リサイズ・fonts.ready・可視化時」の3回だけ
- 動いていないと判定する前に、ウィンドウが前面にあるかを疑う
装飾のために5点落とすかどうかは、そのサイトの目的しだいだと思います。ただ、落とさずに残せるなら、そのほうがいいですよね。