Webパフォーマンス向上のためのJS優先度付け実行メソッド比較: 使い分けガイド
JSの実行について
ユーザーのメインスレッドを闇雲に奪わないことは、重要です。
(今年からCWVにINPが追加され、SEO的に従来のFIDよりも厳しい基準で評価されているかと思います)
その際に特定の処理によって、メインスレッドを阻害しないことが大事ですが、
JavaScriptには、Promise
をはじめ、requestIdleCallback
、queueMicrotask
、Prioritized Task Scheduling API
など、優先度を変更を実現する方法がいくつもあります。選択肢が多いため、どれを選ぶべきか迷うことがあります。この記事では、メインスレッド上での優先度付け実行について、それぞれの手法を整理し、検証していきます。
※ WebWorker
などの並行処理は今回は対象外とします。
検証環境
検証用のリポジトリはこちらです:
asyncjs-validater (GitHub)
以下の流れで検証を行います:
- 対象関数の実行
- 1000ms後に同期実行
- ユーザーアクションによる同期実行
具体的なコード例は下記を参照
// CPUを占有する非同期の重いタスク関数
function heavyTask(durationMs) {
const start = performance.now();
while (performance.now() - start < durationMs) {
// CPU負荷をかけるためのループ
}
}
async function runAsyncFunction() {
// 1000ms後にheavyTaskを実行
setTimeout(async () => {
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'start');
heavyTask(1000); // 1秒間のCPU負荷
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'end');
}, 1000);
// 各種実行の検証
asyncFunction()
}
/* canvasの描画
* heavyaction,検証用の実行,ユーザーアクションを描画する
*/
function drawTimeline() {}
/*
* 押下時にheavyTaskを実行
*/
document.getElementById('userAction').addEventListener('click', async () => {
logMessage('User action', 'User Action', 'User Action Task', 'start');
heavyTask(500); // 0.5秒のCPU負荷の処理
logMessage('User action', 'User Action', 'User Action Task', 'end');
});
document.getElementById('startTest').addEventListener('click', runAsyncFunction);
実行例
今回の検証では、
- async functionでの検証
- settimeoutでの検証
- queuemicrotaskでの検証
- requestIdleCallbackでの検証
- Prioritized Task Scheduling APIでの検証
の5つを行います
検証結果は、まとめへ
また、Chrome 129 での実行なため、他ブラウザでは異なる挙動になる場合があります
async functionでの検証(IIFE)
async function runAsyncFunction() {
// 1000ms後にheavyActionを実行
setTimeout(async () => {
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'start');
heavyTask(1000); // 1秒間のCPU負荷
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'end');
}, 1000);
for (let i = 1; i <= 3; i++) {
(async() => {
logMessage(`Async function part ${i}`, 'asyncFunction', `Task ${i}`, 'start');
heavyTask(1000)
logMessage(`Async function part ${i}`, 'asyncFunction', `Task ${i}`, 'end');
})(); // 1秒間CPUを占有
}
}
for文で、asyncの即時実行関数を実行するようにします。
下記が実行結果です
heavyActionも、ユーザーアクションもおこなってますが、
async functionの実行の方が優先されているのがわかります。
asyncはawaitを利用しない限り、
同期的に処理されてしまうためただasyncで実行するだけでは、setTimeoutのように遅延実行にはなりません。
またawait 1
などでawaitを差し込んだとしても、await以降の処理がマイクロタスクキューに追加されるだけですので、ユーザーアクションの割り込みを許可することはできません。
setTimeoutでの検証
async実行(setTimeout)
async function runAsyncFunction() {
// 1000ms後にheavyActionを実行
setTimeout(async () => {
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'start');
heavyTask(1000); // 1秒間のCPU負荷
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'end');
}, 1000);
for (let i = 1; i <= 3; i++) {
setTimeout(async() => {
logMessage(`Async function part ${i}`, 'asyncFunction', `Task ${i}`, 'start');
heavyTask(1000);
logMessage(`Async function part ${i}`, 'asyncFunction', `Task ${i}`, 'end');
}, 0); // 1秒間CPUを占有
}
}
for文でsetTimeoutを実行するようにします。
下記が実行結果です。
setTimeout を遅延 0 で呼び出したとしても、直ちに実行するのではなくキューに載せて、次の機会に実行するようスケジューリングされるため(引用: タイムアウトの遅延)、
ユーザーアクションの割り込みも行われ、setTimeoutで1000ms設定されているヘビーアクションは一番最後に実行されます
参考:
sleep関数(await&setTimeout) 差し込み
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function runAsyncFunction() {
// 1000ms後にheavyActionを実行
setTimeout(async () => {
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'start');
heavyTask(1000); // 1秒間のCPU負荷
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'end');
}, 1000);
for (let i = 1; i <= 3; i++) {
logMessage(`Async function part ${i}`, 'asyncFunction', `Task ${i}`, 'start');
heavyTask(1000); // 1秒間CPUを占有
logMessage(`Async function part ${i}`, 'asyncFunction', `Task ${i}`, 'end');
await sleep(0);
}
}
for文で、heavyTaskの後に0秒のTimeoutを設定します
下記が実行結果です
先ほどと異なり、heavyTaskの後にTimeoutの時間待機するsleep関数を差し込むようにしています。
そのため、先ほどのsetTimeoutの実行とは異なり、heavyActionも実行されるようになっています。
こちらは、下記を参照していただくのがわかりやすいです。
こちらで簡易的に説明しますと、
本来、同期関数だけですと、heavyActionのタスクのキューイング + ユーザーアクションのタスクのキューイング+ for文内部の実行ですが、
sleep関数によりタスクのキューイングとタスクの実行が行われるため、次のタスクキューを処理できる余裕ができ、await sleep(0)
のタイミングでタスクキューで実行可能なタスクを取り出すことができるようになるためだと解釈しています
そのため、setTimeoutに意味があるわけではないため、たとえば後述するscheduler.postTask()
を利用して下記のようにもできます。
async function sleep(ms) {
return new Promise(resolve => scheduler.postTask(resolve, {
priority: 'background'
}));
}
実行結果
async function sleep(ms) {
return new Promise(resolve => scheduler.postTask(resolve, {
priority: 'user-blocking'
}));
}
実行結果
queueMicrotaskでの検証
async function runAsyncFunction() {
// 1000ms後にheavyActionを実行
setTimeout(async () => {
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'start');
heavyTask(1000); // 1秒間のCPU負荷
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'end');
}, 1000);
// queueMicrotaskでタスクを実行
for (let i = 1; i <= 3; i++) {
queueMicrotask(() => {
logMessage(`queueMicrotask part ${i}`, 'queueMicrotask', `Task ${i}`, 'start');
heavyTask(1000); // 1秒間CPUを占有
logMessage(`queueMicrotask part ${i}`, 'queueMicrotask', `Task ${i}`, 'end');
});
}
}
for文でqueueMicrotaskを実行します。
下記が実行結果です。
async実行(IIFE) の時同様、for文の中の処理が先に実行されます。
queueMicrotaskは、実行中の関数内部でキューイング処理が走るため、queueMicrotask
でwrapされていない処理が先に実行はされますが、setTimeoutのタスクキューへのキューイングと異なり、マイクロタスクキューへのキューイングとなってしまいます。
マイクロタスクキューはタスクキューよりも優先度高く処理されてしまうため、ユーザーアクションの割り込みを許可するような場合には向いていません。
queueMicrotaskはパフォーマンスやCWV改善のために利用するのは適しておらず、
実行タスクの後にマイクロタスクとして優先度高く実行されるため、実行順序を保証したい時に利用できます。
参考:
requestIdleCallbackでの検証
async function runAsyncFunction() {
// 1000ms後にheavyActionを実行
setTimeout(async () => {
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'start');
heavyTask(1000); // 1秒間のCPU負荷
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'end');
}, 1000);
// requestIdleCallbackでタスクを実行
for (let i = 1; i <= 3; i++) {
requestIdleCallback(async () => {
logMessage(`requestIdleCallback part ${i}`, 'requestIdleCallback', `Task ${i}`, 'start');
heavyTask(1000); // 1秒間CPUを占有
logMessage(`requestIdleCallback part ${i}`, 'requestIdleCallback', `Task ${i}`, 'end');
});
}
}
for文でrequestIdleCallbackを実行します。
下記が実行結果です。
requestIdleCallbackのタスクがキューイングされますが、
他のタスクが優先的に実行されます。
後述するschedulerのpriority:"background"と使用用途はほぼ同じになるかと思います。
参考:
Prioritized Task Scheduling APIでの検証
scheduler.postTask(priority: 'user-visible')
async function runAsyncFunction() {
// 1000ms後にheavyActionを実行
setTimeout(async () => {
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'start');
heavyTask(1000); // 1秒間のCPU負荷
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'end');
}, 1000);
for (let i = 1; i <= 3; i++) {
scheduler.postTask(async () => {
logMessage(`Scheduler task part ${i}`, 'schedulerAPI', `Task ${i}`, 'start');
heavyTask(1000); // 1秒間CPUを占有
logMessage(`Scheduler task part ${i}`, 'schedulerAPI', `Task ${i}`, 'end');
}, {
priority: 'user-visible'
});
}
}
for文でscheduler.postTask(callback,{priority: 'user-visible'})
を実行します。
下記が実行結果です。
'user-visible'
は、下記の参照の通りに、
Tasks that are visible to the user but not necessarily blocking user actions. This might include rendering non-essential parts of the page, such as non-essential images or animations.
説明の通りユーザーアクションをブロックせずheavyActionより優先度高く実行します。
scheduler.postTask(priority: 'background')
async function runAsyncFunction() {
// 1000ms後にheavyActionを実行
setTimeout(async () => {
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'start');
heavyTask(1000); // 1秒間のCPU負荷
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'end');
}, 1000);
for (let i = 1; i <= 3; i++) {
scheduler.postTask(async () => {
logMessage(`Scheduler task part ${i}`, 'schedulerAPI', `Task ${i}`, 'start');
heavyTask(1000); // 1秒間CPUを占有
logMessage(`Scheduler task part ${i}`, 'schedulerAPI', `Task ${i}`, 'end');
}, {
priority: 'background'
});
}
}
for文でscheduler.postTask(callback,{priority: 'background'})
を実行します。
下記が実行結果です。
'background'
は、下記の参照の通りに、
Tasks that are not time-critical. This might include log processing or initializing third party libraries that aren't required for rendering.
優先度の低いタスクの実行となります。
使用用途としてはrequestIdleCallback
とほぼ同じになるかと思います。
scheduler.yield
async function runAsyncFunction() {
// 1000ms後にheavyActionを実行
setTimeout(async () => {
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'start');
heavyTask(1000); // 1秒間のCPU負荷
logMessage('Heavy Action', 'Heavy Action', 'Heavy Action Task', 'end');
}, 1000);
for (let i = 1; i <= 3; i++) {
logMessage(`Scheduler task part ${i}`, 'schedulerAPI', `Task ${i}`, 'start');
heavyTask(1000); // 1秒間CPUを占有
logMessage(`Scheduler task part ${i}`, 'schedulerAPI', `Task ${i}`, 'end');
await scheduler.yield();
}
}
for文内部で、heavyTaskの後にawait scheduler.yield()
を実行します。
下記が実行結果です。
scheduler.yield
は、sleep関数(await&setTimeout) 差し込みと同じように、優先度を下げるタスクを明確に決める必要がなく、タスクを分割し後続のタスクの優先度を下げます。
この際に、sleep関数と異なる挙動として、sleep関数の場合ユーザーアクション以外のタスクにも優先度で負けてしまいます。(今回の例で言えば、heavyAction)
参考:
また、isInputPending()を利用することで、chrome128以前でも似たような動作(polyfillのような)を再現できます。
参考:
まとめ
今回の検証結果を下記にまとめました。
(優先度に関しては、 Prioritized Task Scheduling API#task_prioritiesを基準に分類します)
実行別 | 優先度 | IIFE or callback引数 or await式 | ブラウザ対応状況 |
---|---|---|---|
async IIFE | user-blocking | IIFE | ○ |
setTimeout | user-visible | callback引数 | ○ |
sleep関数(await&setTimeout) 差し込み | background | await式 | ○ |
queueMicrotask | user-blocking | callback引数 | ○ |
requestIdleCallback | background | callback引数 | △ |
scheduler.postTask(priority: 'user-visible') | user-visible | callback引数 | △ |
scheduler.postTask(priority: 'background') | background | callback引数 | △ |
scheduler.yield | user-visible | await式 | × |
ブラウザ対応状況を無視した場合、下記の使い分けが個人的にベストプラクティスかなと思っています。(production-ready時には、safariは外しにくいですが...)
実行別 | 優先度 | callback引数 or await式 |
---|---|---|
scheduler.postTask(priority: 'user-visible') | user-visible | callback引数 |
scheduler.yield | user-visible | await式 |
scheduler.postTask(priority: 'background') | background | callback引数 |
sleep関数(await&setTimeout) 差し込み | background | await式 |
Discussion