熟悉requestidlecallback到了解react requestidlecallback polyfill實現

#前言

閱讀本文你將收穫:html

  • 全面熟悉requestidlecallback用法和存在的價值。前端

  • 明確requestidlecallback的使用場景。react

  • 瞭解react requestidlecallback polyfill的實現。git

#背景知識

屏幕刷新率和FPS的關係?

當前大多數的屏幕刷新率都是60hz,也就是每秒屏幕刷新60次,低於60hz人眼就會感知卡頓掉幀等狀況,一樣咱們前端瀏覽器所說的FPS(frame per second)是瀏覽器每秒刷新的次數,理論上FPS越高人眼以爲界面越流暢,在兩次屏幕硬件刷新之間,瀏覽器正好進行一次刷新(重繪),網頁也會很流暢,固然這種是理想模式, 若是兩次硬件刷新之間瀏覽器重繪屢次是沒意義的,只會消耗資源,若是瀏覽器重繪一次的時間是硬件屢次刷新的時間,那麼人眼將感知卡頓掉幀等, 因此瀏覽器對一次重繪的渲染工做須要在16ms(1000ms/60)以內完成,也就是說每一次重繪小於16ms纔不會卡頓掉幀。github

一次重繪瀏覽器須要作哪些事情?web

瀏覽器如何定義一幀?

瀏覽器的一幀說的就是一次完整的重繪。瀏覽器

#認識 requestIdleCallback

如下demo源碼地址app

**window.requestIdleCallback()**方法將在瀏覽器的空閒時段內調用的函數排隊。dom

API異步

var handle = window.requestIdleCallback(callback[, options])

callback: 一個在事件循環空閒時即將被調用的函數的引用。函數會接收到一個名爲 IdleDeadline 的參數,這個參數能夠獲取當前空閒時間以及回調是否在超時時間前已經執行的狀態。
其中 IdleDeadline 對象包含:
didTimeout,布爾值,表示任務是否超時,結合 timeRemaining 使用。
timeRemaining(),表示當前幀剩餘的時間,也可理解爲留給任務的時間還有多少。

options的參數
timeout: 表示超過這個時間後,若是任務還沒執行,則強制執行,沒必要等待空閒。還沒有經過超時毫秒數調用回調,那麼回調會在下一次空閒時期被強制執行。若是明確在某段時間內執行回調,能夠設置timeout值。在瀏覽器繁忙的時候,requestIdleCallback超時執行就和setTimeout效果同樣。
複製代碼

返回值:和setTimeoutsetInterval 返回值同樣,是一個標識符。能夠經過 cancelIdleCallback(handle) 清除取消。

空閒時段

何時瀏覽器出現空閒時段?

場景一

當瀏覽器一幀渲染所用時間小於屏幕刷新率(對於具備60Hz 的設備,一幀間隔應該小於16ms)時間,到下一幀渲染渲染開始時出現的空閒時間,如圖idle period

場景二

當瀏覽器沒有可渲染的任務,主線程一直處於空閒狀態,事件隊列爲空。爲了不在不可預測的任務(例如用戶輸入的處理)中引發用戶可察覺的延遲,這些空閒週期的長度應限制爲最大值50ms,也就是timeRemaining最大不超過50(也就是20fps,這也是react polyfill的緣由之一),當空閒時段結束時,能夠調度另外一個空閒時段,若是它保持空閒,那麼空閒時段將更長,後臺任務能夠在更長時間段內發生。如圖:

注意:timeRemaining最大爲50毫秒,是根據研究[ RESPONSETIME ] 得出的,該研究代表,對用戶輸入的100毫秒之內的響應一般被認爲對人類是瞬時的,就是人類不會有察覺。將閒置截止期限設置爲50ms意味着即便在閒置任務開始後當即發生用戶輸入,用戶代理仍然有剩餘的50ms能夠在其中響應用戶輸入而不會產生用戶可察覺的滯後。

#requestIdleCallback 用法

demo1

先模擬一個可預測執行時間的佔用主線程的方法:

function sleep(date) {
  let flag = true;
  const now = Date.now();
  while (flag) {
    if (Date.now() - now > date) {
      flag = false;
    }
  }
}
複製代碼

requestIdleCallback執行主線程空閒開始調用的方法:

function work() {
  sleep(2000); // 模擬主線程任務執行時間

  requestIdleCallback(() => {
    console.log("空閒時間1");
    sleep(1000);
    console.log("空閒時間1回調任務執行完成");
  });

  requestIdleCallback(() => {
    console.log("空閒時間2");
  });
}

btn1.addEventListener("click", work);
複製代碼

執行結果:點擊button -> 等待2s -> 打印 空閒時間1 -> 等待 1s -> 打印 空閒時間1回調任務執行完成 -> 空閒時間2;當sleep結束requestIdleCallback獲取到主線程空閒,立馬執行cb(也是在主線程執行)繼續佔用主線程,直到sleep結束,第二個requestIdleCallback獲取主線程空閒輸出空閒時間2。細看一下,此處requestIdleCallback不就是setTimeout嗎,這樣的功能用setTimeout也能實現,固然他們是有區別的,的咱們sleep模擬佔用主線程時間是可控的,但大多時候主線程work時間是不可預知的,setTimeout須要知道具體延遲時間,因此這是主要的卻別。

demo2: 模擬dom更新

function renderElement(txt) {
  const p = document.createElement("p");
  p.innerText = txt;
  
  return p;
}

let taskLen = 10;
let update = 0;
function work2() {
  document.body.appendChild(renderElement(`任務還剩 ${taskLen}`));
  console.log(`頁面更新${++update}次`);
  taskLen--;
  if (taskLen) {
    requestAnimationFrame(work2);
  }
}

btn1.addEventListener("click", () => {
  requestAnimationFrame(work2);
  window.requestIdleCallback(() => {
    console.log("空閒了, requestIdleCallback生效了");
  });
});
複製代碼

結果如圖:

通過performance錄製分析如圖:

放大第一幀看:

requestIdleCallback在第一幀事後就執行,緣由第一幀事後就出現了空閒時段。那麼若是每一幀沒有空閒時間,requestIdleCallback會何時執行哪?

修改代碼:

...
function work2() {
  document.body.appendChild(renderElement(`任務還剩 ${taskLen}`));
  console.log(`頁面更新${++update}次`);
  sleep(1000);
  taskLen--;
  if (taskLen) {
    requestAnimationFrame(work2);
  }
}
...
複製代碼

結果:會等到全部的渲染任務執行完畢纔會有空閒時間,因此requestIdleCallbackcb在最後執行。

若是不想讓空閒任務等待那麼久,那麼requestIdleCallback的第二個參數就派上用場了, {timeout: 1000},更改demo,以下:

...
btn1.addEventListener("click", () => {
  requestAnimationFrame(work2);
  window.requestIdleCallback(
    () => {
      console.log("空閒了, requestIdleCallback生效了");
    },
    { timeout: 1200 }  // 最遲能等待1.2s
  );
});
...
複製代碼

運行的結果,console輸出順序:... -> 頁面更新3次 -> 空閒了, requestIdleCallback生效了-> ...

demo3:用戶行爲

當用戶input輸入時,可用requestIdleCallback來避免不可見的行爲形成用戶行爲形成卡頓,譬如發送數據分析、處理界面不可見的業務邏輯等。

下面以發送數據分析爲例:

// 記錄須要發送的數據隊列
const eventStack = [];
// requestIdleCallback是否已經調度
let isRequestIdleCallbackScheduled = false;
// 模擬發送數據
const sendData = (...arg) => {
  console.log("發送數據", arg);
};

function onDivThemeRed() {
  // 業務邏輯
  render.classList.remove("border-blue");
  render.classList.add("border-red");

  eventStack.push({
    category: "button",
    action: "click",
    label: "theme",
    value: "red",
  });

  schedulePendingEvents();
}

function onDivThemeBlue() {
  // 業務邏輯
  render.classList.remove("border-red");
  render.classList.add("border-blue");

  eventStack.push({
    category: "button",
    action: "click",
    label: "theme",
    value: "blue",
  });

  schedulePendingEvents();
}

function schedulePendingEvents() {
  if (isRequestIdleCallbackScheduled) return;

  isRequestIdleCallbackScheduled = true;

  requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });
}

function processPendingAnalyticsEvents(deadline) {
  isRequestIdleCallbackScheduled = false;

  while (deadline.timeRemaining() > 0 && eventStack.length > 0) {
    const evt = eventStack.pop();

    sendData(
      "send",
      "event",
      evt.category,
      evt.action,
      evt.label,
      evt.value
    );
  }

  if (eventStack.length > 0) schedulePendingEvents();
}

btn2.addEventListener("click", onDivThemeRed);
btn3.addEventListener("click", onDivThemeBlue);
複製代碼

總結:

requestIdleCallback會在每一幀結束後執行,去判斷瀏覽器是否空閒,若是瀏覽器一直處於佔用狀態,則沒有空閒時間,且若是requestIdleCallback沒有設置timeout時間,那麼callback的任務會一直推遲執行,若是在當前幀設置timeout,瀏覽器會在當前幀結束的下一幀開始判斷是否超時執行callbackrequestIdleCallback任務沒有和瀏覽器的幀渲染對其,應用不當會形成掉幀卡頓,原則上requestIdleCallback的FPS只有20,因此有高FPS要求的、須要和渲染幀對齊執行任務,如DOM動畫等,建議用requestAnimationFrame,纔會達到最佳流暢效果。

下面介紹一下react中有關requestIdleCallback的介紹。

#reactrequestIdleCallback pollyfill 的實現

前面提到requestIdleCallback工做只有20FPS,通常對用戶來感受來講,須要到60FPS纔是流暢的, 即一幀時間爲 16.7 ms,因此這也是react團隊本身實現requestIdleCallback的緣由。實現大體思路是在requestAnimationFrame獲取一楨的開始時間,觸發一個postMessage,在空閒的時候調用idleTick來完成異步任務。

源碼解析react如何實現requestIdleCallback

源碼在packages/scheduler/src/forks/SchedulerHostConfig.default.js下,分別對非DOM和DOM環境有不一樣的實現。

export let requestHostCallback; // 相似requestIdleCallback
export let cancelHostCallback; // 相似cancelIdleCallback
export let requestHostTimeout; // 非dom環境的實現
export let cancelHostTimeout;  // 取消requestHostTimeout
export let shouldYieldToHost;  // 判斷任務是否超時,須要被打斷
export let requestPaint; // 
export let getCurrentTime; // 獲取當前時間
export let forceFrameRate; // 根據fps計算幀時間
// 非dom環境
if (typeof window === 'undefined' || typeof MessageChannel !== 'function') {
	let _callback = null; // 正在執行的回調
  let _timeoutID = null;
  const _flushCallback = function() {
    // 若是回調存在則執行,
    if (_callback !== null) {
      try {
        const currentTime = getCurrentTime();
        const hasRemainingTime = true;
        // hasRemainingTime 相似deadline.didTimeout
        _callback(hasRemainingTime, currentTime);
        _callback = null;
      } catch (e) {
        setTimeout(_flushCallback, 0);
        throw e;
      }
    }
  };
  
  // ...
  
  requestHostCallback = function(cb) {
    // 若_callback存在,表示當下有任務再繼續,
    if (_callback !== null) {
      // setTimeout的第三個參數能夠延後執行任務。
      setTimeout(requestHostCallback, 0, cb);
    } else {
      // 不然直接執行。
      _callback = cb;
      setTimeout(_flushCallback, 0);
    }
  };
  cancelHostCallback = function() {
    _callback = null;
  };
  requestHostTimeout = function(cb, ms) {
    _timeoutID = setTimeout(cb, ms);
  };
  cancelHostTimeout = function() {
    clearTimeout(_timeoutID);
  };
  shouldYieldToHost = function() {
    return false;
  };
  requestPaint = forceFrameRate = function() {};
} else {
  // 一大堆的瀏覽器方法的判斷,有performance, requestAnimationFrame, cancelAnimationFrame
  // ...
  const performWorkUntilDeadline = () => {
    if (scheduledHostCallback !== null) {
      const currentTime = getCurrentTime();
      // yieldInterval每幀的時間,deadline爲最終期限時間
      deadline = currentTime + yieldInterval;
      const hasTimeRemaining = true;
      try {
        const hasMoreWork = scheduledHostCallback(
          hasTimeRemaining,
          currentTime,
        );
        if (!hasMoreWork) {
          isMessageLoopRunning = false;
          scheduledHostCallback = null;
        } else {
          // 若是有更多的工做,就把下一個消息事件安排在前一個消息事件的最後
          port.postMessage(null);
        }
      } catch (error) {
        // 若是調度任務拋出,則退出當前瀏覽器任務,以便觀察錯誤。
        port.postMessage(null);
        throw error;
      }
    } else {
      isMessageLoopRunning = false;
    }
    needsPaint = false;
  };

  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;

  requestHostCallback = function(callback) {
    scheduledHostCallback = callback;
    if (!isMessageLoopRunning) {
        isMessageLoopRunning = true;
        port.postMessage(null);
    }
	};
  
}
複製代碼

由上可見,非DOM模式下requestHostCallbacksetTimeout模擬實現的,而在DOM下是基於MessageChannel消息的發佈訂閱模式postMessageonmessage實現的。

#總結

requestIdleCallback須要注意的:

  • requestIdleCallback是屏幕渲染以後執行的。

  • 一些低優先級的任務可以使用 requestIdleCallback 等瀏覽器不忙的時候來執行,同時由於時間有限,它所執行的任務應該儘可能是可以量化,細分的微任務(micro task)比較適合requestIdleCallback

  • requestIdleCallback不會和幀對齊,因此涉及到DOM的操做和動畫最好放在requestAnimationFrame中執行,requestAnimationFrame在從新渲染屏幕以前執行。

  • Promise 也不建議在這裏面進行,由於 Promise 的回調屬性 Event loop 中優先級較高的一種微任務,會在 requestIdleCallback 結束時當即執行,無論此時是否還有富餘的時間,這樣有很大可能會讓一幀超過 16 ms。

#拓展

requestAnimationFrame

MessageChannel


歡迎各位大佬批評指正,,,

源碼地址

🐶🐶🐶🐶🐶🐶

參考連接:

w3c.github.io/requestidle…

developers.google.com/web/updates…

wiki.developer.mozilla.org/zh-CN/docs/…

juejin.im/post/5ec730…

相關文章
相關標籤/搜索