Background Tasks API

可用性有限

此特性不是基線特性,因為它在一些最廣泛使用的瀏覽器中不起作用。

後臺任務協作排程 API(也稱為後臺任務 API 或 requestIdleCallback() API)提供了一種將任務排隊的能力,這些任務將在使用者代理確定有空閒時間時自動執行。

注意:此 API 在Web Workers不可用

概念與用法

Web 瀏覽器的主執行緒以其事件迴圈為中心。這段程式碼繪製任何待處理的對當前顯示的Document的更新,執行頁面需要執行的任何 JavaScript 程式碼,接受來自輸入裝置的事件,並將這些事件分派給應該接收它們的元素。此外,事件迴圈處理與作業系統的互動、瀏覽器自身使用者介面的更新等等。它是一段極其繁忙的程式碼,您的主要 JavaScript 程式碼可能與所有這些程式碼一起在這個執行緒中執行。當然,大多數(如果不是全部)能夠更改 DOM 的程式碼都在主執行緒中執行,因為使用者介面更改通常只對主執行緒可用。

由於事件處理和螢幕更新是使用者注意到效能問題的兩種最明顯方式,因此您的程式碼作為 Web 的良好公民,幫助防止事件迴圈執行停滯非常重要。過去,除了編寫儘可能高效的程式碼並將儘可能多的工作解除安裝到workers之外,沒有可靠的方法可以做到這一點。Window.requestIdleCallback() 使瀏覽器能夠告訴您的程式碼它可以安全使用多少時間而不會導致系統滯後,從而積極地幫助確保瀏覽器事件迴圈平穩執行。如果您保持在給定限制內,您可以大大改善使用者體驗。

充分利用空閒回撥

由於空閒回撥旨在為您的程式碼提供一種與事件迴圈協作的方式,以確保系統得到充分利用而不會超負荷,從而導致滯後或其他效能問題,因此您應該仔細考慮如何使用它們。

  • 將空閒回撥用於優先順序不高的任務。因為您不知道有多少回撥已經建立,也不知道使用者系統有多忙,所以您不知道您的回撥會多久執行一次(除非您指定了timeout)。不能保證事件迴圈的每次透過(甚至每次螢幕更新週期)都會包含任何空閒回撥的執行;如果事件迴圈使用了所有可用時間,那您就倒黴了(同樣,除非您使用了timeout)。
  • 空閒回撥應盡力不要超過分配的時間。雖然如果您超過指定時間限制(即使您超過很多),瀏覽器、您的程式碼和整個 Web 仍將正常執行,但時間限制旨在確保您給系統足夠的時間來完成事件迴圈的當前透過並進入下一個,而不會導致其他程式碼卡頓或動畫效果滯後。目前,timeRemaining() 的上限為 50 毫秒,但實際上您通常會比這更少,因為在複雜的網站上,事件迴圈可能已經在佔用這些時間,瀏覽器擴充套件程式也需要處理器時間等等。
  • 避免在空閒回撥中更改 DOM。當您的回撥執行時,當前幀已經完成繪製,並且所有佈局更新和計算都已完成。如果您進行影響佈局的更改,您可能會強制瀏覽器停止並進行本不必要的重新計算。如果您的回撥需要更改 DOM,它應該使用Window.requestAnimationFrame()來排程。
  • 避免執行時不可預測的任務。您的空閒回撥應避免執行任何可能花費不可預測時間的事情。例如,應避免任何可能影響佈局的事情。您還應避免解決或拒絕Promise,因為一旦您的回撥返回,這將呼叫該 Promise 解決或拒絕的處理程式。
  • 在需要時使用超時,但僅在需要時使用。使用超時可以確保您的程式碼及時執行,但它也可能透過強制瀏覽器在沒有足夠時間執行而不會中斷效能時呼叫您,從而導致滯後或動畫卡頓。

介面

後臺任務 API 只添加了一個新介面

IdleDeadline

此型別的物件將傳遞給空閒回撥,以提供空閒期預計持續多長時間的估計,以及回撥是否因為其超時期限已過而執行。

Window 介面也透過此 API 進行了增強,提供了新的requestIdleCallback()cancelIdleCallback() 方法。

示例

在此示例中,我們將探討如何使用requestIdleCallback()在瀏覽器空閒時執行耗時、低優先順序的任務。此外,此示例還演示瞭如何使用requestAnimationFrame()排程文件內容的更新。

下面您將只找到此示例的 HTML 和 JavaScript。CSS 未顯示,因為它對理解此功能並不特別重要。

HTML

為了瞭解我們正在嘗試完成什麼,讓我們看一下 HTML。這建立了一個框(id="container"),用於顯示操作的進度(畢竟,您永遠不知道解碼“量子燈絲超光速粒子發射”需要多長時間),以及第二個主框(id="logBox"),用於顯示文字輸出。

html
<p>
  Demonstration of using cooperatively scheduled background tasks using the
  <code>requestIdleCallback()</code> method.
</p>

<div id="container">
  <div class="label">Decoding quantum filament tachyon emissions…</div>

  <progress id="progress" value="0"></progress>

  <button class="button" id="startButton">Start</button>

  <div class="label counter">
    Task <span id="currentTaskNumber">0</span> of
    <span id="totalTaskCount">0</span>
  </div>
</div>

<div id="logBox">
  <div class="logHeader">Log</div>
  <div id="log"></div>
</div>

進度框使用<progress>元素顯示進度,以及一個帶有部分的標籤,這些部分被更改以顯示有關進度的數字資訊。此外,還有一個“開始”按鈕(巧妙地命名為“startButton”),使用者將使用它來開始資料處理。

JavaScript

文件結構定義好後,構建將完成工作的 JavaScript 程式碼。目標:能夠將呼叫函式的請求新增到佇列中,並使用空閒回撥,只要系統有足夠長的空閒時間來取得進展,就執行這些函式。

變數宣告

js
const taskList = [];
let totalTaskCount = 0;
let currentTaskNumber = 0;
let taskHandle = null;

這些變數用於管理等待執行的任務列表,以及有關任務佇列及其執行的狀態資訊

  • taskList 是一個Array物件陣列,每個物件代表一個等待執行的任務。
  • totalTaskCount 是已新增到佇列中的任務數量計數器;它只會增加,不會減少。我們使用它來計算以百分比形式顯示總工作進度的數學。
  • currentTaskNumber 用於跟蹤到目前為止已處理的任務數量。
  • taskHandle 是當前正在處理的任務的引用。
js
const totalTaskCountElem = document.getElementById("totalTaskCount");
const currentTaskNumberElem = document.getElementById("currentTaskNumber");
const progressBarElem = document.getElementById("progress");
const startButtonElem = document.getElementById("startButton");
const logElem = document.getElementById("log");

接下來我們有引用我們需要互動的 DOM 元素的變數。這些元素是

  • totalTaskCountElem 是我們用來在進度框的狀態顯示中插入已建立任務總數的<span>
  • currentTaskNumberElem 是用於顯示到目前為止已處理的任務數量的元素。
  • progressBarElem 是顯示到目前為止已處理任務百分比的<progress>元素。
  • startButtonElem 是開始按鈕。
  • logElem 是我們將插入日誌文字訊息的<div>
js
let logFragment = null;
let statusRefreshScheduled = false;

最後,我們為其他專案設定了幾個變數

  • logFragment 將用於儲存一個DocumentFragment,它由我們的日誌記錄函式生成,用於在渲染下一個動畫幀時建立要附加到日誌的內容。
  • statusRefreshScheduled 用於跟蹤我們是否已為即將到來的幀排程了狀態顯示框的更新,以便我們每幀只執行一次

管理任務佇列

接下來,讓我們看看我們管理需要執行的任務的方式。我們將透過建立一個任務的 FIFO 佇列來做到這一點,我們將在空閒回撥期間在時間允許的情況下執行這些任務。

任務入隊

首先,我們需要一個用於將任務排隊以供將來執行的函式。該函式 enqueueTask() 如下所示

js
function enqueueTask(taskHandler, taskData) {
  taskList.push({
    handler: taskHandler,
    data: taskData,
  });

  totalTaskCount++;

  taskHandle ||= requestIdleCallback(runTaskQueue, { timeout: 1000 });

  scheduleStatusRefresh();
}

enqueueTask() 接受兩個引數作為輸入

  • taskHandler 是一個將用於處理任務的函式。
  • taskData 是一個物件,它作為輸入引數傳遞給任務處理程式,以允許任務接收自定義資料。

要將任務入隊,我們一個物件推入taskList陣列中;該物件包含taskHandlertaskData值,分別命名為handlerdata,然後增加totalTaskCount,這反映了已入隊任務的總數(當任務從佇列中移除時我們不會減少它)。

接下來,我們檢查是否已經建立了空閒回撥;如果taskHandle為 0,我們知道還沒有空閒回撥,所以我們呼叫requestIdleCallback()來建立一個。它配置為呼叫一個名為runTaskQueue()的函式,我們稍後會檢視,並設定 1 秒的timeout,這樣即使沒有實際的空閒時間可用,它也會至少每秒執行一次。

執行任務

當瀏覽器確定有足夠的空閒時間讓我們做一些工作或我們的一秒超時到期時,我們的空閒回撥處理程式runTaskQueue()會被呼叫。此函式的工作是執行我們排隊的任務。

js
function runTaskQueue(deadline) {
  while (
    (deadline.timeRemaining() > 0 || deadline.didTimeout) &&
    taskList.length
  ) {
    const task = taskList.shift();
    currentTaskNumber++;

    task.handler(task.data);
    scheduleStatusRefresh();
  }

  if (taskList.length) {
    taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000 });
  } else {
    taskHandle = 0;
  }
}

runTaskQueue() 的核心是一個迴圈,只要有剩餘時間(透過檢查deadline.timeRemaining來確定它是否大於 0)或達到超時限制(deadline.didTimeout 為 true),並且任務列表中有任務,迴圈就會繼續。

對於佇列中我們有時間執行的每個任務,我們執行以下操作

  1. 我們從佇列中移除任務物件
  2. 我們增加currentTaskNumber以跟蹤我們已執行的任務數量。
  3. 我們呼叫任務的處理程式task.handler,將任務的資料物件(task.data)作為輸入引數傳遞給它。
  4. 我們呼叫一個函式scheduleStatusRefresh(),以處理排程螢幕更新以反映我們進度的變化。

當時間用完時,如果列表中仍有任務,我們會再次呼叫requestIdleCallback(),以便我們下次有空閒時間時可以繼續處理任務。如果佇列為空,我們將 taskHandle 設定為 0,表示我們沒有排程回撥。這樣,我們下次呼叫enqueueTask()時就會知道請求回撥。

更新狀態顯示

我們希望能夠做的一件事是使用日誌輸出和進度資訊更新我們的文件。但是,您不能在空閒回撥中安全地更改 DOM。相反,我們將使用requestAnimationFrame()請求瀏覽器在可以安全更新顯示時呼叫我們。

排程顯示更新

DOM 更改透過呼叫 scheduleStatusRefresh() 函式進行排程。

js
function scheduleStatusRefresh() {
  if (!statusRefreshScheduled) {
    requestAnimationFrame(updateDisplay);
    statusRefreshScheduled = true;
  }
}

這是一個簡單的函式。它透過檢查 statusRefreshScheduled 的值來檢視我們是否已經排程了顯示重新整理。如果它是 false,我們呼叫 requestAnimationFrame() 來排程重新整理,提供 updateDisplay() 函式來處理該工作。

更新顯示

updateDisplay() 函式負責繪製進度框和日誌的內容。當 DOM 處於安全狀態,我們可以在渲染下一幀的過程中應用更改時,瀏覽器會呼叫它。

js
function updateDisplay() {
  const scrolledToEnd =
    logElem.scrollHeight - logElem.clientHeight <= logElem.scrollTop + 1;

  if (totalTaskCount) {
    if (progressBarElem.max !== totalTaskCount) {
      totalTaskCountElem.textContent = totalTaskCount;
      progressBarElem.max = totalTaskCount;
    }

    if (progressBarElem.value !== currentTaskNumber) {
      currentTaskNumberElem.textContent = currentTaskNumber;
      progressBarElem.value = currentTaskNumber;
    }
  }

  if (logFragment) {
    logElem.appendChild(logFragment);
    logFragment = null;
  }

  if (scrolledToEnd) {
    logElem.scrollTop = logElem.scrollHeight - logElem.clientHeight;
  }

  statusRefreshScheduled = false;
}

首先,如果日誌中的文字滾動到底部,則將 scrolledToEnd 設定為 true;否則將其設定為 false。我們將使用它來確定我們是否應該更新滾動位置,以確保在新增內容後日志保持在末尾。

接下來,如果有任何任務已排隊,我們會更新進度和狀態資訊。

  1. 如果進度條的當前最大值與當前排隊的任務總數(totalTaskCount)不同,則我們更新顯示的已排隊任務總數(totalTaskCountElem)和進度條的最大值,以便它正確縮放。
  2. 我們對到目前為止已處理的任務數量也做同樣的事情;如果progressBarElem.value與當前正在處理的任務編號(currentTaskNumber)不同,那麼我們更新當前正在處理的任務的顯示值和進度條的當前值。

然後,如果有文字等待新增到日誌中(即,如果logFragment不是null),我們使用Element.appendChild()將其附加到日誌元素,並將logFragment設定為null,以便我們不再新增它。

如果日誌在我們開始時已滾動到底部,我們會確保它仍然如此。然後我們將statusRefreshScheduled設定為false,以表示我們已處理重新整理並且可以安全地請求新的重新整理。

向日志新增文字

log() 函式將指定的文字新增到日誌中。由於我們不知道在呼叫log()時是否可以安全地立即操作 DOM,因此我們將快取日誌文字直到可以安全更新。上面,在updateDisplay()的程式碼中,您可以找到在動畫幀更新時實際將日誌文字新增到日誌元素的程式碼。

js
function log(text) {
  logFragment ??= document.createDocumentFragment();
  const el = document.createElement("div");
  el.textContent = text;
  logFragment.appendChild(el);
}

首先,如果logFragment當前不存在,我們建立一個名為logFragmentDocumentFragment物件。此元素是一個偽 DOM,我們可以在其中插入元素而不會立即更改主 DOM 本身。

然後,我們建立一個新的<div>元素,並將其內容設定為與輸入text匹配。然後,我們將新元素追加到logFragment中的偽 DOM 的末尾。logFragment將累積日誌條目,直到下次呼叫updateDisplay(),一旦 DOM 準備好進行更改。

執行任務

現在我們已經完成了任務管理和顯示維護程式碼,我們就可以開始設定程式碼來執行完成工作的任務了。

任務處理程式

我們將用作任務處理程式的函式——即用作任務物件的handler屬性值的函式——是logTaskHandler()。它是一個簡單的函式,為每個任務向日志輸出大量內容。在您自己的應用程式中,您會將此程式碼替換為您希望在空閒時間執行的任何任務。請記住,任何您希望更改 DOM 的操作都需要透過requestAnimationFrame()來處理。

js
function logTaskHandler(data) {
  log(`Running task #${currentTaskNumber}`);

  for (let i = 0; i < data.count; i += 1) {
    log(`${(i + 1).toString()}. ${data.text}`);
  }
}

主程式

當用戶點選“開始”按鈕時,所有內容都會觸發,這會導致呼叫decodeTechnoStuff()函式。

js
function decodeTechnoStuff() {
  totalTaskCount = 0;
  currentTaskNumber = 0;
  updateDisplay();

  const n = getRandomIntInclusive(100, 200);

  for (let i = 0; i < n; i++) {
    const taskData = {
      count: getRandomIntInclusive(75, 150),
      text: `This text is from task number ${i + 1} of ${n}`,
    };

    enqueueTask(logTaskHandler, taskData);
  }
}

document
  .getElementById("startButton")
  .addEventListener("click", decodeTechnoStuff);

decodeTechnoStuff()首先將 totalTaskCount(到目前為止新增到佇列中的任務數量)和 currentTaskNumber(當前正在執行的任務)的值歸零,然後呼叫updateDisplay()將顯示重置為“尚未發生任何事情”狀態。

此示例將建立隨機數量的任務(介於 100 到 200 個之間)。為此,我們使用getRandomIntInclusive()函式,它在Math.random()的文件中作為示例提供,以獲取要建立的任務數量。

然後我們開始一個迴圈來建立實際的任務。對於每個任務,我們建立一個物件,taskData,其中包含兩個屬性

  • count 是要從任務中輸出到日誌的字串數量。
  • text 是要輸出到日誌的文字,輸出次數由count指定。

然後,透過呼叫enqueueTask()將每個任務排隊,將logTaskHandler()作為處理函式傳遞,並將taskData物件作為在呼叫函式時傳遞給函式的物件。

結果

下面是上面程式碼的實際執行結果。嘗試一下,在瀏覽器的開發者工具中玩一下,並在您自己的程式碼中嘗試使用它。

規範

規範
requestIdleCallback()
# requestIdleCallback 方法

瀏覽器相容性

另見