お問い合わせ
2026.11.09 制作の実務

スクロールで動くバー1本で、TBTが160ms増えていた

ホーム / ブログ / 記事

スクロールで動くバー1本で、TBTが160ms増えていた

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

TBT 160ms→0〜50ms・Perf 95→97 の実測
TBT 160ms→0〜50ms・Perf 95→97 の実測

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回まで減らす

毎フレーム書く/50msごとに書く の比較図解
毎フレーム書く/50msごとに書く の比較図解

毎フレーム書く必要は、たいていありません。経過時間を溜めて、一定時間ごとにだけ書き込みます。

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点落とすかどうかは、そのサイトの目的しだいだと思います。ただ、落とさずに残せるなら、そのほうがいいですよね。