こんにちは、チーフアーキテクトの私だ。日夜、膨大なJavaScriptの実行とDOMの海に頭を悩ませている君なら、一度はユーザーからの「なんだかこの画面、クリックしてから反応するまでに引っかかりがあるんだよね」という、胃に穴が開きそうなフィードバックに直面したことがあるはずだ。
原因は明白、メインスレッドの詰まりだ。BlinkやWebKitといったモダンなブラウザエンジンは、1秒間に60回(ハイエンド機なら120回)の画面描画――いわゆる「フレーム」を死守しようと裏で凄まじい計算を叩いている。そこに巨大なDOMツリーの走査や、アナリティクスのデータ整形、サードパーティの重いスクリプトが割り込んできた瞬間、メインスレッドは一気に息絶え、ユーザーのタップやスクロールは冷たく無視されることになる。
この「メインスレッドの過密スケジュール」をハックし、ブラウザの隙間時間を巧みに奪い取るための切り札が、今回深掘りする `requestIdleCallback` だ。教科書通りの使い方で満足している上級エンジニアの君に向けて、ブラウザの内部構造の泥臭い真実と共に、その極限の活用法を授けよう。
—
1. メインスレッドの裏側:なぜ `requestIdleCallback` なのか?
まず、ブラウザが裏で何をやっているかを思い出してほしい。私たちは普段、`requestAnimationFrame` (rAF) を使ってアニメーションを滑らかに描画しようとする。rAFのコールバックは、スタイル計算、レイアウト(リフロー)、ペイント、そしてコンポジットが走る直前の「最も忙しい瞬間」に実行される。
一方、`requestIdleCallback` (rIC) が狙うのはその対極だ。ブラウザが1フレームの描画を終え、次のフレームまで「数ミリ秒の余白(Idle Period)」が生まれた瞬間を見計らって割り込む。
[ ユーザー入力 ] -> [ rAF (アニメーション) ] -> [ スタイル / レイアウト / ペイント ] -> [ 描画完了! (ここから次のVsyncまでが Idle Time) ]
└── requestIdleCallback の出番!
しかし、ここに甘えがあってはならない。ブラウザのアイドル時間は「暇」なのではなく、「次の入力やネットワークイベントが来るまでの猶予時間」に過ぎない。もしアイドルタスクがその猶予時間を食いつぶせば、次にやってきたユーザーのタッチイベントの初動が遅れ、あの忌々しい「Jank(カクつき)」が発生する。
—
2. メモリ効率とタスクのチャンク分割(Chunking)
よくあるアンチパターンとして、`requestIdleCallback` が渡してくれる `IdleDeadline`(残り時間を示すオブジェクト)を無視して、重い処理を一気に流し込むコードを見かける。これでは本末転倒だ。
真に堅牢なアーキテクチャを目指すなら、「タイムスライシング(Time Slicing)」を実装し、処理を細切れのチャンクに分割してアイドル時間に少しずつ消化させなければならない。さらに、V8エンジンにおけるガベージコレクション(GC)のプレッシャーも考慮する必要がある。アイドル時間中に大量のオブジェクトを生成・破棄すると、後続のフレームでGCが走り、結局レンダリングがブロックされる。
以下の実用的なコードを見てほしい。巨大な配列のデータを非同期で安全に処理する、プロダクションレベルのチャンク処理エンジンだ。
/
- 巨大なデータ配列を、メインスレッドをブロックせずに安全に処理するユーティリティ
- @param {Array} items – 処理対象のデータ配列
- @param {Function} processor – 各要素に対する処理関数
- @param {Function} onComplete – 全処理完了時のコールバック
/
function processChunksInIdle(items, processor, onComplete) {
let index = 0;
// 再帰的に実行されるアイドルタスク
const runChunk = (deadline) => {
// timeRemaining() が 0 より大きく、かつ未処理のアイテムがある間ループを回す
// 余裕を持たせるため、deadline.didTimeout が真でない限りは安全マージンを意識する
while ((deadline.timeRemaining() > 1 || deadline.didTimeout) && index < items.length) {
try {
processor(items[index], index);
} catch (error) {
console.error(`チャンク処理中にエラーが発生しました (index: ${index}):`, error);
}
index++;
}
if (index < items.length) {
// まだ残っている場合は、次のアイドル時間をリクエスト
// 万が一ブラウザが忙しすぎてアイドルが回ってこない場合を考慮し、
// timeout オプションで強制実行の猶予(予算)を持たせる
requestIdleCallback(runChunk, { timeout: 500 });
} else {
// 全て完了
if (typeof onComplete === 'function') {
onComplete();
}
}
};
// 最初のアイドルリクエストを発行
requestIdleCallback(runChunk, { timeout: 500 });
}
// --- 使用例 ---
// 10万件のログデータを裏でこっそり整形・解析する想定
const massiveLogData = new Array(100000).fill(0).map((_, i) => ({ id: i, message: `Log entry ${i}` }));
processChunksInIdle(
massiveLogData,
(item, idx) => {
// 重い文字列操作や解析処理に見立てたダミー処理
item.parsed = `[Processed] ${item.message.toUpperCase()}`;
},
() => {
console.log(‘すべてのバックグラウンド処理がメインスレッドを汚さずに完了しました。’);
}
);
—
3. 非同期の競合と重大なバグ:AbortControllerとの統合
実務で `requestIdleCallback` を扱う際、最も頭を悩ませるのが「コンポーネントのアンマウントや状態の変化に伴うキャンセル処理の欠落」だ。
例えば、ユーザーがSPA(Single Page Application)の画面を素早く切り替えたとき、古い画面のバックグラウンドタスクが生き残り、すでに存在しないDOMを操作しようとしてTypeErrorを吐いたり、メモリリークを引き起こしたりするケースが後を絶たない。
ここを完璧に制御するためには、標準の `cancelIdleCallback` と `AbortController` を組み合わせるのが、モダンフロントエンド・アーキテクトの作法だ。
/
- AbortControllerに対応した、堅牢なrequestIdleCallbackラッパー
- @param {Function} callback – 実行するタスク
- @param {Object} options – { timeout, signal }
- @returns {Promise}
/
function cancellableIdleCallback(callback, options = {}) {
const { timeout, signal } = options;
return new Promise((resolve, reject) => {
// すでにシグナルが中断されている場合
if (signal?.aborted) {
return reject(new DOMException(‘Aborted’, ‘AbortError’));
}
const idleId = requestIdleCallback((deadline) => {
if (signal?.aborted) return;
try {
resolve(callback(deadline));
} catch (err) {
reject(err);
}
}, { timeout });
// AbortSignalのリスナーを登録
if (signal) {
signal.addEventListener(‘abort’, () => {
cancelIdleCallback(idleId);
reject(new DOMException(‘Aborted’, ‘AbortError’));
}, { once: true });
}
});
}
// — 使用例:ReactのuseEffectやカスタムフック内での安全なクリーンアップ —
/
const controller = new AbortController();
cancellableIdleCallback((deadline) => {
// 重い初期化処理やプリフェッチ
while (deadline.timeRemaining() > 0 && !controller.signal.aborted) {
// 処理…
}
}, { signal: controller.signal, timeout: 1000 })
.catch(err => {
if (err.name === ‘AbortError’) {
console.log(‘タスクは安全にキャンセルされました。’);
} else {
console.error(err);
}
});
// 画面離脱時やクリーンアップ時にこれを発火するだけでメモリリークを防げる
// controller.abort();
/
—
4. サファリ(WebKit)の亡霊とポリフィルの罠
さて、ここで現実的な泥臭い話をしよう。実は Apple の Safari(iOS含む)は、長年 `requestIdleCallback` のネイティブ実装に対して非常に消極的だった(※近年のバージョンではサポートが進んでいるが、古い環境や特定のWebViewでは依然として未実装なケースがある)。
「よし、じゃあ適当なポリフィルを入れよう」と安易に `setTimeout(fn, 1)` や `requestAnimationFrame` をベースにしたポリフィルを入れるのは待ってほしい。
`setTimeout` を使うと、ブラウザは「暇な時間」ではなく「タイマーの期限が来た瞬間」に実行しようとするため、かえってアニメーションの最中にタスクが割り込み、Jankを引き起こす最悪の結果を招く。
もし真の意味でフォールバックを実装するなら、メッセージチャンネル(`MessageChannel`)や、ブラウザの描画サイクルの裏を突く巧妙なタイミング制御を行うべきだ。しかし、現代の開発においては、未実装環境では「単に即時実行を諦めて次フレームに送る(あるいは単発の `setTimeout` で逃げる)」という割り切りも、アーキテクチャの安定性(予測可能性)を保つ上で立派な選択肢となる。
—
5. チーフアーキテクトからの提言
`requestIdleCallback` は、魔法の杖ではない。メインスレッドの根本的な肥満化――例えば「無駄に巨大なバンドルサイズ」や「過剰なReactのコンポーネントツリーの再評価」を隠蔽するための免罪符として使ってはならない。
しかし、アナリティクスのビーコン送信、非クリティカルなデータのプリフェッチ、インメモリキャッシュのクレンジング、シンタックスハイライトの遅延処理など、「ユーザーのインタラクションの邪魔をしてはならないが、いつかは絶対にやらなければならないバックグラウンド処理」を調停する上で、これほどエレガントなAPIは他に存在しない。
ブラウザの鼓動を感じ取り、メインスレッドの呼吸の隙間を縫うようにコードを走らせる。この細部へのこだわりこそが、凡百のWebアプリと、触れた瞬間に「おっ」と思わせる超高速なアプリケーションを分ける境界線だ。
さあ、君のコードベースのどこにある「メインスレッド泥棒」を、アイドル時間に追放しに行こうか。

コメント