🚶‍♀️

Webパフォーマンス向上のためのJS優先度付け実行メソッド比較: 使い分けガイド

2024/09/23に公開

JSの実行について

ユーザーのメインスレッドを闇雲に奪わないことは、重要です。
(今年からCWVにINPが追加され、SEO的に従来のFIDよりも厳しい基準で評価されているかと思います)

その際に特定の処理によって、メインスレッドを阻害しないことが大事ですが、
JavaScriptには、Promiseをはじめ、requestIdleCallbackqueueMicrotaskPrioritized Task Scheduling APIなど、優先度を変更を実現する方法がいくつもあります。選択肢が多いため、どれを選ぶべきか迷うことがあります。この記事では、メインスレッド上での優先度付け実行について、それぞれの手法を整理し、検証していきます。

WebWorker などの並行処理は今回は対象外とします。

検証環境

検証用のリポジトリはこちらです:
asyncjs-validater (GitHub)

以下の流れで検証を行います:

  1. 対象関数の実行
  2. 1000ms後に同期実行
  3. ユーザーアクションによる同期実行

具体的なコード例は下記を参照

コード例
    // 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);

実行例
実行例

今回の検証では、

  1. async functionでの検証
  2. settimeoutでの検証
  3. queuemicrotaskでの検証
  4. requestIdleCallbackでの検証
  5. Prioritized Task Scheduling APIでの検証

の5つを行います

検証結果は、まとめ

また、Chrome 129 での実行なため、他ブラウザでは異なる挙動になる場合があります

async functionでの検証(IIFE)

https://imamiya-masaki.github.io/asyncjs-validater/simple-async.html

simple-async.html
    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の即時実行関数を実行するようにします。
下記が実行結果です
async実行順序

heavyActionも、ユーザーアクションもおこなってますが、
async functionの実行の方が優先されているのがわかります。

asyncはawaitを利用しない限り、
同期的に処理されてしまうためただasyncで実行するだけでは、setTimeoutのように遅延実行にはなりません。
またawait 1などでawaitを差し込んだとしても、await以降の処理がマイクロタスクキューに追加されるだけですので、ユーザーアクションの割り込みを許可することはできません。

setTimeoutでの検証

async実行(setTimeout)

https://imamiya-masaki.github.io/asyncjs-validater/simple-async-withtimeout.html

simple-async-timeout.html
    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設定されているヘビーアクションは一番最後に実行されます

参考:
https://zenn.dev/estra/books/js-async-promise-chain-event-loop/viewer/d-epasync-task-microtask-queues#settimeout-api
https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-settimeout-dev

sleep関数(await&setTimeout) 差し込み

https://imamiya-masaki.github.io/asyncjs-validater/sleeponly.html

sleeponly.html
    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も実行されるようになっています。

こちらは、下記を参照していただくのがわかりやすいです。
https://zenn.dev/estra/books/js-async-promise-chain-event-loop/viewer/14-epasync-chain-to-async-await#async-関数
https://developer.mozilla.org/ja/docs/Web/API/setTimeout#タイムアウトの遅延

こちらで簡易的に説明しますと、
本来、同期関数だけですと、heavyActionのタスクのキューイング + ユーザーアクションのタスクのキューイング+ for文内部の実行ですが、
sleep関数によりタスクのキューイングとタスクの実行が行われるため、次のタスクキューを処理できる余裕ができ、await sleep(0)のタイミングでタスクキューで実行可能なタスクを取り出すことができるようになるためだと解釈しています

そのため、setTimeoutに意味があるわけではないため、たとえば後述するscheduler.postTask()を利用して下記のようにもできます。

scheduler.postTask(resolve,{prioirty: 'background'})
    async function sleep(ms) {
      return new Promise(resolve => scheduler.postTask(resolve, {
        priority: 'background'
      }));
    }

実行結果
scheduler.postTask(resolve,{prioirty: 'background'})

scheduler.postTask(resolve,{prioirty: 'user-blocking'})
    async function sleep(ms) {
      return new Promise(resolve => scheduler.postTask(resolve, {
        priority: 'user-blocking'
      }));
    }

実行結果
scheduler.postTask(resolve,{prioirty: 'user-blocking'})

queueMicrotaskでの検証

https://imamiya-masaki.github.io/asyncjs-validater/queuemicrotask.html

queuemicrotask.html
    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改善のために利用するのは適しておらず、
実行タスクの後にマイクロタスクとして優先度高く実行されるため、実行順序を保証したい時に利用できます。

参考:
https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#microtask-queuing
https://developer.mozilla.org/ja/docs/Web/API/HTML_DOM_API/Microtask_guide
https://zenn.dev/estra/books/js-async-promise-chain-event-loop/viewer/d-epasync-task-microtask-queues#マイクロタスクキュー

requestIdleCallbackでの検証

https://developer.mozilla.org/ja/docs/Web/API/Window/requestIdleCallback

https://imamiya-masaki.github.io/asyncjs-validater/requestidlecallback.html

requestidlecallback.html
    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"と使用用途はほぼ同じになるかと思います。

参考:
https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API

Prioritized Task Scheduling APIでの検証

https://developer.mozilla.org/ja/docs/Web/API/WorkerGlobalScope/scheduler

scheduler.postTask(priority: 'user-visible')

https://imamiya-masaki.github.io/asyncjs-validater/scheduler-user-visible.html

scheduler-user-visible.html
    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'は、下記の参照の通りに、
https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/postTask

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')

https://imamiya-masaki.github.io/asyncjs-validater/scheduler-background.html

scheduler-background.html
    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'は、下記の参照の通りに、
https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/postTask

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

https://imamiya-masaki.github.io/asyncjs-validater/scheduler-yield.html

scheduler-yield.html
    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)
参考:
https://developer.chrome.com/blog/introducing-scheduler-yield-origin-trial?hl=ja

また、isInputPending()を利用することで、chrome128以前でも似たような動作(polyfillのような)を再現できます。
参考:
https://developer.mozilla.org/en-US/docs/Web/API/Scheduling/isInputPending#examples

まとめ

今回の検証結果を下記にまとめました。

(優先度に関しては、 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