長いタスクを分割するscheduler.yieldという提案
3 行まとめ
-
scheduler.yieldが Chrome115 から origin trial で試せるように -
scheduler.yieldを使うと長いタスクを分割できる -
scheduler.yieldではユーザーのインタラクション以外のタスクが割り込まない
Long task の問題とタスクの分割
ブラウザのメインスレッドを占有するような実行時間が長いタスク(Long task)は、そのタスクが実行されている間に何かしらのインタラクションがあっても、タスクが終わるまでブラウザはインタラクションに対応できません。

クリックしても長いタスクが終わるまではクリックのタスクは実行されない
こういった長いタスクを分割することで、インタラクションなどの優先度の高いタスクに対応することができます。これは Core Web Vitals の指標であるInteraction to Next Paint(INP)の向上も期待できます。

タスク分割をすることでクリックのタスクが次のタスクとして実行される
async/await と setTimeoutを使ったタスクの分割
長いタスクを分割する方法として、async/await と setTimeoutを使った方法があります。
実際にフォーム画面で submit した際の一連の長い処理を例にタスクの分割をみてみましょう。
この例では submit された際に、以下の処理を行います。
- バリデーション
- ローディングの表示
- 入力内容を保存する API へ送信
- submit 後の画面の更新
- アナリティクスの送信
function validateForm() {
// バリデーション
}
function showSpinner() {
// submit後のローディングの表示
}
function saveToDatabase() {
// 入力内容を保存するAPIへ送信
}
function updateUI() {
// submit後の画面の更新
}
function sendAnalytics() {
// アナリティクスの送信
}
function handleSubmit() {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
タスクの実行状況は Chrome DevTools の Performance タブ上で確認することもできます。

この長いタスクを async/await と setTimeoutを使って分割してみましょう。
function yieldToMain() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
async function handleSubmit() {
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics,
];
for (const task of tasks) {
task();
await yieldToMain();
}
}
それぞれの処理(関数)を配列に格納し、for文で順番に実行しています。for文の中では、関数が実行された後にyieldToMainをawaitします。yieldToMainでは、setTimeoutを使って 0ms 後にresolveするPromiseを返しています。
async/await と setTimeoutを使うことで、タスクが Chrome 上でどのように実行されているのか確認してみましょう。

Performance タブを見ると関数ごとにタスクが分割されていることがわかります。
さらに分割されたタスクを見てみると、最初の関数であるvalidateFormは Click 時に実行され、それ以降の関数はsetTimeoutによるタイマー起動時(Timer Fired)に Microtask として実行されています。
長いタスクが分割される仕組み
async/await と setTimeoutを使うと、なぜタスクが分割されるのか順番に見ていきましょう。
まず、フォームが submit された際にhandleSubmitが実行されます。handleSubmitの中では最初の関数であるvalidateFormが実行されます。

次に、handleSubmitはyieldToMainが実行され、setTimeoutによって 0ms 後にresolveするPromiseが返されます。この時、setTimeoutのコールバックの処理は Task Queue に入れられます。

yieldToMainはawaitしているので、返ってくるPromiseが完了するまでhandleSubmitの処理は中断されます。

次のタスクとして、Task Queue にあるsetTimeoutのコールバックの処理が実行されます。

setTimeoutのコールバックの処理が終わると、yieldToMainのPromiseがresolveされるので、handleSubmitの後続の処理が再開されます。for文の中では次の関数であるshowSpinnerとyieldToMainが Microtask Queue に入れられます。

Microtask Queue に入れられたshowSpinnerとyieldToMainが実行されます。

yieldToMainが実行され、setTimeoutのコールバックの処理は Task Queue に入れられます。(以降、関数分、この流れが繰り返される)

async/awaitとsetTimeoutを使った際の問題点
async/awaitとsetTimeoutを使って長いタスクを分割した場合、ユーザーのインタラクション以外のタスクが、分割したタスクの間に割り込んでしまうことがあります。
例えば、サードパーティのスクリプトがsetIntervalを使い、一定の間隔で何かしらの処理をしていた場合などが挙げられます。実際にsetIntervalを実行中にasync/awaitとsetTimeoutを使ったタスク分割を行うとsetIntervalのタスクが間に割り込んでしまうのが Performance タブで確認できます。

scheduler.yieldとは
前置きが長くなりましたが、今回紹介するscheduler.yieldを使うと、長いタスクを分割する際にユーザーのインタラクション以外のタスクが、分割したタスクの間に割り込まないようにできます。
scheduler.yieldは Chrome115 以降でフラグか origin trial で試せます。
使い方も簡単で、前述したサンプルコードのyieldToMainをscheduler.yieldに置き換えるだけです。
for (const task of tasks) {
task();
- await yieldToMain();
+ await scheduler.yield();
}
実際にsetIntervalを実行中にscheduler.yieldを使ったタスク分割を行うと、setIntervalのタスクに割り込まれません。

DEMO
Chrome115 以上で origin trial のトークンが有効な間は、以下のリンクでscheduler.yieldの動作を確認できます。
Start ボタンを押すと、setIntervalのタスクが一定間隔で実行します。その後、yielding setTimeout のボタンを押すとasync/awaitとsetTimeoutを使ったタスク分割が実行され、setIntervalのタスクが間に割り込んでくるのを確認できます。

また、Start ボタンを押してから yielding scheduler.yield のボタンを押すと、scheduler.yieldを使ったタスク分割が実行され、setIntervalのタスクが割り込まないことを確認できます。

ソースコードは以下のリポジトリで公開しています。
あとがき
scheduler.yieldは、オリジントライアルかフラグ(chrome://flags/)を設定することで Chrome 上で気軽に使えます。また、フィードバックも募集しているようなので、興味がある人はぜひ試してみてください。
Discussion