瀏覽器也擁有了原生的 “時間切片” 能力!

大家好,我是 ConardLi

就在 Chrome 115 版本,瀏覽器開始了對 scheduler.yield 的灰度測試。scheduler.yieldscheduler API 中新增的一個功能,它能以更簡單、更好的方式將控制權交還給主線程。在開始講解這個 API 之前,我們先來看一個新的性能指標。

下次繪製交互 (INP)

下次繪製交互 (INP) 是一項新的指標,瀏覽器計劃於 2024 年 3 月將其取代取代首次輸入延遲 (FID) ,成爲最新的 Web Core Vitals(Web 核心性能指標,可以看我這篇文章:解讀新一代 Web 性能體驗和質量指標)。

Chrome 使用數據顯示,用戶在頁面上花費的時間有 90% 是在網頁加載完成後花費的,因此,仔細測量整個頁面生命週期的響應能力是非常重要的,這就是 INP 指標評估的內容。

良好的響應能力意味着頁面可以快速響應並且與用戶進行的交互。當頁面響應交互時,最直接的結果就是視覺反饋,由瀏覽器在瀏覽器渲染的下一幀中體現。例如,視覺反饋會告訴我們是否確實添加了購物車的商品、是否快讀打開了導航菜單、服務器是否正在對登錄表單的內容進行身份驗證等等。INP 的目標就是確保對於用戶進行的所有或大多數交互,從用戶發起交互到繪製下一幀的時間儘可能短。

INP 是一種指標,通過觀察用戶訪問頁面的整個生命週期中發生的所有單擊、敲擊和鍵盤交互的延遲來評估頁面對用戶交互的整體響應能力。

交互是在同一邏輯用戶手勢期間觸發的一組事件處理程序。例如,觸摸屏設備上的 “點擊” 交互包括多個事件,例如 pointerup、pointerdownclick。交互可以由 JavaScript、CSS、內置瀏覽器控件或其組合驅動。

交互的延遲就是由驅動交互的這一組事件處理程序的單個最長持續時間組成的,從用戶開始交互到渲染下一幀視覺反饋的時間。

INP 考慮的是所有頁面的交互,而首次輸入延遲 (FID) 只會考慮第一次交互。而且它只測量了第一次交互的輸入延遲,而不是運行事件處理程序所需的時間或下一幀渲染的延遲。

瀏覽器希望使用 INP 替代 FID 就意味着用戶的交互體驗越來越重要了,我們常常聽到的時間切片的概念,實際上就是爲了提升網頁的交互響應能力。

時間切片

JavaScript 使用 run-to-completion 模型來處理任務。這意味着,當任務在主線程上運行時,該任務將運行必要的時間才能完成。任務完成後,控制權交會還給主線程,這樣主線程就可以處理隊列中的下一個任務。

除了任務永遠不會完成的極端情況(例如無限循環)之外,屈服是 JavaScript 任務調度邏輯不可避免的一個方面。屈服遲早會發生,只是時間問題,而且越早越好。當任務運行時間過長(準確地說超過 50 毫秒)時,它們會被視爲長任務。

長任務是頁面響應能力差的一個根源,因爲它們延遲了瀏覽器響應用戶輸入的能力。長任務發生的次數越多,而且運行的時間越長,用戶就越有可能感覺到頁面運行緩慢,甚至感覺頁面完全掛掉了。

不過,代碼在瀏覽器中啓動任務並不意味着必須等到任務完成後才能將控制權交還給主線程。你可以通過在任務中明確交出控制權來提高對頁面上用戶輸入的響應速度,這樣就能在下一個合適的時間來完成任務。這樣,其他任務就能更快地在主線程上獲得時間,而不必等待長任務的完成。

這張圖可以很直觀的顯示:在上面的執行中,只有在任務運行完成後纔會交還控制權,這意味着任務可能需要更長時間才能完成,然後纔會將控制權交還給主線程。在下面,控制權交還是主動進行的,將一個較長的任務分解成多個較小的任務。這樣,用戶交互可以更快地運行,從而提高輸入響應速度和 INP

當我們想要明確屈服時,就是在告訴瀏覽器 “嘿,我知道我要做的工作可能需要一段時間,並且我不希望你在響應用戶輸入之前必須完成所有這些工作或其他可能也很重要的任務”。

聽起來這個是不是很熟悉?這其實就是我們常說的 “時間切片” 的概念,之前你聽到可能還是在 React 的理念裏,因爲它是最早提出這個能力的前端框架。我們再來回顧下面這個典型的例子:

舊版 React 架構是遞歸同步更新的,如果節點非常多,即使只有一次 state 變更,React 也需要進行復雜的遞歸更新,更新一旦開始,中途就無法中斷,直到遍歷完整顆樹,才能釋放主線程。

當渲染的層級很深時,遞歸更新時間超過了 16ms,如果這時有用戶操作或動畫渲染等,就會表現爲卡頓:

後來,React 實現了自己的 Scheduler ,它可以將一次耗時很長的更新任務被拆分成一小段一小段的。這樣瀏覽器就有剩餘時間執行樣式佈局和樣式繪製,減少掉幀的可能性。

每個小的任務完成後,控制權就會交還給主線程,瀏覽器就有了時間去及時的完成用戶的交互或頁面的繪製,所以頁面會很絲滑:

這個思路太棒了,在原生的 JavaScript 代碼,或者其他框架中我們也想要這樣的能力怎麼辦?

使用 setTimeout

一種常見的過渡方法是使用時間爲 0 的 setTimeout。 這種方法之所以有效,是因爲傳遞給 setTimeout 的回調會將剩餘工作轉移到一個單獨的任務中,這個任務將排隊等待後續執行,這樣也可以實現把一大塊工作分成更小的部分。

但是,使用 setTimeout 進行屈服可能會帶來不良的副作用:屈服之後的工作將進入任務隊列的最尾部。通過用戶交互安排的任務仍會排在任務隊列的前面,但你想做的剩餘工作可能會被排在它前面的其他任務進一步延遲。

我們可以看一個下面的示例:

function blockingTask (ms = 200) {
  let arr = [];
  const blockingStart = performance.now();

  console.log(`Synthetic task running for ${ms} ms`);

  while (performance.now() < (blockingStart + ms)) {
    arr.push(Math.random() * performance.now / blockingStart / ms);
  }
}

function yieldToMain () {
  return new Promise(resolve ={
    setTimeout(resolve, 0);
  });
}

async function runTaskQueueSetTimeout () {
  if (typeof intervalId === "undefined") {
    alert("Click the button to run blocking tasks periodically first.");
    
    return;
  }
  
  clearTaskLog();

  for (const item of [1, 2, 3, 4, 5]) {
    blockingTask();
    logTask(`Processing loop item ${item}`);
    
    await yieldToMain();
  }
}

document.getElementById("setinterval").addEventListener("click"({ target }) ={
  clearTaskLog();

  intervalId = setInterval(() ={
    if (taskOutputLines < MAX_TASK_OUTPUT_LINES) {
      blockingTask();
    
      logTask("Ran blocking task via setInterval");
    }
  });
  
  target.setAttribute("disabled"true);
}{
  once: true
});

document.getElementById("settimeout").addEventListener("click"() ={
  runTaskQueueSetTimeout();
});

我們先通過 setinterval 來定期執行一些任務,下面我們來使用 setTimeout 來模擬時間切片,將長任務進行拆解,我們會得到下面這樣的打印結果:

Processing loop item 1
Processing loop item 2
Ran blocking task via setInterval
Processing loop item 3
Ran blocking task via setInterval
Processing loop item 4
Ran blocking task via setInterval
Processing loop item 5
Ran blocking task via setInterval
Ran blocking task via setInterval

很多腳本(尤其是第三方腳本)經常會註冊一個定時器函數,在某個時間間隔內運行工作。使用 setTimeout 來拆解長任務意味着,來自其他任務源的工作可能會排在退出事件循環後必須完成的剩餘工作之前。

這也許能夠起到一定的作用,但在許多情況下,這種行爲是開發者不願輕易放棄主線程控制權的原因。能主動交出控制權是好事,因爲用戶交互有機會更快地運行,但它也會讓其他非用戶交互的工作在主線程上獲得時間。這確實是個問題,scheduler.yield 可以幫助解決這個問題!

scheduler.yield

我們需要注意一下,交出主線程控制權並不是 setTimeout 的設計目標,它的核心目標是能在未來某個時間完成某個任務,所以它會把任務中的工作排在隊列的最後面。

但是,與之相反,默認情況下,scheduler.yield 會將剩餘的工作發送到隊列的前面。這意味着你想要在 yield 後立即恢復的工作不會讓位於其他來源的任務(用戶交互除外)。

scheduler.yield 是一個向主線程主動屈服並在調用時返回 Promise 的函數。這意味着你可以在異步函數中等待它:

async function yieldy () {
  // Do some work...
  // ...

  // Yield!
  await scheduler.yield();

  // Do some more work...
  // ...
}

還是使用前面的例子,這次我們使用 scheduler.yield 進行等待:

async function runTaskQueueSchedulerDotYield () {
  if (typeof intervalId === "undefined") {
    alert("Click the button to run blocking tasks periodically first.");
    
    return;
  }

  if ("scheduler" in window && "yield" in scheduler) {
    clearTaskLog();

    for (const item of [1, 2, 3, 4, 5]) {
      blockingTask();
      logTask(`Processing loop item ${item}`);

      await scheduler.yield();
    }
  } else {
    alert("scheduler.yield isn't available in this browser :(");
  }
}

我們會發現打印的結果是這樣的:

Processing loop item 1
Processing loop item 2
Processing loop item 3
Processing loop item 4
Processing loop item 5
Ran blocking task via setInterval
Ran blocking task via setInterval
Ran blocking task via setInterval
Ran blocking task via setInterval
Ran blocking task via setInterval

這樣就可以達到兩全其美的效果:既能將長任務進行分割,主動給主線程讓出控制權來提高網站的交互響應速度,又能確保讓出主線程後要完成的工作不會被延遲。

試用

如果大家對 Scheduler.yield 感興趣並且想嘗試一下,從 Chrome 115版本開始可以:

打開 chrome://flags ,然後選擇啓用 Experimental Web Platform Features ,這樣就可以使用 Scheduler.yield 了。也可以嘗試使用官方提供的 Polifill :https://github.com/GoogleChromeLabs/scheduler-polyfill

如果在業務代碼裏使用,爲了兼容不支持的低版本瀏覽器,可以在不支持時回退到 setTimeout 寫法:

// A function for shimming scheduler.yield and setTimeout:
function yieldToMain () {
  // Use scheduler.yield if it exists:
  if ('scheduler' in window && 'yield' in scheduler) {
    return scheduler.yield();
  }

  // Fall back to setTimeout:
  return new Promise(resolve ={
    setTimeout(resolve, 0);
  });
}

// Example usage:
async function doWork () {
  // Do some work:
  // ...

  await yieldToMain();

  // Do some other work:
  // ...
}

當然,如果你不想讓你的任務被其他任務延遲掉,也可以在不支持這個 API 時選擇不屈服:

// A function for shimming scheduler.yield with no fallback:
function yieldToMain () {
  // Use scheduler.yield if it exists:
  if ('scheduler' in window && 'yield' in scheduler) {
    return scheduler.yield();
  }

  // Fall back to nothing:
  return;
}

// Example usage:
async function doWork () {
  // Do some work:
  // ...

  await yieldToMain();

  // Do some other work:
  // ...
}

最後

大家覺得這個 API 怎麼樣呢?喚應在評論區留言。

參考:https://developer.chrome.com/en/blog/introducing-scheduler-yield-origin-trial/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/HjyBH8ZRwuNgwufMDc4bgQ