HN 日本語サマリー

← 一覧へ戻る
Web開発

ブラウザのメインスレッドは高コストである

The Browser's Main Thread Is Expensive (kciter.so)

407 pointsby kciter141 コメント

要約

ブラウザのメインスレッドは、JavaScriptの実行、レンダリング、イベント処理など、多くの重要なタスクを担っており、そのブロックはユーザー体験を著しく低下させます。この記事では、この貴重なリソースを賢く使うための戦略として、メインスレッド内での作業分割やタイミング調整、あるいはコンポジターやWeb Workerへのタスクオフロードといった手法を解説しています。

全文翻訳

ブラウザのメインスレッドはコストが高い

「フロントエンド最適化」と聞いて何を思い浮かべますか? ほとんどの場合、ネットワークリクエストの削減、バンドルサイズの縮小、キャッシュの有効活用などが挙げられます。さらに進めば、再レンダリングの抑制やリソースのロードタイミングの調整なども考えられるでしょう。しかし、メインスレッドが問題になることは、ほとんどの画面ではまずありません。しかし、インタラクションが多く、データがリアルタイムでストリーミングされ、スクロール、アニメーション、入力処理などが複雑に絡み合う画面では、状況は一変します。ネットワークやバンドルサイズでどれだけ節約しても、メインスレッドがブロックされた瞬間に画面はフリーズしてしまいます。

おそらく、ウェブサイトでスクロールが時々カクついたり、ボタンの反応がわずかに遅れたり、検索ボックスに入力した文字が少し遅れて表示されたりする経験をしたことがあるでしょう。それは不快なほどではありませんが、微妙に神経を逆なでします。そのような「ジャーク(jank)」こそが、ブロックされたメインスレッドの姿です。

開発者としてこのようなジャークに遭遇した場合、通常は「自分のコードが遅いのでは?」と考え、アルゴリズムを分解したり、無駄な計算を探したりし始めます。しかし、ほとんどの場合、コードの速度自体が問題ではありません。このコードがメインスレッドを占有しており、それだけで問題を引き起こすには十分なのです。

ブラウザには複数のスレッドがありますが、コードから操作できるほとんどすべてがメインスレッドに集中しています。計算、レンダリング、イベント処理、ネットワーク応答処理、そしてフレームワークの内部処理もすべてそこで実行されます。これは、一つのリソースに大量の作業が集中している状態です。

ブラウザのメインスレッドはコストが高いリソースです。ほとんどの場合は問題を引き起こしませんが、何か野心的なことをしようとすると、このメインスレッドの問題を解決しなければなりません。この記事では、このコストの高いリソースをどのように扱うかについて説明します。

メインスレッドは何をするのか?

まず、メインスレッドが実際に行っていることを確認しましょう。その作業は、大きく分けて2つのカテゴリに分類されます。

1つ目はJavaScriptの実行です。私たちが書いたコード、イベントハンドラ、タイマー、ネットワーク応答コールバック、そしてフレームワークの内部処理はすべてここで実行されます。これらのタスクは、キューに入った順に、隙間があれば実行され、画面のリフレッシュサイクルとは無関係に処理されます。

2つ目は画面の描画です。DOMやスタイルが変更され、画面の更新が必要になった場合、ブラウザは以下のステップを順番に実行してフレームを生成します。

  • requestAnimationFrame コールバックの実行 - フレーム描画直前に実行されるよう登録されたJavaScript
  • スタイル計算 - 各要素の最終的なCSS値を計算する
  • レイアウト - 各要素の位置とサイズを計算する(リフローとも呼ばれる)
  • ペイント - どの色で何を描画するかを記述するペイントコマンドを生成する

何も変更がなければ、これらのステップは完全にスキップされるため、必ずしも毎フレーム実行されるわけではありません。最終的なコンポジットステップ(生成された出力を画面に組み立てる処理)のみがコンポジター・スレッド[1]に引き渡されます。したがって、画面を描画するパイプラインの前半部分のほとんどは、メインスレッドの責任です。

画面をスムーズに見せるためには、フレームはディスプレイのリフレッシュレートで描画される必要があります。最も一般的な60Hzディスプレイでは、毎秒60フレーム、つまり約16.6ミリ秒ごとにフレームが描画されます。そして、そのすべてを自由に使えるわけではありません。ブラウザ自身の処理コストを差し引くと、実質的な予算は通常10ミリ秒程度と見なされ[2]、120Hzデバイスでは予算自体が半分になります。

そして、上記の2種類の作業は同じスレッド上で一列に並んでいます。JavaScriptはシングルスレッドのイベントループモデルを中心に設計されています。メインスレッドは一度に1つのタスクを処理し、そのタスクが実行されている間は、他の何も起こりえません。 もし1つのJavaScript関数が200ミリ秒実行されたら、その200ミリ秒の間、ブラウザは画面を再描画したり、ユーザーからのクリックを受け付けたりすることができません。約10ミリ秒のフレーム予算に対して、これは致命的な時間です。このように長く実行され、メインスレッドを占有するタスクは「ロングタスク」と呼ばれ、一般的に50ミリ秒を超えるものは問題と見なされます。

言葉だけでは伝わりにくいので、実際に体感してみましょう。以下のデモでは、ボタンを押すとJavaScriptが一時的にメインスレッドを掴みます。

[画像: JSアニメーションとCSSアニメーションの比較デモ。ボタンを押すとJSアニメーションと入力フィールドがフリーズするが、CSSアニメーションは継続する様子を示す。]

ボタンを押すと、JSアニメーションが停止し、入力フィールドへの入力も無効になります。一方、CSSアニメーションは動き続けます。メインスレッドを長時間占有することは、画面をフリーズさせることと同じです。 CSSアニメーションの違いがどこから来るのかについては、後ほど説明します。

これはWebパフォーマンス指標に直接つながります。ユーザーが何かを行った後の画面応答時間を測定するINP(Interaction to Next Paint)や、ページロード中にメインスレッドがブロックされていた合計時間を測定するTBT(Total Blocking Time)は、どちらも本質的にはメインスレッドがブロックされていた時間を表す方法です。パフォーマンス最適化の大部分は、この単一のスレッドをいかに賢く使うかという問題です。

賢く使う方法は、大きく分けて2つのファミリーに分類されます。1つは、メインスレッド内で時間をうまく分割することです。もう1つは、作業を完全にメインスレッドの外に送り出すことです。順に見ていきましょう。

高コストリソースを賢く使う

最初のファミリーは、メインスレッド上に留まりつつ、その時間を賢く使うことに焦点を当てます。ここには4つの主要な操作があります。

  • 長すぎる作業をどのように分割するか?
  • 頻繁すぎる作業をどのようにグループ化するか?
  • 複数のタスクの中で、どれを先に実行するか?
  • 今すぐ必要のない作業をどのように延期するか?

これらを、分割(splitting)、バッチ処理(batching)、優先順位付け(prioritizing)、遅延実行(deferring)と呼びます。最初の2つはタスクのサイズを形成し、後の2つはタスクのタイミングを決定します。4つの中で、分割は他のすべての基礎となります。タスクには境界線が必要であり、その間に何を配置し、何を後回しにするかを決定できます。したがって、分割から始めます。

分割

ライブストリームのチャットペインを想像してみてください。人気のストリームでは、チャットは毎秒数百メッセージに達することがあります。そのような環境では、メッセージは一度に1つずつ丁寧には届きません。トラフィックが急増すると、サーバーはそれらを数十件の塊で送信し、部屋に入った瞬間に、バックログにあった何百ものメッセージが一気に届きます。それらのメッセージの塊を、到着したときに一度にすべてレンダリングしたらどうなるでしょうか?描画される各メッセージは、DOM作成、スタイル計算、レイアウト、ペイントを伴います。そして、それらの何百ものイテレーションが単一のタスク内で連続して実行されます。その間、自分のメッセージを入力しようとしているユーザーは、入力フィールドのカクつきに悩まされ、画面上の他のすべてのアニメーションもひっかかります。他の人のチャットがメインスレッドを独占し、あなたの入力の邪魔をしています。

その解決策は、そこから導かれます。塊を小さなピースに分割し、ピースの間で一時的にメインスレッドの制御を返します。その隙間に、ブラウザはキューに入れられていた画面更新や入力処理をキャッチアップできます。

以下のデモは、ストリーミングチャットペインをシミュレートしたものです。「チャットをフラッドする」ボタンを押すと、メッセージが流れ込んできます。画面上部にあるスムージネスゲージとFPSを見ながら、入力フィールドに文字を入力してみてください。「即時レンダリング」と「遅延レンダリング」モードを比較してください。

[画像: スムージネスインジケーター(JSアニメーション)60 fps。チャットメッセージが流れる中、入力フィールドの応答性を示す。]

「即時レンダリング」モードでは、メッセージが到着するたびにDOMが操作されるため、チャットがフラッドしている間はFPSが急激に低下し、ゲージがカクつき、入力フィールドが遅延します。よく見ると、チャットメッセージ自体も目に見えて遅く表示され始めます。なぜなら、それらを受信して処理するコールバックもメインスレッドのキューで待機しているタスクであり、他のすべてのものと一緒に遅延してしまうからです。ここで「遅延レンダリング」に切り替えてみてください。メッセージは以前と同じように1つずつ描画されていますが、入力が復活し、画面が再び動きます。変わったのは、20メッセージごとにメインスレッドが一瞬解放されることだけです。

ただし、デモを誤解しないように注意してください。遅延は作業を速くするわけではありません。 総作業量は変わらず、各遅延で発生する数ミリ秒の待機時間はオーバーヘッドとして追加されるため、実際には壁時計時間ではより長くかかります。では、なぜ入力とレンダリングが同時に回復したのでしょうか?

前述のように、メインスレッドはタスク実行中に何もできません。フレームを生成するレンダリングパイプラインも、タスクの途中に割り込むことはできません。それはタスクの間にのみ実行できます。遅延とは、それらの隙間を作り出す行為です。バックログに入っていた入力やフレーム生成が隙間で順番を得るため、ユーザーにはパフォーマンスが向上したように感じられます。

コードレベルでは、遅延させる古典的な方法はsetTimeoutであり、これは継続処理を次のタスクに押し込みます。以下のコードを見てください。

// チャットメッセージのバッチが一気に到着する
socket.on('messages', (chats) => {
  renderChats(chats);
});

// 20メッセージごとにメインスレッドを遅延させながらメッセージを描画する
async function renderChats(chats) {
  let count = 0;
  for (const chat of chats) {
    appendChatNode(chat); // 1メッセージ描画

    if (++count % 20 === 0) {
      await new Promise((resolve) => setTimeout(resolve, 0)); // ここで遅延
    }
  }
}

これにより、チャットがどれだけ激しくフラッドしても、DOM作業が単一の塊としてメインスレッドを占有することはなく、その塊の間にはユーザーの入力やアニメーションを処理するための隙間が生まれます。

このコードの主役はsetTimeoutです。残りの作業の再開を新しいタスクとしてスケジュールすると、現在のタスクはその時点で終了し、その隙間にバックログに入っていた入力やレンダリングが処理されます。awaitはスケジュールされたタスクが戻ってくるまで関数を一時停止し、その後中断したところから再開します。

[図: 時間軸の図。setTimeoutによる遅延が、タスクの間に隙間を作り、入力やレンダリングがその隙間で実行される様子を示す。]

上記の例では、到着した作業を数で分割しました。しかし、アニメーションが既に実行中であったり、ユーザーがスクロール中であったりする場合、数で分割するよりも時間で分割する方が安全です。アニメーションはフレームごとにメインスレッドのわずかなリソースを使用するため、重いジョブは常に時計をチェックし、フレームの残りの予算をすべて使い切る前に自分自身をカットする必要があります。

async function processDuringAnimation(items) {
  let i = 0;
  let frameStart = performance.now();
  while (i < items.length) {
    // フレーム開始から5ミリ秒経過するまで作業する
    while (i < items.length && performance.now() - frameStart < 5) {
      doWork(items[i++]);
    }
    frameStart = await new Promise(requestAnimationFrame); // 次のフレーム開始時に再開
  }
}

ここでは、performance.now()が予算を超えたかどうかをチェックするストップウォッチとして機能し、requestAnimationFrameが「次のフレームが描画される直前に起こして」と知らせるアラームとして機能します。これも、時間で分割する際にsetTimeoutではなくrAFで遅延させる理由です。再開はフレームサイクルと同期して行われます。

rAFはフレームの開始タイムスタンプをコールバックに渡しますが、上記のコードはそのタイムスタンプを予算の基準点として使用します。なぜなら、関数はフレームを独占するわけではないからです。もし同じフレームで他のアニメーションコールバックが先に実行された場合、それらが使用した時間だけ私たちのシェアを減らさなければ、フレーム予算は破られます。フレーム開始時刻に基準を合わせることで、「5ミリ秒使用する」が「フレーム開始から5ミリ秒まで使用する」となり、複数のアニメーションが1つのフレームを共有する場合にコードが自然に協調するようになります。

なぜ5ミリ秒なのか? 特に理由はありません。実質的な予算は約10ミリ秒であるため、バックグラウンド作業にその半分を割り当て、残りをアニメーションコールバック、スタイル、レイアウト、ペイントのために残すのは合理的なヒューリスティックです。アニメーションが重い場合は、この値を小さくしてください。

このアプローチにより、重い作業が進行中であっても、毎フレーム画面を描画する余地があり、作業とアニメーションがスムーズに並行して実行されます。以下のデモを試してみてください。マウスを動かすと、4,000個のパーティクルがカーソルから離れるように散らばり、近くのパーティクルも互いに押し合います。そのため、1つのパーティクルの方向を決定するには、他のすべてのパーティクルとの距離をチェックする必要があります。これは1回のパスで約1600万回の距離計算になり、それを毎フレーム再計算すると、それだけでフレーム予算を使い果たしてしまいます。「すべて一度に計算」モードと「フレームあたり5ミリ秒」モードを比較してください。

[画像: 4,000個のパーティクルがマウスカーソルに反応して散らばるデモ。スムージネスメーターが表示されている。]

分割は、メインスレッドの時間を節約するための最も基本的な方法です。これは、ユーザーが気にする知覚パフォーマンス、つまり高速な応答とスムーズな画面を提供します。

最後に、注意すべき点がいくつかあります。まず、分割しすぎると逆効果になります。 遅延して復帰すること自体にコストがかかるため、ピースが小さすぎると、そのオーバーヘッドが実行しようとしている作業よりも大きくなる可能性があります。

次に、setTimeoutによる遅延には最小遅延[3]があるため、各ピースは何もせずに数ミリ秒待たされる可能性があります。通常は問題ありませんが、非常に高い応答性が求められる状況では、この遅延が問題になることがあります。

そのため、一部のコードでは、MessageChannel経由でメッセージを投稿して次のタスクをスケジュールしています。Reactのスケジューラはこの方法を使用しています。より最近では、この問題に対処するためにscheduler.yield()という標準APIも登場しました。その利点は、遅延後、元の作業が他のキューに入れられたタスクよりも前に再開されることです。ただし、ブラウザのサポートはまだ均一ではありません。

第三に、異なる遅延ツールは異なるタイミングで復帰します。 setTimeoutとscheduler.yield()はレンダリングサイクルに関係なく再開しますが、requestAnimationFrameはフレーム描画直前に再開するため、画面更新と同期して実行する必要がある作業にはrequestAnimationFrameが適しています。より細かい優先順位制御が必要な場合は、MessageChannel上に独自のキューを構築し、遅延と再開を自分で管理することもできます。

最後に、分割が常に可能なわけではありません。例えば、数メガバイトのレスポンスをJSON.parseで解析するのは、単一の原子的な同期呼び出しであり、途中で停止して遅延させる方法はありません。解析が完了するまで、メインスレッドは停止したままです。このように分割できない重い作業は、「賢く使う」ことの明確な限界です。その場合は、前提を変更して、メインスレッドで作業を実行しないようにする必要があります。これは「高コストリソースを使わない」セクションで説明します。

バッチ処理

分割だけではすべての問題が解決するわけではありません。ストリーミングチャットの例を思い出してください。遅延は入力とレンダリングを救いましたが、チャットの描画を速くする効果はありませんでした。むしろ、スループット(単位時間あたりに描画されるメッセージ数)は、遅延のオーバーヘッドによって低下しました。では、チャットがスループットよりも速く流れ込んできたらどうなるでしょうか? 到着が処理を上回り、バックログは増え続け、画面に表示されるメッセージはますます古くなっていきます。この状況をバックプレッシャーと呼びます。

スループットを上げるには、分割とは異なるツールが必要です。例えば、メッセージを1つずつ描画する代わりに、蓄積されたバッチを一度に描画できます。メッセージごとの固定コストが統合され、同じ時間でより多くのチャットを描画できます。分割するように言われたかと思えば、バッチ処理するように言われるのは矛盾しているように聞こえるかもしれませんが、どちらの目的もタスクを適切なサイズにトリミングすることです。分割は、レンダリングが間に合わないほど長いタスクに対処し、バッチ処理は、パイプラインの固定コストが繰り返し支払われるほど頻繁なタスクに対処します。

バッチ処理の最適な対象はイベントです。スクロール、リサイズ、入力イベントは、短時間で数十回から数百回発生することがあります。それらすべてで重いハンドラを実行すると、メインスレッドには何も残りません。そのため、多くのイベントを1回の実行にまとめます。「事態が落ち着いた後に実行する」または「間隔あたり最大1回実行する」のいずれかです。これらはそれぞれデバウンス(debounce)とスロットル(throttle)と呼ばれます。

以下のデモは、長いCHANGELOGを開いたマークダウンエディタです。プレビューを構築するには、ドキュメント全体(約2,000行)を解析し、DOMをゼロから再構築する必要があります。これは、キーストロークごとに実行するには高すぎます。「デバウンスなし」を選択して、左のエディタに素早く入力してみてください。プレビューは文字ごとに再構築され、入力が遅れます。「300msデバウンス」に切り替えると、入力後に一度だけレンダリングが行われ、入力がスムーズになります。

[画像: 約2,000行のCHANGELOG.mdファイルを開いたマークダウンエディタ。スムージネスメーターが表示されている。]

視覚的な更新には、requestAnimationFrameを使用できます。画面はフレームあたり1回しか描画されないため、どれだけ多くの更新リクエストが積み重なっても、フレームあたり1回の描画で十分です。

let scheduled = false;

socket.on('tick', (tick) => {
  chart.push(tick); // すべてのデータポイントを保持 — 何も捨てられない
  if (scheduled) return; // このフレームの描画は既に予約済み
  scheduled = true;
  requestAnimationFrame(() => {
    renderBoard(); // フレームあたり1回描画
    scheduled = false;
  });
});

以下のデモは、毎秒1,000メッセージを超える60個のティッカーのボードを更新します。「各ティックでレンダリング」モードでは、各メッセージでボード全体を再描画します。チャートライブラリのupdate()を各メッセージで呼び出すのはよくある間違いであり、まさにこのようになります。「フレームあたり1回」に切り替えると、到着した各データポイントは引き続き反映されますが、FPSは回復します。

[画像: 60個のティッカーが表示されたボードのデモ。スムージネスメーターが表示されている。]

DOM書き込みもバッチ処理できます。100個のノードを一度に追加するのではなく、一度に1つずつ追加する、あるいは個別のスタイルプロパティを変更する代わりに単一のクラスをトグルする、といったことは、多くの変更を1つにまとめ、パフォーマンスを向上させます。HTML文字列を組み立ててinnerHTMLに一度に割り当てるという古いテクニックも、本質は同じです。書き込みを収集し、レンダリングパイプラインの固定コストを1回だけ支払います。

この種の最適化は、フロントエンド開発者には馴染みのあるパターンであり、Reactの仮想DOM自体もそのための仕組みです。状態が何度変更されても、変更は仮想ツリーに蓄積され、まず比較され、実際の差分のみが実際のDOMに一度に適用されます。単一のイベントハンドラ内で複数の状態更新を単一の再レンダリングにマージしたり、分析イベントをキューに入れて個別に送信するのではなくまとめて送信したりすることも、同じ考え方です。アイテムごとに繰り返される固定コストが、バッチごとに1回だけ支払われます。

優先順位付け

分割とバッチ処理が作業のサイズを形成するとすれば、優先順位付けはその順序を決定します。ユーザーがちょうど押したボタンへの反応は迅速に行われる必要がありますが、画面外のコンテンツの統計情報を事前に計算することは待つことができます。優先順位付けとは、緊急性の高い作業を、緊急性の低い作業よりも前に配置することです。

順序は重要です。なぜなら、中断できないメインスレッドでは、順序こそがユーザーが感じる応答性だからです。順序を制御するには、通常、作業が投入され、そこから引き出されるキューを構築します。キュー内のジョブはFIFO(先入れ先出し)で処理されますが、緊急のものが来ると、ラインの先頭に引き上げられます。

const queue = [];
const channel = new MessageChannel();

// 1メッセージ = 1タスク。一部を処理し、次を予約する
channel.port1.onmessage = () => {
  const job = queue.shift(); // 現在の先頭から取得
  if (!job) return; // 重複予約を防ぐ
  job();
  if (queue.length > 0) channel.port2.postMessage(null);
};

function postJob(job, urgent = false) {
  if (urgent) queue.unshift(job); // 緊急ジョブは先頭に移動
  else queue.push(job);
  if (queue.length === 1) channel.port2.postMessage(null);
}

この構造は有用です。なぜなら、優先順位は固定値ではないからです。元々は緊急でなかった作業が、ユーザーの操作によって突然緊急になることがあります。例えば、ユーザーが投稿に数十枚の写真を添付した場合です。コストを節約するため、クライアントはサーバーにアップロードする前に画像をリサイズすることがあり、そのリサイズは急がない作業であり、順序通りに処理できます。

Reactも同様のラインで、より洗練された仕組み(スターベーション保護、バッチ処理、継続処理など)を備えています。startTransitionやuseDeferredValueを使用すると、スケジューラが起動し、MessageChannel経由で遅延し、独自の優先度キューで作業を順序付けます。

しかし、ユーザーが特定の写真をクリックして添付が正しく行われたか確認しようとした場合、その写真のプレビューが最も緊急のジョブになります。上記のような優先度付きキューを使用すると、緊急のジョブを最初に処理できます。アイドル時に作業を進め、必要になったら急ぐというこのアプローチは、時折「アイドル・アンティル・アーセント(idle-until-urgent)」パターン[4]と呼ばれます。

// 添付された写真のプレビューを順序通りに構築する
files.forEach((file, i) => {
  const job = () => createPreview(file, i);
  job.photoId = i; // キュー内で見つけやすいようにタグ付け
  postJob(job);
});

// 準備ができていない写真をクリックすると、そのジョブが先頭に移動 → 優先度上昇
onClickPhoto((i) => {
  const idx = queue.findIndex((job) => job.photoId === i);
  if (idx > 0) queue.unshift(queue.splice(idx, 1)[0]);
});

以下のデモで違いを体感してください。60枚の写真が添付されており、各プレビューはピクセル単位のフィルタリングで生成されています。プレビューが順序通りに構築されている間、まだ準備ができていない灰色のタイルをクリックしてみてください。「順序通り」モードでは、そのタイルの順番が来るまで待たなければなりませんが、「最初にクリック」モードではキューをスキップしてすぐに表示されます。作業の総量は同じで順序だけが変わったにもかかわらず、ユーザー体験は完全に異なります。

[画像: 60枚の写真のプレビュー構築デモ。左は順序通り、右はクリックしたものが優先される。]

したがって、優先順位とは、現時点でユーザーにとって最も重要なものが何かを理解することです。

最近のブラウザは、Scheduler APIやTaskControllerのような標準を提供しています。サポートはまだ不完全なため、実際にはポリフィルと組み合わせて使用したり、独自のキューを構築したりしています。この記事では、手作りのキューアプローチを使用しています。

遅延実行

メインスレッドを節約するための最後にして最も確実な方法は、今すぐ必要のないことは今すぐやらないことです。分割とバッチ処理が「どのサイズで?」と問い、優先順位付けが「どの順序で?」と問うのに対し、遅延実行は「この作業は本当に今すぐ行う必要があるのか?」と問います。

初期ページロードは、遅延実行が効果を発揮する典型的な場所です。すべてのJavaScriptを事前にダウンロードして実行する必要はありません。コード分割により、現在の画面に必要なコードだけが最初に実行され、残りは必要になったときにロードされるため、最初からメインスレッドが遅くなるのを防ぎます。

レンダリング自体も遅延させることができます。ソーシャルフィードを考えてみてください。通知タブから戻ってきたときに、数百件の投稿が蓄積されるほどスクロールしていた場合、数秒間フリーズするアプリがあります。フィードのDOMがタブを切り替えている間も維持されていたとしても、再び表示されるとき、ビューポートの近くにない投稿も含めて、数百件すべての投稿のスタイルとレイアウトが一度に再計算されます。では、ビューポート外の投稿を、その高さだけを占める空のシェルとして残しておき、画面に近づいてきたら実際のコンテンツで満たすとしたらどうでしょうか? その「近づいてくる」瞬間を教えてくれるツールがIntersectionObserverです。これは画像遅延読み込みライブラリが使用するのと同じ方法です。

const io = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) fill(entry.target); // 近づいてきたら埋める
      else empty(entry.target); // 画面外に出たら空にする、場所は維持する
    }
  },
  { rootMargin: '400px' } // スクロールが到着する前に埋めるための余裕
);

feed.querySelectorAll('.feed-item').forEach((el) => io.observe(el));

以下のデモは、1,500件の投稿が積み上げられたフィードです[5]。「表示時にのみレンダリング」がオンになった状態で始まります。通知タブに移動して戻ってきても、どれだけ蓄積されていても、戻りは即座です。ここで「すべてレンダリング」に切り替えて、もう一度往復してみてください。すべての戻りが数百ミリ秒フリーズし、1,500件すべての投稿が再度レイアウトされます。一時的に表示される空白のスロットを除けば、2つのモードは同じように見えます。

[画像: 1,500件の投稿が積み上げられたフィードのデモ。通知タブへの移動と戻りの速さが比較される。]

このアプローチでは、レンダリングだけでなく、他のことも遅延させます。ビューポート外の投稿はDOMさえ構築されないため、それを作成・維持するコストも他のすべてと一緒に遅延させます。ウィジェットが重い初期化処理を持つ場合、その初期化もウィジェットが画面に近づくまで待機させることができます。CSSプロパティcontent-visibility: autoもあり、単一行で同様の効果を目指しています。ただし、現時点では、実装はエンジン間で異なり、Safariにはページに戻る際に遅くなるというパフォーマンスバグがあるため、現時点ではIntersectionObserverがどこでも予測可能な動作をするオプションです。

継続的に実行される作業(カルーセル、アニメーションプロモバナー、ライブチャートなど)は、ビューポート外では純粋な無駄です。誰も見えない絵を描き直すために、毎フレームメインスレッドの時間を費やしています。表示されている間は実行し、画面外に出たら停止します。この記事の十数個のデモが1ページに共存できているのも、この方法によります。各デモは、ビューポート外にスクロールアウトしたら停止するように構築されています。

高コストリソースを使わない

これまでのすべては、メインスレッドを「賢く使う」ことについてでした。2番目のテクニックファミリーは、そもそもメインスレッドで作業を実行しないことです。

ロングタスクデモで未解決のままだった質問に戻りましょう。メインスレッドは完全にブロックされていましたが、なぜCSSアニメーションは何もなかったかのように動き続けたのでしょうか? そのアニメーションは、そもそもメインスレッドで実行されていなかったからです。ブラウザ内では、複数のスレッドが作業を分担しています。注目すべきスレッドは以下の通りです。

  • メインスレッド: JavaScriptの実行、DOM操作、スタイル計算、レイアウト実行、イベント処理を行います。
  • コンポジター・スレッド: 既に描画されたレイヤーを画面に合成します。スクロールや特定のアニメーションを処理します。
  • ラスタースレッド: ペイントコマンドを実際のピクセルに変換します。
  • ワーカー・スレッド: 明示的に作成する、独立したJavaScript実行空間です。

残念ながら、これらのスレッドを好きなように制御することはできません。コンポジター・スレッドとラスタースレッドはブラウザが独自に管理する領域であり、直接命令を与えることはできません。また、作成できるワーカー・スレッドには、DOMにアクセスできないという大きな制限があります。

したがって、「メインスレッドを使わない」ということは、任意の作業をどこかに送るという意味ではありません。それは、他のスレッドが処理できる形式を取りうる作業を選び出し、それを送るということです。これには主に2つの方法があります。

コンポジターへの処理移動

コンポジター・スレッドは、CSSアニメーションが停止しなかった理由です。transformとopacityは、ドキュメント内の要素の位置、サイズ、色を変更しません。それらは既に描画されたレイヤーを移動したり、その透明度を調整したりするため、レイアウトやペイントをやり直す必要がありません。これにより、ブラウザはメインスレッドを経由せずに、コンポジター・スレッドで直接それらを処理できます。メインスレッドがビジーであっても、コンポジターは独立して実行されるため、アニメーションはスムーズに保たれます。

top、left、width、heightのようなプロパティで要素を移動した場合、毎フレームレイアウトを再計算する必要があり、これはメインスレッドの作業です。以下のデモでは、2つのボックスが同じように左右にスライドしますが、1つはtransformで、もう1つはleftで移動します。ボタンを押してメインスレッドに負荷をかけてみてください。

[画像: transformとleftプロパティで要素を移動させた場合のデモ。メインスレッドに負荷をかけるとleftで移動するボックスがカクつく。]

メインスレッドがビジーになると、下のボックス、つまりleftで移動しているボックスだけがカクつき始めます。上のtransformボックスはコンポジターによって駆動されており、負荷に関係なくスムーズに動作します。同じ「横スライド」でも、アニメートするプロパティによって完全に異なるスレッドに配置されます。だからこそ、leftではなくtransform: translateで要素を移動させるアニメーションや、widthではなくtransform: scaleで要素をリサイズさせるアニメーションを構築する方がパフォーマンスが良いのです。

では、レイアウトが実際に変更される必要があるアニメーションはどうでしょうか? アイテムを削除すると下のアイテムがスムーズにスライドして配置されるリストを想像してみてください。これは装飾的な動きではありません。位置は実際に変更されます。しかし、topをアニメートすると、毎フレームレイアウトが発生します。このジレンマを解決するテクニックがFLIP(First, Last, Invert, Play)[6]です。レイアウト変更を1回だけ発生させ、残りの移動全体をtransformに任せます。 これは以下の順序で行われます。

  • First: 移動前の位置を測定する
  • Last: 実際にレイアウトを変更し、新しい位置を測定する。レイアウトはここで1回だけ実行される
  • Invert: 新しい位置にある要素にtransformを適用し、古い位置にあるかのように見せる
  • Play: そのtransformをアニメートして解除する。この部分はコンポジターの担当
const first = el.getBoundingClientRect(); // First: 現在の位置

list.prepend(el); // ただ1回のレイアウト変更

const last = el.getBoundingClientRect(); // Last: 最終的な位置
const dx = first.left - last.left;
const dy = first.top - last.top;

// Invert: 古い位置に戻ったように見せる → Play: 解放する
el.animate([
  { transform: `translate(${dx}px, ${dy}px)` },
  { transform: 'none' }
], {
  duration: 300,
  easing: 'ease-in-out',
});

ユーザーの目には、要素は古い場所から新しい場所へ滑るように移動しますが、実際には要素は既に新しい場所に到達しており、transformが一時的にそれを引き戻してから解放します。アニメーションが再生されている間、毎フレームの作業はコンポジターがtransformを補間することだけです。ほとんどのリスト並べ替えアニメーションは、このように構築されており、VueのTransitionGroupやFramer Motionのレイアウトアニメーションは、内部的にはFLIPを使用しています。

以下のデモは、一目で違いを示します。「ランキングシャッフル再生」ボタンを押すと、両方のランキングリストが同じようにシャッフルされます。左のリストはtopをトランジションでアニメートし、右のリストはFLIP経由でtransformのみを移動させます。負荷がない状態では、どちらもスムーズに見えます。ここで「メインスレッドに負荷をかける」をオンにして、もう一度再生してみてください。左のリストはカクつきながら終了しますが、右のリストは負荷がかかってもスムーズに保たれます。

[画像: ランキングリストのシャッフルデモ。左はtopトランジション、右はFLIPアニメーション。メインスレッドに負荷をかけると左がカクつく。]

最後に、コンポジターに作業を渡すことに関する2つの注意点があります。1つはwill-change: transformです。これはブラウザに「この要素は変更される予定なので、事前に独自のレイヤーとして準備してください」というヒントを与え、アニメーションの開始をスムーズにすることができます。しかし、使いすぎるとレイヤーの数が増加し、メモリが無駄になります。

もう1つはレイアウト値の読み取りです。これはFLIPコードで見たばかりです。レイアウト値を読み取るコード(getBoundingClientRect、offsetWidth)とスタイルを書き込むコードの順序を間違えると、レイアウトスラッシング(layout thrashing)と呼ばれる問題に遭遇します。

// 🔴 読み取りと書き込みがインターリーブされている — 各イテレーションでレイアウト再計算を強制する
for (const el of elements) {
  const width = el.offsetWidth; // 読み取り(レイアウトが必要)
  el.style.width = width + 10 + 'px'; // 書き込み(レイアウトを無効にする)
}

// 🟢 すべての読み取りを完了してから、まとめて書き込みを行う
const widths = elements.map((el) => el.offsetWidth); // 読み取りを収集
elements.forEach((el, i) => {
  el.style.width = widths[i] + 10 + 'px'; // 書き込みを収集
});

スタイルを変更した直後にレイアウト値を読み取ると、ブラウザは最新の回答を提供するためにレイアウトをその場で再計算せざるを得ません。それがループ内で発生すると、レイアウトは1フレームで数十回実行され、メインスレッドは遅くなります。読み取りと書き込みをグループ化する習慣をつけるだけで、これを回避できます。

ワーカーへの処理移動

では、transformで再表現できない重い作業はどうでしょうか? 大規模なペイロードの解析、画像の処理、複雑な計算などをどうしますか? そのような純粋な計算は、Web Workerを使用してメインスレッド全体から完全に分離して送信できます。

ワーカーは別のスレッドでJavaScriptを実行し、メインスレッドとは完全に分離されています。重い計算をワーカーに任せれば、その間、メインスレッドはUIの応答性を維持することに専念できます。

// メインスレッド
const worker = new Worker('parser.js');
worker.postMessage(hugeRawData);
worker.onmessage = (e) => {
  render(e.data); // 結果のみを受け取り、画面に表示する
};

もちろん、これは無料ではありません。前述のように、ワーカーはDOMにアクセスできないため、画面を直接操作できません。計算を実行し、その結果をメインスレッドに送り返すことしかできません。また、メインスレッドとワーカーはpostMessageを通じてのみ通信し、これはデータをコピー(シリアライズ)するため、やり取りされるデータが大きい場合、そのコストは相当なものになります。

したがって、ワーカーは万能薬ではありません。計算が通信コストを上回るほど重く、DOMに関係ない場合に輝きます。短くて軽いジョブをワーカーに送ると、通信コストが計算コストを上回り、損をします。毎回、この作業が本当にメインスレッドで実行する必要があるのかどうかを問いかけてください。

言葉だけでは伝わりにくいので、実際に重い画像処理を見てみましょう。シームカービング(seam carving)は、写真の中を最もエネルギー(色の変化が少ない)の垂直パスを見つけ、それを1パスずつ削除することで、重要な被写体を維持しながら画像を狭めるアルゴリズムです[7]。単一のシームを削除するには数百から数千万ピクセルをスキャンする必要があり、約250個のシームを削除すると数億回の操作になります。これをメインスレッドで実行すると、ページ全体がフリーズします。しかし、ワーカーに送ると、計算が実行されている間、画面は応答性を保つことができます。

以下のデモで「メインスレッドで実行」ボタンを押してください。計算が1〜2秒間実行されている間、ページ全体がフリーズし、結果は終了後にまとめて表示されます。ペイントが割り込むタスク境界がないため、中間ステップを表示することもできませんでした。次に「ワーカーで実行」に切り替えてください。同じ計算が実行されている間、画像がリアルタイムで狭まっていくのを見ることができます。

[画像: シームカービングデモ。メインスレッドで実行するとフリーズするが、ワーカーで実行するとリアルタイムで画像が狭まる。]

スムーズな中間フレームの背後には、もう1つのことがあります。ワーカーがフレームごとに数メガバイトのピクセルバッファをコピーして送信した場合、そのコストも積み重なります。そのため、postMessageはデータをコピーする代わりに、データの所有権を完全に転送するという代替手段を提供します。ArrayBufferのような転送可能なオブジェクトは参照のみで移動するため、サイズに関係なくコストはほぼゼロになります。バッファを引き渡した側はそれ以上使用できなくなり、その代わりにコピーコストがなくなります。

// ピクセルバッファをコピーせずにワーカーに渡す
// 転送後、この側はそれを使用できなくなる
worker.postMessage({ buf: pixels.buffer, width, height }, [pixels.buffer]);

処理自体の排除

これまでは、作業が本当にメインスレッドで実行される必要があるかどうかを問うてきました。今回は、その作業が本当に実行される必要があるのか? を問います。パフォーマンスにとって最良のこととは、そもそも作業を実行しないことです。

バックプレッシャーは以前にも登場しました。どれだけバッチ処理しても、流入量が最大スループットを超えると、バックログは無限に増え続けます。残念ながら、ブラウザにはサーバーに減速を指示する良い方法がありません。ある時点で、与えられたすべてを実行するという考えを諦めなければなりません。一般的に、作業を排除するには3つの方法があります。

[図: 作業を排除する3つの方法(ドロップ、マージ、スキップ)を示す図。]

最初の方法はドロップ(捨てる)です。ライブログのようなデータが流れていくだけの場合、処理が遅れ始めたら、古いエントリを静かに破棄してもユーザーは気づかないでしょう。現在に追いつくことが、すべてを表示することよりも重要です。

2番目はマージ(統合)です。最新の値だけが意味を持つデータ(ランキングなど)の場合、バックログに入った更新をマージし、最終的な値だけを適用できます。マージを使用すると、流入速度に関係なく、作業量は画面が消化できる量に固定されます。

3番目のスキップ(スキップ)は、流入する作業ではなく、繰り返し実行される作業を対象とします。計算が同じ入力に対して同じ結果を返す場合、2回実行する理由はありません。結果を記憶して再利用することをメモ化(memoization)と呼びます。

この考え方は記事全体に隠れていました。デバウンスはタイピング中の実行をスキップし、フィードデモは表示されていない投稿のレンダリングをスキップしました。この観点から見ると、この記事の半分は作業の排除についてでした。

最適化を学ぶとき、注意は作業をうまく行う方法に集まりがちですが、最大の利益は通常、作業を削除することから得られます。タスクを速くする前に、まずそれを考えてください。この作業は、今、ここで、本当に実行される必要があるのか?

まとめ

一部の開発者は、フロントエンドの作業を簡単なものと考えています。しかし、ブラウザは私たちが想定しがちなよりもはるかに複雑なシステムです。HTML、CSS、JavaScriptで画面を描画することがすべてではありません。

ストリーミングプラットフォームのような大量のリアルタイムデータを扱うアプリや、画像エディタ、マップ、ゲームのように画面が絶えず変化するアプリは、メインスレッドがビジーになると遅く感じ始めます。誰もが最新のハードウェアを持っているわけではないため、このようなサービスでは最適化が不可欠です。そして、これらの問題を解決するには、コードの最適化以上のものが必要です。ブラウザの仕組みを理解し、メインスレッドの時間を節約し、必要のない作業を実行しないことが求められます。

結局のところ、深く掘り下げれば何も簡単ではありません。開発の多くはトレードオフであり、状況に応じて選択する必要があり、それは最終的に開発者の経験と判断にかかっています。どちらもすぐに身につくものではありませんが、学習と実験を通じて確実に構築できます。この記事がその一助となれば幸いです。

  1. コンポジター・スレッドは、既に描画されたレイヤーを画面に合成する責任を負います。記事の後半で再び触れます。↩
  2. https://web.dev/articles/rendering-performance ↩
  3. HTML仕様では、setTimeout呼び出しが5レベルを超えてネストした場合、最小4ミリ秒の遅延が義務付けられています。上記のコードのようにループ内で繰り返し遅延させると、この条件はほとんどすぐに満たされるため、遅延が0に設定されていても、各ピースは少なくとも4ミリ秒待機します。↩
  4. https://philipwalton.com/articles/idle-until-urgent/ ↩
  5. 確かに、1ページに1,500件の投稿が積み重なることは実際にはめったにありません。デモでは、遅延レンダリングの効果を劇的に示すために、意図的にその数を積み上げています。↩
  6. https://aerotwist.com/blog/flip-your-animations/ ↩
  7. https://en.wikipedia.org/wiki/Seam_carving ↩