熟悉requestidlecallback到了解react requestidlecallback polyfill实现

WechatIMG198.jpeg

#前言

阅读本文你将收获:

  • 全面熟悉requestidlecallback用法和存在的价值。

  • 明确requestidlecallback的使用场景。

  • 了解react requestidlecallback polyfill的实现。

#背景知识

屏幕刷新率和FPS的关系?

当前大多数的屏幕刷新率都是60hz,也就是每秒屏幕刷新60次,低于60hz人眼就会感知卡顿掉帧等情况,同样我们前端浏览器所说的FPS(frame per second)是浏览器每秒刷新的次数,理论上FPS越高人眼觉得界面越流畅,在两次屏幕硬件刷新之间,浏览器正好进行一次刷新(重绘),网页也会很流畅,当然这种是理想模式, 如果两次硬件刷新之间浏览器重绘多次是没意义的,只会消耗资源,如果浏览器重绘一次的时间是硬件多次刷新的时间,那么人眼将感知卡顿掉帧等, 所以浏览器对一次重绘的渲染工作需要在16ms(1000ms/60)之内完成,也就是说每一次重绘小于16ms才不会卡顿掉帧。

一次重绘浏览器需要做哪些事情?

image

浏览器如何定义一帧?

浏览器的一帧说的就是一次完整的重绘。

#认识 requestIdleCallback

以下demo源码地址

window.requestIdleCallback()方法将在浏览器的空闲时段内调用的函数排队。

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

image

场景二

当浏览器没有可渲染的任务,主线程一直处于空闲状态,事件队列为空。为了避免在不可预测的任务(例如用户输入的处理)中引起用户可察觉的延迟,这些空闲周期的长度应限制为最大值50ms,也就是timeRemaining最大不超过50(也就是20fps,这也是react polyfill的原因之一),当空闲时段结束时,可以调度另一个空闲时段,如果它保持空闲,那么空闲时段将更长,后台任务可以在更长时间段内发生。如图:

image

注意: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生效了");
  });
});

结果如图:

image

经过performance录制分析如图:

image

放大第一帧看:

image

requestIdleCallback在第一帧过后就执行,原因第一帧过后就出现了空闲时段。那么如果每一帧没有空闲时间,requestIdleCallback会什么时候执行哪?

修改代码:

...
function work2() {
  document.body.appendChild(renderElement(`任务还剩 ${taskLen}`));
  console.log(`页面更新${++update}次`);
  sleep(1000);
  taskLen--;
  if (taskLen) {
    requestAnimationFrame(work2);
  }
}
...

结果:会等到所有的渲染任务执行完毕才会有空闲时间,所以requestIdleCallbackcb在最后执行。

image

如果不想让空闲任务等待那么久,那么requestIdleCallback的第二个参数就派上用场了, {timeout: 1000},更改demo,如下:

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

运行的结果,console输出顺序:... -> 页面更新3次 -> 空闲了, requestIdleCallback生效了-> ...

image

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


源码地址

欢迎各位大佬批评指正,,,

🐶🐶🐶🐶🐶🐶

参考链接:

https://w3c.github.io/requestidlecallback/

https://developers.google.com/web/updates/2015/08/using-requestidlecallback

https://wiki.developer.mozilla.org/zh-CN/docs/Web/API/Background_Tasks_API

https://juejin.im/post/5ec73026f265da76da29cb25#heading-15

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343