こんにちは。フロントエンドの現場で日々パフォーマンスチューニングに頭を悩ませている皆さん、お疲れ様です。
今日も「なぜか俺の書いたコード、ハイスペックなMacBookでもたまにカクつくんだが……」なんて不可解な現象に直面していませんか? ローカル環境ではサクサク動くのに、実際のユーザーの端末(特にモバイルやミドルレンジのPC)で測定すると、突然インタラクションがもたつく。その原因、もしかすると「ロングタスク(Long Tasks)」がブラウザのメインスレッドを占有しているせいかもしれません。
今回は、ブラウザが裏側でどうやってHTMLをパースし、DOMを作り、レンダリングしているのか。そして、なぜ「50ms」という数字がWebパフォーマンスの生死を分けるのか、そのメカニズムをエンジニアの視点から徹底的に解剖していきます。現場ですぐに使える具体的なコード改善パターンも用意したので、ぜひ最後までついてきてください。
—
1. ブラウザの心臓部:メインスレッドの孤独な戦い
まず、私たちが普段書いているJavaScriptがブラウザ上でどう処理されているか、その大前提を整理しておきましょう。
Webブラウザのレンダリングエンジン(WebKitやBlinkなど)の中心には、「メインスレッド(Main Thread)」と呼ばれる、文字通りすべての命運を握る単一のスレッドが存在します。このメインスレッドは、以下のようなあまりにも多くの仕事をたった一人でこなしています。
1. HTMLのパースとDOMツリーの構築
2. CSSのパースとCSSOMツリーの構築
3. JavaScriptの実行(イベントハンドラ、タイマー、フレームワークのコンポーネントライフサイクルなど)
4. スタイルの計算(Recalculate Style)
5. レイアウト / リフロー(Layout / Reflow)
6. ペイント / ラスタライズ(Paint / Composite)
そう、JavaScriptの実行と画面の描画(レンダリング)は、原則として同じメインスレッド上で「排他的」に行われます。
JavaScriptがゴリゴリと重い処理を回している間、ブラウザは画面の再描画を行うことができません。ユーザーがボタンをクリックしても、スクロールしようとしても、メインスレッドがJavaScriptの処理で手一杯であれば、ブラウザはその入力を一切無視せざるを得ないのです。これが「カクつき」や「入力遅延(Input Delay)」の正体です。
—
2. なぜ「50ms」なのか?ロングタスクの魔力
W3Cのパフォーマンス関連の仕様や、Googleが提唱するCore Web Vitals(特にINP: Interaction to Next Paint)において、「50ms」という数字は呪文のように頻繁に登場します。
なぜ50msなのか? その理由は人間の知覚とフレームレートにあります。
一般的なディスプレイは60Hz(1秒間に60回画面を更新)で駆動しています。これは、1フレームあたりの持ち時間が約16.67msであることを意味します。ブラウザはこの16msの間に、スタイル計算、レイアウト、ペイントを終わらせて次のフレームを描画しなければなりません。
ここで、あるJavaScriptのタスクがメインスレッドを占有したとします。もしそのタスクが50ms以上かかった場合、ブラウザは16msのフレーム予算を遥かに超過し、ユーザーからの入力(クリックやスクロール)に対して直ちに応答できなくなります。
W3CのLong Tasks APIでは、「実行に50ミリ秒以上かかった連続したタスク」をロングタスクと定義しています。この閾値を超えると、ユーザーは明確に「あ、今フリーズしたな」と知覚するレベルの遅延(Jank)が発生します。
—
3. 現場でよくあるアンチパターン:巨大なループ処理
例えば、数万件の巨大なJSONデータを取得し、それをパースしてDOMに一気に流し込むようなコードを書いてしまったとしましょう。
// 【アンチパターン】メインスレッドを完全に殺す重い処理
function renderHugeList(items) {
const container = document.getElementById(‘list-container’);
container.innerHTML = ”; // 既存の要素をクリア
// 数万件のループ:これだけで数十〜数百ミリ秒かかることがある
for (let i = 0; i < items.length; i++) {
const item = items[i];
const div = document.createElement('div');
div.className = 'list-item';
div.textContent = `${item.id}: ${item.name} - ${item.description.repeat(10)}`;
// 重い計算やDOM操作を延々と続ける
heavyCalculation(item);
container.appendChild(div);
}
}
このコードを実行すると、`for`ループが抜けるまでメインスレッドは完全にブロックされます。この間、ユーザーがどれだけ画面をスクロールしようとしても、アニメーションはピタリと止まり、ブラウザは沈黙します。
---
4. 解決策:タスクの分割(Yielding to the Main Thread)と非同期化
このロングタスク問題に対抗する王道のプラクティスが「タスクの分割(Chunking / Yielding)」です。
考え方はシンプルです。「一気に全部やろうとするからメインスレッドが死ぬんだ。小分けにして、合間にブラウザへ主導権(コントロール)を返そう」というアプローチです。
ブラウザに主導権を返すためには、JavaScriptの実行を一旦中断し、マイクロタスクまたはマクロタスクとして残りの処理をスケジュールし直します。
実践:`setTimeout` や `requestAnimationFrame` を使った分割
昔ながらのテクニックですが、現在でも非常に堅牢な方法です。配列をチャンク(小分けの塊)に分割し、処理の合間にブラウザが描画や入力イベントの処理を行える隙間を作ります。
// 【改善版】タスクを分割してメインスレッドに息継ぎをさせるコード
async function renderHugeListOptimized(items, chunkSize = 500) {
const container = document.getElementById(‘list-container’);
container.innerHTML = ”;
for (let i = 0; i < items.length; i += chunkSize) { // 指定したサイズ(チャンク)ごとに切り出す const chunk = items.slice(i, i + chunkSize); // DocumentFragmentを使ってメモリ上でDOMを構築し、リフローの回数を最小限に抑える const fragment = document.createDocumentFragment(); for (const item of chunk) { const div = document.createElement('div'); div.className = 'list-item'; div.textContent = `${item.id}: ${item.name} - ${item.description.repeat(10)}`; heavyCalculation(item); fragment.appendChild(div); } // まとめたDOMを一度にメインツリーにアタッチ container.appendChild(fragment); // ★ここがキモ:次の処理までブラウザに制御を返す(メインスレッドの開放) // setTimeout(..., 0) や Promise を使ってタスクをタスクキューの末尾に回す await yieldToMainThread(); } } /
- メインスレッドに制御を戻すためのヘルパー関数
/
function yieldToMainThread() {
return new Promise(resolve => {
// setTimeoutを使うことで、ブラウザがレンダリングや入力イベントの処理を行う隙間を作る
setTimeout(resolve, 0);
});
}
さらにモダンなアプローチ:`scheduler.yield()` API
最新のモダンブラウザでは、さらに洗練されたAPIとして `isSupported`な環境における `scheduler.yield()`(Prioritized Task Scheduling API) が登場しつつあります。これにより、単なる `setTimeout(0)` よりも優先順位を考慮したスマートなタスク譲渡が可能になっています。(※ブラウザのサポート状況に応じてフォールバックを用意しつつ導入を検討してください)
—
5. シニアからの実践的なアドバイスとまとめ
ロングタスクとレンダリングの競合を防ぐためのポイントを、最後にギュッとまとめておきます。
1. 「一網打尽」の思考を捨てる:数千件のDOM操作や重い計算は、必ずチャンク(小分け)に分割する。
2. DocumentFragmentを活用する:DOMへの頻繁なアクセスはレイアウトスラッシング(強制同期レイアウト)を引き起こすため、メモリ上で組み立ててから一気にアタッチする。
3. Chrome DevToolsの「Performance」タブを友達にする:
- 開発者ツールの「Performance」パネルで記録をとり、赤い三角マーク(Long Taskの警告)がついている箇所を徹底的に潰す。
- 「Main」セクションのタイムラインで、どの関数がメインスレッドを占有しているのかを可視化する習慣をつける。
フロントエンドのパフォーマンスチューニングは、派手な新しいフレームワークを導入することではなく、こうしたブラウザの泥臭い挙動(メインスレッドの仕組み)を理解し、それに優しく寄り添うコードを書くことから始まります。
皆さんのプロジェクトでも、ユーザーの「サクサク快適に動く」という感動のために、ぜひ今日のコードを見直してみてください。それでは、次の現場でお会いしましょう!

コメント