Skip to content

Latest commit

 

History

History
423 lines (312 loc) · 15.7 KB

2022-10-11:web.dev: Optimize long tasks.md

File metadata and controls

423 lines (312 loc) · 15.7 KB

2022-10-11:web.dev: Optimize long tasks

Jeremy Wagner, Sep 30, 2022 — Updated Oct 3, 2022

如果你讀了關於 web performance 的東西,保持 JavaScript app 快速的建議往往涉及到這些事

  • 不要阻塞 main thread
  • 分解 long tasks

要理解為什麼優化 JavaScript 中的 task 很重要

  • 你需要了解 task 的作用以及 browser 如何處理它們

什麼是 task ?

task 是 browser 所做的任何離散(discrete)的工作

  • task 涉及的工作包括: rendering、 parsing HTML 和 CSS、執行你的 JavaScript code,以及其他你無法直接控制的事情
  • 在所有這些事情中,你寫的 JavaScript 是 task 的主要來源

task 以幾種方式影響性能。例如

  • 當 browser 在啟動過程中下載一個 JavaScript file 時,它會排隊執行 task,以 parse 和 compile JS,從而使其得以執行
  • 在頁面生命週期的後期,當你的 JS 進行工作時,task 會被啟動
    • 比如通過 event handlers 驅動 interactions,JavaScript 驅動的 animations,以及 background activity,如 log
  • 所有這些東西,除了 web workers 和類似的 API 之外,其他都是在 main thread 上進行的

什麼是 main thread ?

main thread 是在 browser 中運行大多數 task 的地方

  • 你寫的幾乎所有的 JavaScript 都是在這個 thread 上完成的

main thread 一次只能處理一個 task

  • 當 task 超過一定的時間點,準確地說,是 50ms,它們被歸類為 long task
  • 如果 User 在 long tasks 行時試圖與頁面互動,或者需要進行重要的 rendering update,那麼 browser 在處理這項 task 時就會出現延遲
  • 這就造成了 interaction 或 rendering latency

(Chrome 的 performance profiler 中的 long tasks 的角落裡有一個紅色的三角形任務的阻塞部分用紅色的對角線圖案填充。)

你需要拆分 task

  • 將 long task,劃分為單獨運行時間較短的小 task

這點很重要,當 task 被分解時,browser 有更多的機會來 response 更優先的工作,如 user interactions

在上圖的頂部

  • 一個 user interaction 的 event handler 必須等待一個 long task 才能運行,這 delays 了 interaction 的發生
  • 在底部,該 event handler 有機會更快運行。因為 event handler 有機會在較小的 task 之間運行
  • 在上面的例子中,User 可能已經注意到 lag;在下面的例子中,interaction 可能感覺是即時的

但問題是

  • 「分解 long tasks」和「不要阻塞 main thread」的建議不夠具體,除非你已經知道如何做這些事情

這就是這篇文章要解釋的內容

Task management 策略

在軟件架構中

  • 一個常見的建議是將工作分解成更小的功能。這帶來了更好的 readability,和 project maintainability 的好處
  • 這也更容易寫測試
function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

上面這例子中,有個 saveSettings() function

  • 其中呼叫了五個 function 來完成工作,如驗證表單、顯示 spinner、發送 data 等等
  • 概念上,這是很好的架構。如果需要 debug 這些 function,你可以單獨找出每個 function 的作用

然而,問題是

  • JavaScript 並沒有把這些 function 作為單獨的 task 來運行,因為它們是在 saveSettings() function 中執行的
  • 這意味著,所有五個 function 都作為一個 task 運行
  • JavaScript 這樣工作是因為它採用 run-to-completion 的任務執行模式
  • 意味著不管它阻塞 main thread 多長時間,每個任 task 一直運行到完成為止

下面是一套策略,可以用來分解 task 並確定其優先次序

Manually defer code 執行

Developer 使用的一種將 task 分解的方法是 setTimeout()

  • 將 function 傳遞給 setTimeout()。這將 callback 的執行推遲到一個單獨的 task 中,即使指定的 timeout 為0
function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

如果你有一系列需要按順序運行的 function,這很好

  • 但你的 code 並不總是這樣組織的
  • 例如,你能有大量的 data 需要在一個循環中處理,如果有數以百萬,這個 task 可能需要很長的時間
function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

例如,在上面這例子用 setTimeout() 是有問題的

  • 它很難實現,而且整個 data set 可能需要很長的時間來處理,即使每個項目都可以很快處理
  • setTimeout() 並不是這邊合適的工具

除了 setTimeout(),還有其他 API 允許將 code 的執行推遲到後續

  • 使用 postMessage() 來加速 timeouts
  • 也可以使用 requestIdleCallback() 來分解工作
    • 但要注意!requestIdleCallback()以盡可能低的優先級來安排 task
    • 而且只在 browser 空閒的時候。當 main thread 擁擠時,用 requestIdleCallback() 調度的 task 可能永遠無法運行

使用 async/await 來創造 yield points

yield to the main thread

  • 這句話什麼意思?
  • 為什麼要這樣做?
  • 什麼時候應該這樣做?

yield to the main thread

  • 你給它一個機會來處理比當前排隊的 tesk 更重要的 tesk
  • 理想情況下,當你有些關鍵的面向 User 的工作需要盡快執行時,你應該讓位於 main thread
  • Yielding to the main thread 可以為關鍵 task 創造機會,使其更快地運行

當 task 被分解時,其他 task 可以通過 browser 的內部優先級方案更好地被優先處理

  • Promise 是一種 yield to the main thread 方法是使用
    • 搭配呼叫 setTimeout()
function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

注意

  • 雖然 example return 了一個在 呼叫setTimeout() 後 resolve 的 Promise,但負責在新 task 中運行其餘 code 的不是Promise,而是setTimeout() 呼叫
  • Promise callback 作為 microtasks 而不是 task 運行,因此不會 yield to the main thread

saveSettings() function 中

  • 如果每個 function 呼叫後 await``yieldToMain() function,你可以在每個 task 後 yield to the main thread
async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread:
    await yieldToMain();
  }
}

你不需要在每個 function 呼叫後都 yeild。例如

  • 如果你運行的兩個 function 會導致 user interface 的關鍵性更新,你可能不想在它們之間 yield
    • 如果可以的話,讓這些工作先運行,然後考慮在那些不那麼關鍵的或 User 看不到的後台工作的 function 之間 yield

其結果是

  • 曾經的單體 task 現在被分解成獨立的 task

saveSettings() function 現在作為獨立的 task 執行其 child function

  • 使用基於 Promise 的方法來 yielding,而不是手動使用 setTimeout() 是更好的做法
  • Yield points 成為聲明性(declarative)的,因此更容易 code、閱讀和理解

只有在必要的時候才 yield

如果有一堆 task

  • 但你只想在 User 試圖 interact 時才 yield,那怎麼辦?
  • 這就是 isInputPending() 所要做的事情
    • isInputPending() 可以在任何時候運行,以確定 User 是否試圖與 page interact
    • isInputPending() will return true. It returns false otherwise

假設你有個 queue of tasks,但你不希望妨礙任何 input

  • 這個 code 同時使用了 isInputPending() 和自定義的 yieldToMain() function
  • 保證了在 User 試圖 interact 時,input 不會被 delay
async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

saveSettings() 運行時

  • 它將循環處理 queue 中的 task
  • 如果 isInputPending() 在 loop 過程中返回 true
  • saveSettings() 將呼叫 yieldToMain() 以便處理 User input
  • 否則,它將連續運行,直到沒有更多的 task 留下

saveSettings() 運行一個 五個task 的 task queue

  • 但 User 在運行第二個 task 時打開 menu,isInputPending() 讓位於 main thread 處理 interaction,並恢復運行其餘的 task
  • isInputPending() 不一定在 User input 後立即 return true
  • 因為 OS 需要時間來告訴 browser 發生了 interaction。這意味著其他 code 可能已經開始執行了
    • (正如上面的截圖中看到的 saveToDatabase()
  • 即使使用 isInputPending(),仍然必須限制每個 function 的工作量

當 browser 不支持 isInputPending() 時,退而求其次使用 time-based 的方式

  • 這方法,可以為不支持 isInputPending() 的 browser 提供後備方案
async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline += 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

目前的 API 中存在的缺點

到目前為止提到的 API 可以幫助分解 task

  • 但它們有個顯著的缺點
  • 當通過 deferring code 在後續 task 中運行而 yield to the main thread 時,該 code 會被添加到 task queue 的最末端

如果你控制了頁面上的所有 code,就有可能創建自己的 scheduler,並能進行優先排序

  • 但 third-party scripts 不會使用你的 scheduler
  • 在這樣的環境中,你並不能真正地對 task 進行優先排序。只能把它分成幾塊,或者明確地 yield to user interactions
  • 幸運的是,目前有個專門的 scheduler API 正在開發中,它可以解決這些問題

一個專門的 scheduler API

scheduler API目前提供了 postTask() function

The postTask() API has three priorities you can use:

  • 'background': 最低優先級的 tasks.
  • 'user-visible': (default) 中等優先級的 task
  • 'user-blocking': critical tasks, high priority

以下面的 code 為例,postTask() API 被用來以最高優先級運行三個 task ,其餘兩個任務以最低優先級

function saveSettings () {
  // Validate the form at high priority
  scheduler.postTask(validateForm, {priority: 'user-blocking'});

  // Show the spinner at high priority:
  scheduler.postTask(showSpinner, {priority: 'user-blocking'});

  // Update the database in the background:
  scheduler.postTask(saveToDatabase, {priority: 'background'});

  // Update the user interface at high priority:
  scheduler.postTask(updateUI, {priority: 'user-blocking'});

  // Send analytics data in the background:
  scheduler.postTask(sendAnalytics, {priority: 'background'});
};

saveSettings() 運行時

  • function 使用 postTask() 安排各個功能
  • User 的關鍵工作被安排在 high priority,而 User 不知道的工作被安排在後台運行
  • 這使 user interaction 的執行速度更快,因為工作被分割開來,並被適當地優先處理
  • 這是個如何使用 postTask() 的簡單例子。可以實例化不同的 TaskController 對象,在 task 之間共享優先級

內建 yield 與 continuation

scheduler API 中有一個 proposed 的部分,目前沒有在任何 browser 中實現

  • 就是一個 built-in yielding 的機制。它的使用類似於前面的 yieldToMain() function
async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread with the scheduler
    // API's own yielding mechanism:
    await scheduler.yield();
  }
}

上面的 code 基本上跟之前的範例很像

  • 但沒有使用 yieldToMain(),而是呼叫和 await scheduler.yield()

(沒有 yielding、有 yielding、有 yielding 和 continuation)

當使用 scheduler.yield()

  • task 的執行會從它離開的地方繼續進行,甚至在 yield point 之後
  • scheduler.yield()的好處是 continuation,意味著如果在一組 task 的中間 yield,其他 task 將在 yield point 之後以相同的順序繼續進行
    • 這就避免了 third-party scripts 篡改你的執行順序

結論

管理 task 是具有挑戰性的

  • 但這樣做可以更快 response user interaction
  • 對於管理和優先處理任務,沒有一個唯一的建議

這些是在管理 task 時要考慮的主要事項

  • 對於關鍵的、面向 User 的任務 Yield to the main thread
  • 使用 isInputPending() 在 User 試圖 interact 時,將其切換到 main thread
  • 使用 postTask() 對任務進行優先排序
  • 最後,在 function 中盡可能地少做工作