天猫前端
首发于天猫前端
浅谈React Scheduler任务管理

浅谈React Scheduler任务管理

背景

大家应该都知道,React16采用了fiber架构,这样的架构下,React内部会动态灵活的管理所有组件的渲染任务,可以随时暂停某一个组件的渲染,所以,对于复杂型应用来说,对于某一个交互动作的反馈型任务,我们是可以对其进行拆解,一步步的做交互反馈,避免在一个页面重绘时间周期内做过多的事情,这样就能减少应用的长任务,最大化提升应用操作性能,那么,我们现在可以只聚焦在任务管理上,一起来研究一下React到底是如何管理渲染任务的。


阅读源码

很庆幸,React现在的架构已经非常清晰,已经不再是React15那个臃肿,晦涩难懂的时代,React16已经将内部很多模块物理剥离出来,同时目录结构也越来越清晰,阅读React源码已经是件很轻松的事情了。

源码地址:

github.com/facebook/rea

我们大概的扫一下源码,可以看到几个核心的点:

  1. 时间
  2. 优先级
  3. requestAnimationFrame
  4. requestIdleCallback

想要展开去讲的话,就必须得先理解页面的绘制过程,从帧管理到EventLoop中的时间片管理出发,一步步的来理解Facebook这帮人到底是想干什么。


了解JS执行流程

Javascript执行是会经历静态编译,动态解释和事件循环做任务调度的过程,大致的流程如下(注意,该流程是以chrome浏览器内核为标准的执行流程,在node或者其他浏览器中,执行流程会有所差异,但是核心思想是差不多的):



该流程的核心特征是:

  • 一个主线程负责解析编译与事件循环调度
  • 异步任务队列与V8通讯是通过Polling Check来实现的
  • 异步任务队列分为macrotask系统级任务与microtask微任务,它们是按照标准的事件源来分类的


了解页面绘制过程


众所周知,浏览器渲染页面的流程大概是:

执行JS(具体流程在上面有描述)--->计算Style--->构建布局模型(Layout)--->绘制图层样式(Paint)--->组合计算渲染呈现结果(Composite)

该流程的特征是:

  • 整个过程称之为一帧
  • 帧的渲染过程是在JS执行流程之后或者说一个事件循环之后
  • 帧的渲染过程是在一个独立的UI线程中处理的,还有GPU线程,用于绘制3D视图
  • 帧的渲染与帧的更新呈现是异步的过程,因为屏幕刷新频率是一个固定的刷新频率,通常是60次/秒,就是说,渲染一帧的时间要尽可能的低于16.6毫秒,否则在一些高频次交互动作中是会出现丢帧卡顿的情况,这就是因为渲染帧和刷新频率不同步造成的
  • 对于离散型交互动作,不要求一帧的渲染时间低于16.6毫秒,但是也是有标准RAIL模型需要遵循的


Google的RAIL模型:

  • 以用户为中心;最终目标不是让您的网站在任何特定设备上都能运行很快,而是使用户满意。
  • 立即响应用户;在 100ms以内确认用户输入,用于离散型交互操作,需要100ms内得以响应
  • 设置动画或滚动时,在 16ms以内生成帧.
  • 最大程度增加主线程的空闲时间,保证JS单任务执行时间不超过50ms。
  • 持续吸引用户;在 1000ms以内呈现交互内容,首屏秒开。


了解requestAnimationFrame

只需要记住几点:

  • 系统控制回调的执行时机恰好在回调注册完成后的下一帧渲染周期的起点的开始执行,控制js计算的到屏幕响应的精确性,避免步调不一致而导致丢帧
  • requestAnimationFrame回调只会在当前页面激活状态下执行,可以大大节省CPU开销
  • 需要注意一点,如果同时在一个高频次交互过程中注册多个requestAnimationFrame回调,这些回调的执行时机都会被注册至下一帧渲染周期的起点上,这样会导致每一帧的渲染压力增加
  • requestAnimationFrame回调参数是回调被调用的时间,也就是当前帧的起始时间


了解requestIdleCallback

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

同样只需要记住几点:

  • 对于离散型交互,上一帧的渲染到下一帧的渲染时间是属于系统空闲时间,经过亲测,Input输入,最快的单字符输入时间平均是33ms(通过持续按同一个键来触发),相当于,上一帧到下一帧中间会存在大于16.4ms的空闲时间,就是说任何离散型交互,最小的系统空闲时间也有16.4ms,也就是说,离散型交互的最短帧长一般是33ms。
  • requestIdleCallback回调调用时机是在回调注册完成的上一帧渲染到下一帧渲染之间的空闲时间执行
  • callback 是要执行的回调函数,会传入 deadline 对象作为参数,deadline 包含:
  1. timeRemaining:剩余时间,单位 ms,指的是该帧剩余时间。
  2. didTimeout:布尔型,true 表示该帧里面没有执行回调,超时了。
  • options 里面有个重要参数 timeout,如果给定 timeout,那到了时间,不管有没有剩余时间,都会立刻执行回调 callback。


放弃requestIdleCallback

很遗憾,React16至今都没有用上requestIdleCallback,从Facebook官方的解释上来看,主要还是因为浏览器的兼容性问题,同时React团队也没有看到任何浏览器厂商在正向的推动requestIdleCallback的覆盖进程,所以React只能采用了偏hack的polyfill方案。


requestIdleCallback Polyfill方案

很简单,33毫秒,直接固定死每帧的总时间为33ms,还有就是requestAnimationFrame回调的第一个参数,每一帧的起始时间,最终借助requestAnimationFrame让一批扁平的任务恰好控制在一块一块的33ms这样的时间片内执行即可


scheduler调度算法

首先,要明确几点:

  • scheduler是用来做任务调度的
  • 所有任务在一个调度生命周期内都有一个过期时间与调度优先级,但是调度优先级最终还是会转换为过期时间,只是过期时间长短的问题,过期时间越短代表越饥饿,优先级也就越高,但已经过期了的任务也会被视为饥饿任务
  • requestAnimationFrameWithTimeout,这是React scheduler的一个超强的函数,它是解决网页选项卡如果在未激活状态下requestAnimationFrame不会被触发的问题,这样的话,调度器是可以在后台继续做调度的,一方面也能提升用户体验,同时后台执行的时间间隔是以100ms为步长,这个是一个最佳实践,100ms是不会影响用户体验同时也不影响CPU能耗的一个折中时间间隔
  • 调度优先级分为:
    • 立即执行优先级,立即过期
    • 用户阻塞型优先级,250毫秒后过期
    • 空闲优先级,永不过期,可以在任意空闲时间内执行
    • 普通优先级,5秒后过期


  • 一个调度生命周期分为几个阶段
    • 调度前
      • 注册任务队列(环状链表,头接尾,尾接头),按照过期时间从小到大排列,如果当前任务是最饥饿的任务,则排到最前面,并立即开始调度,如果并不是最饥饿的任务,则放到队列中间或者最后面,不做任何操作,等待被调度
    • 调度准备
      • 通过requestAnimationFrame在下一次屏幕刚开始刷新的帧起点时计算当前帧的截止时间(33毫秒内)
      • 如果不超过当前帧的截止时间且当前任务没有过期,进入任务调度
      • 如果已经超过当前帧的截止时间,但没有过期,进入下一帧,并更新计算帧截止时间,重新判断时间(轮询判断),直到没有任何过期超时或者超时才进入任务调度
      • 如果已经超过当前帧的截止时间,同时已经过期,进入过期调度
    • 正式调度
      • 执行调度
        • 在当前帧的截止时间前批量调用所有任务,不管是否过期
      • 过期调度
        • 批量调用饥饿任务或超时任务的回调,删除任务节点
    • 调度完成
    • 检查任务队列是否还有任务
      • 先执行最饥饿的任务
      • 如果存在任务,则进入下一帧,进入下一个调度生命周期



scheduler的应用场景

最大的应用场景当然是React Fiber,Fiber将每次React渲染打扁成了一系列有序的commit任务,然后通过scheduler来控制每一帧的渲染并发量,从而提升整体性能,就这样将React渲染过程变成了



这是过去的渲染过程


QA

  1. 为什么要用链表结构?
    1. 答:因为链表结构就是为了空间换时间,对于插入删除操作性能非常好


总结

还记得那年,整天泡在图书馆的日子,为的只是抓住时光流逝的细微瞬间去学习JS技术。

还记得那年,心里空虚的整日撸着微博,为的只是咨询各种大V各式各样的问题。

时间一点点流逝,初心不变,持续学习,持续进步。。。

欢迎来天猫,与我一起探索更有挑战的前端技术方向!

zhili.wzl@alibaba-inc.com

编辑于 2018-11-02

文章被以下专栏收录