こんにちは。君の書いた前回のプルリクエスト、ロジックは綺麗にまとまっていたけれど、低スペックなモバイル端末で検証したかい?
ユーザーがボタンをタップした瞬間、画面が数秒間フリーズして「クソアプリだな」ってブラウザを閉じられそうなポイントがいくつかあったんだよね。
「あれ? ちゃんと非同期処理にしているし、メモリリークもないはずなのに……」って顔をしているね。
原因はJavaScriptの非同期処理じゃない。メインスレッドの独占、いわゆる「ロングタスク」だ。
今回は、ブラウザの心臓部であるメインスレッドとレンダリングの切っても切れない関係について、現場のリアルな知見を交えて徹底的に解説しよう。明日からすぐに使えるコードも用意したから、しっかりついてきなよ。
—
なぜ「50ms」がブラウザの生死を分けるのか?
フロントエンド開発でよく耳にする「50ms」という魔法の数字。これは、RAILモデルやINP(Interaction to Next Paint)の指標においても極めて重要な閾値だ。
なぜ50msなのか? 答えはシンプルで、人間の脳と人間の目の限界、そしてブラウザのフレームレートの物理法則にある。
ユーザーがボタンを押したり、スクロールしたり、画面をタップしたとき、ブラウザは「なんか入力があったぞ」と検知してから次の画面を描画(ペイント)するまでに、100ms以内に応答を返さなければならない。人間は100msを超えると「遅延(ラグ)」を感じ始める。
ここでブラウザのメインスレッドのスケジュールを思い出してほしい。メインスレッドは1つしかない。そこでは以下のタスクが直列(シングルスレッド)で処理されている。
1. JavaScriptの実行(イベントハンドラ、フレームワークの再描画計算など)
2. スタイルの計算(Style Calculation)
3. レイアウト(Layout / Reflow)
4. ペイントと合成(Paint & Composite)
JavaScriptの実行に時間がかかりすぎると、その間、ブラウザはユーザーの入力イベント(クリックやスクロール)を処理したくてもできない。これが「メインスレッドのブロッキング」だ。
そして、1つのJavaScriptのタスクが50ms以上かかると、そのタスクは「ロングタスク(Long Task)」とみなされる。100msの応答予算のうち半分以上をJSの実行が食いつぶしてしまうため、ブラウザは滑らかな60fps(1フレーム約16.6ms)を維持できなくなり、画面がカクついたり、完全にフリーズしたようになるというわけだ。
—
ブラウザの裏側で何が起きているか?
もう少し解像度を上げて、ブラウザの内部で何が起きているか覗いてみよう。
君が巨大なJSONデータを取得し、それをパースして数千行のDOM要素を生成するループ処理をメインスレッドで回したとする。
// ⚠️ やってはいけない典型的なロングタスクの例
const heavyProcess = (items) => {
items.forEach(item => {
// 重い計算やDOM操作が数千回続く…
const el = document.createElement(‘div’);
el.textContent = expensiveCalculation(item);
document.body.appendChild(el);
});
};
このコードが実行されると、JavaScriptエンジン(V8など)のコールスタックはパンク状態になる。この間、レンダリングエンジンは「おい、次の画面を描画したいからメインスレッドをよこせよ!」と叫んでいるのに、JavaScriptの実行が終わるまでスレッドを明け渡してもらえない。
ブラウザは親切心から「よし、JavaScriptの実行が終わったら、溜まっているスタイル計算とレイアウトをまとめて片付けるか」と処理を試みるが、その待ち時間がユーザーにとっては耐え難いラグになる。
「じゃあ、`setTimeout(fn, 0)`を使えばいいの?」と思ったそこの君。
半分正解で、半分古い。実務ではもっと洗練されたモダンなアプローチを使うべきだ。
—
実践:タスク分割(Task Yielding)でメインスレッドを解放する
ロングタスクを撃退するための基本戦術は、「大きな仕事を細切れにして、適度にブラウザに主導権を返す(Yield)」ことだ。
ブラウザに主導権を返せば、その隙にブラウザはレンダリングやユーザー入力の処理を挟み込むことができる。
現代のフロントエンド開発において、これを美しく実装するためのベストプラクティスを見ていこう。
1. `scheduler.yield()` を使った最先端のアプローチ(推奨)
もしターゲットブラウザが対応しているなら、W3Cで策定が進んでいる `isInputPending()` や `scheduler.yield()` を使うのが最もモダンだ。これにより、ユーザーの入力が待機している場合は優先して処理を譲ることができる。
2. `setTimeout` や `MessageChannel` を使ったフォールバック実装
まだレガシーな環境を考慮しなきゃいけないプロジェクトもあるだろう。そんなときは、イベントループの仕組みを利用してタスクを非同期に分割するヘルパー関数を作るのが現場の定石だ。
実務でそのままコピペして使える、堅牢なタスク分割ユーティリティを書いておいた。
/
- メインスレッドのブロッキングを防ぐため、処理を小さなチャンクに分割するヘルパー
- @param {Array} items 処理対象の配列
- @param {Function} callback 1アイテムごとの処理
- @param {number} [chunkSize=50] 一度に処理する数
/
export async function processInChunks(items, callback, chunkSize = 50) {
let index = 0;
const processChunk = () => {
return new Promise((resolve) => {
// タイムアウトを挟むことで、一度コールスタックを空にし、
// ブラウザにレンダリングや入力検知のタイミング(制御権)を与える
setTimeout(() => {
const heritageIndex = Math.min(index + chunkSize, items.length);
// チャンク分だけ処理を回す
for (; index < heritageIndex; index++) {
callback(items[index], index);
}
resolve();
}, 0);
});
};
while (index < items.length) {
await processChunk();
// 必要であればここでユーザーの入力待ち(isInputPendingなど)をチェックすることも可能
}
}
実際のコンポーネントでの使用例
例えば、大量のリストアイテムを画面に描画する処理でこれを使ってみよう。
import { processInChunks } from ‘./utils/processInChunks.js’;
const renderHugeList = async (hugeDataArray) => {
const container = document.getElementById(‘list-container’);
container.innerHTML = ”; // 初期化
console.time(‘レンダリング完了まで’);
await processInChunks(hugeDataArray, (item) => {
const itemEl = document.createElement(‘div’);
itemEl.className = ‘list-item’;
itemEl.textContent = item.name;
// ここで複雑なDOM構築やスタイル適用を行う想定
container.appendChild(itemEl);
}, 30); // 1フレーム(16ms)の負荷を見ながら30件ずつ刻む
console.timeEnd(‘レンダリング完了まで’);
console.log(‘ユーザーはフリーズなしで快適に操作できています!’);
};
このコードを実行すると、コンソールには数ミリ秒おきにチャンク処理のログが流れ、その合間にブラウザはスムーズに画面の再描画やスクロール処理を行う。UIがカクつくことはピタリとなくなるはずだ。
—
シニアからのプロとしての心構え
パフォーマンスチューニングで一番やりがちなミスは、「問題が起きてから計測せずに闇雲にコードを書き換えること」だ。
必ず、Chrome DevToolsの Performance パネル を開き、赤いバー(Long Task)が立っていないか確認しなさい。そして今回紹介したタスク分割を適用した後に、その赤いバーがキレイに消え去り、FPSのグラフが緑色の安定した波形に戻るのを確認するんだ。
フロントエンドの仕事は、動くものを作るだけじゃない。「ユーザーの時間を奪わない、滑らかな体験を設計する」ことだ。
今日のこの知見を、次の機能開発から必ずコードに落とし込んでくれよ。期待しているぞ!

コメント