IdleCallbackをリクエスト-空き時間を有効に活用してください

作成者:カランカラン
💡

質問やフィードバックがありましたら、フォームからお願いします

本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください

多くのウェブページには、さまざまな script を実行する必要があります。もちろん、優先順位もあります。たとえば、重要なタスクとしては、UIのレンダリングや関連するインタラクションイベントの登録、APIを呼び出してデータを取得することなどがあり、これらは高優先度のタスクです。一方で、あまり重要でないタスクには、アナリティクスのスクリプト、レイジーローディング、あまり重要でないイベントの初期化などがあります。

どうやってIdleを判断する?

ブラウザがIdle状態にあることをどうやって知るのでしょうか?これは相当複雑な問題です。ブラウザは、HTML、CSS、JavaScriptを解析し、UIをレンダリングし、APIコールを行い、画像を取得して解析し、GPUアクセラレーションを行うなど、一連のタスクをスケジュールしています。Idleの状態を知るには、ブラウザのスケジューリングの仕組みを理解する必要があります。しかし幸運なことに、requestIdleCallbackがこの問題を解決してくれます。

requestIdleCallbackの概要

requestIdleCallbackはフレームの最後に実行されますが、全てのフレームでrequestIdleCallbackが実行される保証はありません。この理由は簡単で、各フレーム終了時に時間が残っているかどうかを保証できないからです。したがって、requestIdleCallbackの実行タイミングを保証することはできません。

よく見ると、requestIdleCallbackはコンテキストスイッチのような感覚があります。フレームとフレームの間にいくつかの作業を完了させ、中断してから再び実行を続けることができます。

requestIdleCallback(fn, {timeout})

const myWork = deadline => {
  console.log("重要でない仕事です。")
  while (deadline.timeRemaining() > 0) {
    // ロギングを送信
    // 非重要なデータを取得
    // 想像上のものを使用
  }
  abortMyJob()
}

第二の引数のoptionsには、timeoutオプションがあります。このtimeout期間にブラウザが呼び出さない場合、ブラウザに現在の作業を中断させて強制的に呼び出すことができます。

これは適切な使い方ではありません。なぜなら、Idleの時間を利用する理由は、そのタスクが重要でないからであり、それのために現在の作業を中断する必要はないからです。ブラウザは私たちにこの柔軟性を提供してくれますが、時には特定の時間内にイベントが発生することを望むこともあります。

cancelIdleCallback(id)

requestIdleCallback()はIDを返します。このIDを使ってcancelIdleCallback(id)を呼び出すことで、必要のないIdleコールバックをキャンセルすることができます。

requestIdleCallbackは中断される?

deadlineパラメータは、このフレーム内でタスクを完了するためにどれだけの時間があるかを示します。渡されたコールバックはこのパラメータを取得できます。公式の見解によれば、この時間を超えてもブラウザはタスクを強制的に中断することはありません。ただし、deadlineの終了までに完了できることを期待しています。これにより、ユーザーに最適な体験を提供することができます。

deadline.timeRemaining()は、現在利用可能な時間がどれだけ残っているかを返します。

requestIdleCallbackでDOM操作を実行するとどうなる?

前述のように、requestIdleCallbackはフレームの最後に実行されるため、ブラウザはすでに再計算、レイアウト、ペイントの作業を完了しています。この時点でDOMを変更すると、ブラウザは再度スタイル、レイアウト、ペイントの再計算を行うスケジュールを強制されることになります。

requestIdleCallback内でrequestIdleCallbackを呼び出すとどうなる?

requestIdleCallback内でrequestIdleCallbackを呼び出すことは合法です。ただし、このコールバックは次のフレームにスケジュールされます。(実際には、必ずしも次のフレームとは限らず、ブラウザのスケジューリングによって異なります)

例を見せてください!

たとえば、いくつかのユーザーがいて、マウスがプロフィール画像の上に移動すると、個人の紹介が表示されるとします。Idleの時間をうまく活用するために、requestIdleCallbackを使って必要なAPIを事前に取得しておき、まだデータを取得していない場合はAPIを呼び出してデータを取得します。

function fetchUser(name) {
  const users = {
    kalan: "食べ物、コーヒー、人生",
    jack: "女性、コーヒー、人生",
  }
  return Promise.resolve(users[name])
}

const userIntro = {}

const queue = [
  { name: "kalan", fetched: false },
  { name: "jack", fetched: false },
]

requestIdleCallback(deadline => {
  while (deadline.timeRemaining() > 0) {
    let q = queue.pop()
    fetchUser(q.name).then(user => {
      if (deadline.timeRemaining() > 0) {
        userIntro[user.name] = user
        q.fetched = true
      }
    })
  }
}, 500)

avatar.addEventListener("mouseover", e => {
  const name = e.target.getAttribute("data-name")
  if (userIntro[name]) {
    // 紹介を表示
  } else {
    fetchUser(name).then(user => showInfo(user))
  }
})

この例では、requestIdleCallbackを利用してユーザーのデータを事前に取得し、ユーザーがプロフィール画像をクリックしたときにすぐに表示できるようにしています。もしまだデータが取得できていなければ、再度fetchUserを呼び出します。

requestIdleCallbackの効果を示すために、この例は少し面倒に見えるかもしれません。fetchが完了したかどうかを判断するためにキューを維持する必要があり、mouseoverがトリガーされた場合には再度userIntroに値があるかどうかを判断する必要があります。開発上は、複数のユーザーのデータを一度に取得する方が簡単かもしれません。しかし、これは完全にニーズや使用シーンに依存します。

アナリティクス

もう一つの一般的なシーンは、ユーザーのボタンのクリックや再生、視聴時間などの追跡です。イベントを収集した後、requestIdleCallbackを使って一度に送信することができます。例えば:

const btns = btns.forEach(btn => // 追跡したいボタン
btn.addEventListener('click', e => {
    // その他のインタラクションを処理...
    //...
    putIntoQueue({
      type: 'click'
      // データを収集
    });
    schedule();
});

function schedule() {
    requestIdleCallback(
      deadline => {
          while (deadline > 0) {
            const event = queues.pop();
            send(event);
          }
      },
      { timeout: 1000 }
   );
}

ここでは、timeoutを加えてブラウザがschedule関数を呼び出すことを保証しています。

Reactではどうでしょう?

他の不必要な作業がメインスレッドをブロックしないように、Reactフックを統合して、不要な作業をすべてuseIdleCallbackに投げ込むことができます。

import { useEffect, useRef } from "react"

function useIdleCallback(callback, timeout) {
  useEffect(() => {
    let id = requestIdleCallback(deadline => {
      callback(deadline)
    }, timeout)
    return () => cancelIdleCallback(id)
  }, [callback, timeout])
}

function UserIntro({ data }) {
  useIdleCallback(() => {
    sendLog(data)
  })
  return // あなたの素晴らしいUI
}

いくつかの問題

上記の例は便宜上のものであり、実際には管理すべき問題が多くあります。たとえば、キューの管理、timeoutの管理、タスクの優先度をラベル付けして実行順序を確保することなどが最適化できるポイントです。

結論

ブラウザのサポートはまだ十分ではありません:

スクリーンショット 2019-03-15 午前10.42.06

ReactのFiberでは、実際に似たようなメカニズムを利用して実現しています。かつて作業を実行していると、コールスタックが深くなりすぎたり、updateQueueが多すぎたりしてメインスレッドが固まってしまい、更新に時間がかかることがありました。Fiberのメカニズムは、作業を小さな単位に分割し、他の高優先度の作業があればそれを先に処理し、その後に他の作業に戻るという仕組みです。

この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨

Buy me a coffee