質問とそれを使用するタイミング
データダッシュボードでのクリックを処理しています。まず、以下のコードの出力を予測してください。
console.log("A");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => {
console.log("promise");
queueMicrotask(() => console.log("nested"));
});
queueMicrotask(() => console.log("microtask"));
console.log("B");すべてのステップを説明した上で、次のパフォーマンス問題を分析してください。
let remaining = 100_000;
function continueInMicrotask() {
remaining -= 1;
if (remaining > 0) {
queueMicrotask(continueInMicrotask);
}
}
queueMicrotask(continueInMicrotask);
setTimeout(() => console.log("timer can run"), 0);
requestAnimationFrame(() => console.log("frame can render"));タイマー、その後のクリック、および描画が遅延する理由を説明し、ページが応答性を保てるようにバッチ計算を再設計してください。この質問はブラウザのメインスレッド上のJavaScriptに関するものです。Workerには独自のイベントループがあり、Node.jsにはフェーズモデルがあるため、ブラウザの回答にそのまま持ち込むべきではありません。
これは、ミドルレベルおよびシニアのフロントエンド、Webパフォーマンス、フルスタックの面接に有用です。優れた回答は、スケジューリングモデルを観察可能なユーザー体験に結びつけます。つまり、最終的に完了したとしても、処理の実行中にページがインタラクティブな状態を維持できたことを意味するわけではない、ということです。
面接官が評価している点
第1のシグナルは、正確なモデルです。初期スクリプトの実行、クリックのコールバック、期限切れのタイマーコールバックはタスクとして実行されます。Promiseのリアクション、queueMicrotask()のコールバック、MutationObserverのコールバックはマイクロタスクを使用します。タスクが終了した後、イベントループはマイクロタスクチェックポイントを実行し、キューが空になるまでマイクロタスクを処理し続けます。
第2のシグナルは、候補者がブラウザに永続的な「マクロタスクキュー」が1つだけ存在するかのような説明を避けているかどうかです。HTML標準では、異なるタスクソースを異なるタスクキューに関連付けることが認められています。ブラウザは、1つのタスクソース内での順序を維持しつつ、実行可能なキューの中から実装定義の選択を行うことができます。「マクロタスク」は口頭の略称として使われることがありますが、標準におけるより正確な用語は「タスク(task)」です。
第3のシグナルは、正しいレンダリング境界の把握です。マイクロタスクチェックポイントが終了した後にのみ、ブラウザは他のタスクやレンダリング更新に進むことができ、レンダリングはすべてのタスクの後に保証されているわけではありません。キューが空になる前にコードが別のマイクロタスクを追加し続けると、入力イベント、タイマー、レンダリング機会のすべてが枯渇(starvation)する可能性があります。
第4のシグナルは、適切なスケジューリングプリミティブの選択です。await Promise.resolve()はマイクロタスク内で関数を再開するだけであるため、後続のタスクや描画を先に実行させることはできません。真のメインスレッドの譲歩(yield)は、サポートされている環境でのscheduler.yield()やsetTimeout()フォールバックなど、将来のタスクで継続をスケジュールします。安全に分割できないCPU処理はWorkerに配置すべきです。
回答前に確認すべき質問
- どのブラウザをサポートする必要がありますか?
scheduler.yield()は広く使用されているすべてのブラウザで利用できるわけではないため、幅広いサポートには機能検出とフォールバックが必要です。 - 処理はレコード間で分割できますか、それとも1回の呼び出しが長時間ブロックする可能性がありますか? 分割可能なループはバッチ間で譲歩できます。1つのレコード自体が重い場合、メインスレッドでのバッチ処理でも依然として長いブロックが発生するため、Workerを使用するかアルゴリズムを変更します。
- バッチごとに進捗を描画する必要がありますか、それとも最終結果のみが必要ですか? 視覚的な進捗には、状態更新後にメインスレッドを譲歩する必要があります。最終結果のみでよい場合は、繰り返されるDOM、レイアウト、描画のコストを回避できます。
- 結果は厳密な順序でコミットする必要がありますか? Workerは並行して計算できますが、順不同の完了にはシーケンス番号、マージルール、または順序付けられたコミットバッファが必要です。
- バックグラウンドタブでも処理を継続する必要がありますか?
requestAnimationFrame()はほとんどのバックグラウンドタブで一時停止されるため、必須のバックグラウンド処理用の一般的なスケジューラーとしては不適切です。 - 応答性はどのように判定されますか? インタラクションレイテンシ、バッチごとの予算、合計スループット、キャンセル動作について合意し、「フリーズしない」ことをテスト可能にします。
30秒の回答フレームワーク
「ブラウザは実行可能なタスクを1つ選択して実行し、その後にマイクロタスクキューを空にするマイクロタスクチェックポイントを実行します。その後でのみ、レンダリングや別のタスクに移行できます。最初のスニペットはAとBを同期的にログ出力し、その後にエンキュー順でpromiseとmicrotaskを出力します。promiseのコールバックは、すでに待機しているmicrotaskの後ろにnestedを追加し、timeoutが最後に実行されます。再帰的なマイクロタスクはチェックポイントを開いたままにし、タイマー、入力、レンダリングを枯渇させます。await Promise.resolve()は依然としてマイクロタスクであるため、真のメインスレッドの譲歩ではありません。私は分割可能な処理をタイムスライスし、バッチ間で機能検出したscheduler.yield()を呼び出し、フォールバックとしてsetTimeout()を使用します。1つの単位が依然としてCPUヘビーである場合はWorkerに移し、パフォーマンス記録と実際の入力でインタラクションレイテンシを検証します。」
ステップごとの解決策
ステップ1: エンキュー時刻から出力を導き出す
現在、スクリプト全体が1つのタスクとして実行されています。同期文はイベントループを待たないため、最初の出力は次のようになります。
A
BsetTimeout(..., 0)は、タイミング条件が満たされた後、そのコールバックが将来のタスクになり得ることを意味します。0であっても現在のスクリプトを中断することはありません。Promise.resolve().then(...)はPromiseのリアクションを最初のマイクロタスクとしてキューに入れ、続くqueueMicrotask(...)が2番目のマイクロタスクをキューに入れます。
スクリプトタスクが終了すると、マイクロタスクチェックポイントが始まります。最初のマイクロタスクはpromiseをログ出力し、マイクロタスクキューの末尾にnestedを追加します。明示的なマイクロタスクはすでに待機していたため、nestedの前にmicrotaskを出力します。マイクロタスクキューが空になると、タイマータスクに実行の機会が与えられます。
A
B
promise
microtask
nested
timeout各行について、コールバックがいつキューに入り、どのスケジューリングカテゴリに入るかを記録します。競合するコールバックの1つがまだキューに入れられていない場合、「マイクロタスクが優先される」という理解だけでは不十分です。
ステップ2: タスク、マイクロタスク、レンダリングの境界を確立する
再利用可能な簡略化されたシーケンスは次のとおりです。
- ブラウザは実行可能なタスクキューからタスクを1つ選択します。
- JavaScriptのコールスタックが空になるまでそのタスクを実行します。
- マイクロタスクチェックポイントを実行します。チェックポイント中に追加されたマイクロタスクは、同じチェックポイント内で処理されます。
- レンダリング機会、ドキュメントの可視性、実装ポリシーに基づいて、ブラウザはレンダリングを更新する場合があります。
- ループが継続し、別のタスクを処理できます。
これは2つの実践的な観察結果を説明しています。第1に、クリックハンドラーがDOMを変更し、直ちに大規模な同期計算を開始した場合、ブラウザが描画機会を取り戻していないため、ユーザーには通常中間状態が見えません。第2に、マイクロタスクは他のイベントやタイマーの前に発生しなければならない短い整合性維持の処理には適していますが、無制限または大規模な再帰的計算には適していません。
ステップ3: マイクロタスクの枯渇を診断する
2番目のスニペットで最初のマイクロタスクが呼び出されるたびに、別のマイクロタスクがキューに入れられます。キューが空になるまでチェックポイントは終了できないため、タイマータスクや次のクリックタスクが実行される前に100,000回のコールバックが完了してしまいます。
終了条件がない場合、スケジューリングモデル上マイクロタスクキューは決して空になりません。ブラウザは最終的に応答不能ページの警告を表示したり、ページを終了したり、実装上の保護機能を適用したりする可能性がありますが、アプリケーションの正確性をそれに依存することはできません。requestAnimationFrame()は実行中のJavaScriptをプリエンプト(中断)しません。将来の再描画の前にコールバックを要求するだけです。メインスレッドが該当するレンダリングステップに到達しなければ、コールバックは待機し続けます。
この一見譲歩しているように見える処理も依然として誤りです。
async function processAll(records) {
for (const record of records) {
normalize(record);
await Promise.resolve();
}
}各awaitの継続は、Promiseのマイクロタスクを通じて再開されます。コールスタックは一時的に空になりますが、チェックポイントがそれらのマイクロタスクを消費し続けるため、後続の入力タスクやタイマータスクは依然として入ることができません。
ステップ4: 分割可能な処理をタスク間に分散する
各バッチに測定可能な時間予算を与え、バッチ間で真の譲歩を行います。
function yieldToMain() {
return globalThis.scheduler?.yield
? globalThis.scheduler.yield()
: new Promise((resolve) => setTimeout(resolve, 0));
}
async function processRecords(records, budgetMs = 5) {
let index = 0;
while (index < records.length) {
const deadline = performance.now() + budgetMs;
while (index < records.length && performance.now() < deadline) {
normalize(records[index]);
index += 1;
}
updateProgress(index / records.length);
if (index < records.length) {
await yieldToMain();
}
}
}5ミリ秒という値はこの演習用の初期想定であり、デバイス共通の標準ではありません。アイテムあたりのコスト、インタラクションレイテンシ、スループットに対して、ターゲットデバイス上で調整してください。scheduler.yield()は継続を後続の優先タスクとしてスケジュールし、ブラウザに必要な作業を先に処理する機会を与えます。ブラウザのサポートが完全ではないため、この例では機能検出を行っています。setTimeout()フォールバックはより広範に対応しますが、タイマーのクランプ処理(timer clamping)、バックグラウンドポリシー、他のタスクとの競合の影響を受けます。
もう1つの境界があります。予算はnormalize()の呼び出し間でのみチェックできるということです。1回の呼び出しが80ミリ秒ブロックする場合、5ミリ秒の予算は何の役にも立ちません。normalize()を分割するか、アルゴリズムを置き換えるか、計算をWorkerに移行してください。
ステップ5: 処理に適したプリミティブを選択する
| 要件 | 選択肢 | 主なコストまたは境界 |
|---|---|---|
| 現在のタスクの後、他のイベントの前に短いクリーンアップや整合性通知を実行する | queueMicrotask() | 再帰や重い計算が他の作業を枯渇させる |
| 優先順位付けされた継続を維持しながら、長いメインスレッド処理を分割する | scheduler.yield() | 機能検出が必要。サポートが完全ではない |
| 幅広い互換性を持って継続を将来のタスクに移す | setTimeout() | タイマーの遅延とスケジューリングが決定的ではない |
| 将来の再描画の前にアニメーション状態を更新する | requestAnimationFrame() | 重いコールバック処理は依然としてその描画をブロックする。バックグラウンドタブでは通常一時停止される |
| 安全に分割できないCPUヘビーな処理を実行する | Web Worker | メッセージ、コピー、または共有メモリプロトコルのコスト |
requestAnimationFrame()は視覚的な作業を描画に合わせるものであり、一般的なバックグラウンドジョブキューではありません。描画の直前に大規模な計算を隠すためではなく、軽量な視覚的変更を送信するために使用してください。WorkerはページのメインスレッドからCPU計算を取り除きますが、キャンセル、進捗報告、結果の順序付け、転送コストを自動的に解決するわけではありません。これらには明示的なプロトコルが必要です。
ステップ6: 完了だけでなく応答性を検証する
検証は少なくとも4つのレイヤーをカバーする必要があります。
- 順序テスト: 最小限のページで同期コード、Promiseのリアクション、
queueMicrotask()、タイマーをログ出力し、観察された順序が導出結果と一致することを確認します。 - タイムラインの検査: ブラウザのパフォーマンスツールでクリック、バッチ、進捗の描画を記録します。ロングタスク、継続的なマイクロタスク、フレームギャップ、入力コールバックが実際にいつ実行されるかを検査します。
- 負荷とキャンセル: レコード数を増やし、CPUをスロットリングし、処理中にクリックやスクロールを行い、操作をキャンセルします。キューが無制限に増加しないことを確認します。
- 境界環境:
scheduler.yield()を持たないブラウザでフォールバックを検証します。ページをバックグラウンドタブに移動し、ビジネスワークフローが継続的なrequestAnimationFrame()コールバックに誤って依存していないことを確認します。
受け入れには完了時間とインタラクションレイテンシの両方が必要です。バッチ処理はスケジューリングのオーバーヘッドを追加し、合計時間をわずかに増加させる可能性があります。その目的は、入力、描画、その他の必要なタスクのための実行ウィンドウを残すことです。そのスループットコストが許容できない場合は、メインスレッドを再びマイクロタスクで埋めるのではなく、アルゴリズムを最適化するかWorkerを使用してください。
優れた回答の例
「エンキュー時刻から順序を導出します。現在のスクリプトは1つのタスクであるため、AとBは同期的です。タイマーは将来のタスクをスケジュールするだけです。Promiseのリアクションは明示的なqueueMicrotaskコールバックよりも前にマイクロタスクキューに入るため、チェックポイントはまずpromiseを出力します。そのコールバックは、すでに待機しているmicrotaskの後ろにnestedを追加します。最終的な順序は、A、B、promise、microtask、nested、timeoutです。
タスクの後、ブラウザはマイクロタスクチェックポイントを実行し、マイクロタスクキューが空になるまで処理を続けます。2番目のスニペットはそのキューを補充し続けるため、チェックポイントが長時間開いたままになります。タイマーとクリックは後続のタスクであり、描画はメインスレッドがレンダリング機会に到達する必要があるため、それらのすべてが遅延します。requestAnimationFrameはJavaScriptをプリエンプトできず、await Promise.resolveは別のマイクロタスクで再開するだけであるため、どちらも枯渇を解決しません。
各レコードの処理が高速である場合、時間予算に沿って処理し、バッチ間でscheduler.yieldを呼び出します。すべてのブラウザで利用できるわけではないため、機能検出を行ってsetTimeoutにフォールバックします。5ミリ秒の予算から始めるのはあくまで実験であり、入力レイテンシとスループットに対してターゲットデバイス上で調整します。1つのレコード自体が重い場合は、その操作を分割するかWorkerに移行します。
最後に、パフォーマンスタイムラインを記録し、連続したマイクロタスクのウォーターフォールがないこと、進捗が実際に描画されていること、処理中にもクリックやスクロールが実行されること、そしてキャンセル、バックグラウンドタブ、互換性フォールバックが正しく動作することを確認します。この設計は、測定可能な応答性と引き換えに、ある程度のスケジューリングオーバーヘッドを受け入れるものです。」
よくある間違い
- 「同期、マイクロタスク、マクロタスク」と暗記して唱える → ネストされたマイクロタスクがすでにキューに入っているものの後ろで実行される理由を説明できず、複数のタスクソースを無視している → エンキュー時刻、カテゴリ、キューの状態を1行ずつアノテーションする。
setTimeout(..., 0)を即時と呼ぶ → 期限切れのタイマーは将来のタスクを実行可能にするだけであり、現在のタスクやチェックポイントをプリエンプトすることはできない → 正確な時間を約束することなく、最も早い実行可能タイミングを述べる。- すべてのマイクロタスクの後に描画が発生すると想定する → チェックポイントはマイクロタスクを継続的に排出し、その後のレンダリング機会もスキップされる可能性がある → チェックポイント全体が完了した後の描画について議論する。
await Promise.resolve()でチャンク化する → 継続は依然としてマイクロタスクであり、後続の入力タスクやタイマータスクを受け入れない → 将来のタスクで継続をスケジュールする。- 「優先度」のために終わりのない
queueMicrotask()の呼び出しを使用する → 他のタスクやレンダリングを枯渇させる → マイクロタスクは短く有限な整合性維持の処理に限定する。 - 重い処理を
requestAnimationFrame()に移す → コールバックは依然として描画前のメインスレッドで実行されるため、重い処理はフレームを遅延させる → rAFの視覚的処理は軽量に保ち、CPU処理をチャンク化またはオフロードする。 scheduler.yield()を無条件で呼び出す → 広く使われている一部のブラウザはサポートしていない → 機能検出し、setTimeout()フォールバックをテストする。- 合計時間のみを測定する → 通常のスループットの裏に、入力に応答しない長い期間が隠れている可能性がある → インタラクションレイテンシ、ロングタスク、フレーム、キューの増加も検査する。
- どこでも1つの固定バッチサイズを使用する → アイテムあたりのコスト、リフレッシュレート、デバイス速度は異なる → 時間予算から始めて、ターゲット環境で調整する。
フォローアップの質問と回答
フォローアップ1: 2つの遅延ゼロタイマーとクリックでは、どちらが先に実行されますか?
API名だけでは、タスクソースをまたがる普遍的な順序を確立することはできません。ブラウザは複数のタスクキューを維持し、1つのタスクソース内での順序を維持しながら、実行可能なキューの中から選択することができます。タイマーがいつ実行可能になったか、クリックがいつ発生したか、他に何がページを占有していたかを明確にし、ターゲットの実装を観察してください。決定論的な面接のスニペットでは通常、これらの条件が制御されています。
フォローアップ2: scheduler.yield()がPromiseを返す場合、なぜそれが真の譲歩になるのですか?
重要な特性は、戻り値の型ではなく、APIがどのように解決をスケジュールするかです。すでに解決されたPromiseの後の継続は、現在のマイクロタスクチェックポイントに参加します。scheduler.yield()は、Promiseを解決する後続の優先タスクをスケジュールするため、継続の前に入力などの保留中の作業を実行させることができます。これには依然として機能検出が必要であり、ごく小さな操作ごとに個別のタスクにすべきではありません。
フォローアップ3: Workerで100,000レコードを処理してもページがフリーズすることはありますか?
はい。過剰なメッセージ、大量のデータコピー、頻繁なDOM変更、またはコストの高い結果のマージによって、メインスレッドが依然として過負荷になる可能性があります。進捗メッセージをバッチ化し、その頻度を制限し、Transferable Objectsや正当な理由のある共有メモリプロトコルを検討し、メインスレッドでは必要な視覚的変更のみをコミットしてください。
フォローアップ4: queueMicrotask()はどのような場合に使用すべきですか?
現在の同期ロジックの後、他のイベントの前に実行する必要がある短く有限な処理(例えば、同期的なキャッシュヒットとPromiseベースのキャッシュミスで同じコールバック順序を提供する場合や、内部ライブラリの通知を1回バッチ処理する場合など)に使用します。これは長時間の処理用スケジューラーではありません。終了ルールと処理の制限範囲の両方を述べてください。
フォローアップ5: チャンク化された操作をどのようにキャンセルしますか?
各バッチの開始時および各譲歩の後にAbortSignalをチェックし、レコードの取得を停止し、古い操作が新しいリクエストの進捗や結果を上書きしないようにします。Worker設計では、キャンセルメッセージと遅れて届いた結果を破棄するルールが必要です。チェックがまばらすぎると反応が遅くなり、ごく小さな操作ごとにチェックするとオーバーヘッドが増えるため、バッチの境界で測定してください。
フォローアップ6: requestAnimationFrame()は即座の描画を保証しますか?
いいえ。将来の再描画の前にコールバックを要求するだけであり、ブラウザは可視性やレンダリングポリシーに基づいてレンダリングをスケジュールまたはスキップできます。ほとんどのブラウザは、バックグラウンドタブでこれらのコールバックを一時停止します。コールバックが実行された場合でも、その中の長い処理は実際の描画を遅延させ続けます。