1. 浏览器渲染基础
1.1 渲染帧
帧(frame):动画过程中,每一幅静止的画面叫做帧
帧率(frame per second):每秒连续播放的静止画面的数量
帧时长(frame running time):每一幅静止的画面的停留时间
丢帧(dropped frame):当某一帧时长高于平均帧时长
- 一般来说浏览器刷新率在60Hz,渲染一帧的时间必须控制在16.67ms(1s/60=16.67ms)
- 如果渲染超过该时间,对用户视觉上来说,就会出现卡顿现象,即丢帧(
dropped frame)
1.2 帧生命周期
简单描述帧生命周期
-
首先处理输入事件,能让用户得到最快的反馈
-
接下来是处理定时器,需要检查定时器是否到时间,并执行对应的回调函数
-
接下来处理
Begin Frame(开始帧),即每一帧的事件,包括window.resize,scroll等 -
接下来执行请求动画帧
requestAnimationFrame(rAF),每次在绘制之前,会执行rAF回调 -
接着进行layout操作,计算布局和更新布局,即元素的样式是怎样的,应该在页面上如何展示
-
进行paint操作,得到树中每个节点的尺寸与位置,浏览器对每个元素进行内容填充
此时上面步骤完成后,如果还有空闲时间(Idle Period),执行RequestIdleCallback函数里注册的任务(它就是React Fiber任务调度实现的基础)
1.3 丢帧实验
为什么会丢帧?
对于流畅的动画,如果对一帧画面的处理时间超过16.67ms,就能感到卡顿,下面链接是模拟丢帧实验
Demo: linjiayu6.github.io/FE-RequestI…
当用户点击任一按键A,B,C时,因主线程执行click event任务,动画因浏览器不能及时处理下一帧,导致出现卡顿现象 主要逻辑代码如下
// 处理同步任务,并占用主线程
const bindClick = id =>
element(id).addEventListener('click', Work.onSyncUnit)
// 绑定click事件
bindClick('btnA')
bindClick('btnB')
bindClick('btnC')
var Work = {
// 有1万个任务
unit: 10000,
// 处理每个任务
onOneUnit: function () { for (var i = 0; i <= 500000; i++) {} },
// 同步处理所有的任务
onSyncUnit: function () {
let _u = 0
while (_u < Work.unit) {
Work.onOneUnit()
_u ++
}
}
}
1.4 解决掉帧问题
前面提到当正常帧任务完成时间在16ms内,会有空闲的时间,就会执行requestIdleCallback函数里注册的任务,这个就是React Fiber实现的基础api。先来看看requestIdleCallback在每帧的调用
-
低优先级任务由
requestIdleCallback处理 -
高优先级任务如与动画相关的由
requestAnimationFrame处理 -
requestIdleCallback可以在多个空闲期间调用,执行任务 -
window.requestIdleCallback(callback)的callback中会接受到默认参数deadline,其中包含了以下来两个属性:timeRamining返回当前帧还剩多少时间可用didTimeout返回callback任务是否超时
下面我们就对前面的实验进行改造:
-
利用
RequestIdleCallback处理任务 -
将高耗时的任务拆解,分步在
idle period里面执行
逻辑如下:
const bindClick = id =>
element(id).addEventListener('click', Work.onAsyncUnit)
// 绑定定click事件
bindClick('btnA')
bindClick('btnB')
bindClick('btnC')
var Work = {
// 有1万个任务
unit: 10000,
// 处理每个任务
onOneUnit: function () { for (var i = 0; i <= 500000; i++) {} },
// 异步处理
onAsyncUnit: function () {
// 空闲时间 1ms
const FREE_TIME = 1
let _u = 0
function cb(deadline) {
// 当任务还没有处理完 & 一帧还有的空闲时间 > 1ms
while (_u < Work.unit && deadline.timeRemaining() > FREE_TIME) {
Work.onOneUnit()
//计算执行的次数,到10000就跳出循环,如果未到10000以便下面判断,继续放入到空闲执行
_u ++
}
// 任务完成, 执行回调
if (_u >= Work.unit) {
// 执行回调
return
}
// 任务没完成, 继续等待空闲执行
window.requestIdleCallback(cb)
}
//刚开始点击时,就将耗时任务放进空闲时间中处理
window.requestIdleCallback(cb)
}
}
效果如下:
可以看到帧率都是在60fps左右。
值得注意的是,requestIdleCallback里应该避免执行长时间的任务,否则可能会阻塞页面渲染
如果在requestIdleCallback里执行一个长时间的任务,在第一帧时间不够执行后,它会抢夺第二帧的一些时间,从而造成卡顿。
2. React Fiber架构
React15架构的缺点:
-
使用递归遍历更新树,不能中断
-
有用户高优先级操作的比如点击事件 动画等,必须等待主线程释放才能响应,会造成丢帧
为了解决上述的问题,React团队对核心算法进行重构,重构的产物就是Fiber reconciler,过程如下:
注意:scheduler和Fiber Reconciler这个时期是可中断的,
commit时期是不可中断的
React的fiber架构更改:
-
将树的结构重构为多项链表结构,递归算法重构为深度优先遍历算法
-
将无法中断的更新重构为异步的可中断的更新
-
将更新碎片化,每执行完一部分,查看是否有高优先级任务,如果有,记录状态,先去执行高优先级任
务,在下一个空闲时间再执行剩下部分
2.1 Scheduler(调度阶段)
React Fiber的构建不是一蹴而就,它是每个fiber作为一个工作单元(fiber tree上的一个节点)进行工作循环,shouldYieldToRenderer看看分配给任务的时间用完了没,没用完就处理下一个任务,否则释放主线程,等待等下一次requestIdleCallback回调接着做。
// Flush asynchronous work until there's 在render进行渲染a higher priority event
while (nextUnitOfWork !== null && !shouldYieldToRenderer()) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
原理图如下:
每个工作单元运行时有6种优先级:
synchronous同步执行task在next tick之前执行animation下一帧之前执行high在不久的将来立即执行low稍微延迟(100-200ms)执行也没关系offscreen下一次render时或scroll时才执行
synchronous首屏(首次渲染)用,要求尽量快,不管会不会阻塞UI线程。
animation通过requestAnimationFrame来调度,这样在下一帧就能立即开始动画过程;
后3个都是由requestIdleCallback回调执行的;
offscreen指的是当前隐藏的、屏幕外的(看不见的)元素
高优先级的比如键盘输入(希望立即得到反馈),低优先级的比如网络请求,让评论显示出来等等.
每一种优先级都会分配一定的expirationTime,时间越短,优先级越高。
React16 的 expirationTimes 模型只能区分是否>=expirationTimes決定节点是否更新。
这样的优先级机制存在2个问题:
-
生命周期函数怎么执行(可能被频频中断):触发顺序、次数没有保证了
-
starvation(低优先级饿死):如果高优先级任务很多,那么低优先级任务根本没机会执行(就饿死了)
React17 的 lanes 模型 可以选定一个更新区间,并且动态的向区间中增减优先级,可以处理更细粒度的更新。
2.2 Fiber Reconciler(协调阶段)
这个过程是diff的过程,也是effect收集的过程,找出所有节点的变更,比如节点新增,删除,属性变更等,这些变更统称为副作用(effect),最后的结果是生成一个 effect list,在后面render里,就根据 effect list在commit阶段进行渲染
2.2.1 fiber节点属性
由于基于时间分片的更新,就要更多的上下文信息,当切换高优先级任务时,记住当前的节点信息,以便下次空闲时间可以继续执行任务
fiber节点的属性有很多,大家需要关注的有这几个 return、child、sibling(这几个主要负责 fiber链表的连接);stateNode;effectTag;expirationTime;alternate;nextEffect
class FiberNode {
constructor(tag, pendingProps, key, mode) {
// 实例属性
this.tag = tag; // 标记不同组件类型,如函数组件、类组件、文本、原生组件...
this.key = key; // react 元素上的 key 就是 jsx 上写的那个 key ,也就是最终 ReactElement 上的
this.elementType = null; // createElement的第一个参数,ReactElement 上的 type
this.type = null; // 表示fiber的真实类型 ,elementType 基本一样,在使用了懒加载之类的功能时可能会不一样
this.stateNode = null; // 实例对象,比如 class 组件 new 完后就挂载在这个属性上面,如果是RootFiber,那么它上面挂的是 FiberRoot,如果是原生节点就是 dom 对象
// fiber
this.return = null; // 父节点,指向上一个 fiber
this.child = null; // 子节点,指向自身下面的第一个 fiber
this.sibling = null; // 兄弟组件, 指向一个兄弟节点
this.index = 0; // 一般如果没有兄弟节点的话是0 当某个父节点下的子节点是数组类型的时候会给每个子节点一个 index,index 和 key 要一起做 diff
this.ref = null; // reactElement 上的 ref 属性
this.pendingProps = pendingProps; // 新的 props
this.memoizedProps = null; // 旧的 props
this.updateQueue = null; // fiber 上的更新队列执行一次 setState 就会往这个属性上挂一个新的更新, 每条更新最终会形成一个链表结构,最后做批量更新
this.memoizedState = null; // 对应 memoizedProps,上次渲染的 state,相当于当前的 state,理解成 prev 和 next 的关系
this.mode = mode; // 表示当前组件下的子组件的渲染方式
// effects
this.effectTag = NoEffect; // 表示当前 fiber 要进行何种更新(更新、删除等)
this.nextEffect = null; // 指向下个需要更新的fiber
this.firstEffect = null; // 指向所有子节点里,需要更新的 fiber 里的第一个
this.lastEffect = null; // 指向所有子节点中需要更新的 fiber 的最后一个
this.expirationTime = NoWork; // 过期时间,代表任务在未来的哪个时间点应该被完成
this.childExpirationTime = NoWork; // child 过期时间
this.alternate = null; // current 树和 workInprogress 树之间的相互引用
}
}
2.2.2 遍历流程
React 16中大量地使用了链表这样的数据结构。使用多向链表替换了之前的树结构
过程如下:
-
从顶点开始遍历
-
有子节点,先遍历子节点
-
如果没有子节点,看是否有兄弟节点,有则遍历兄弟节点,并把effect向上归并
-
如果没有兄弟节点,那看父节点有没有兄弟节点,有的话遍历父节点的兄弟节点
-
如果都没有则遍历结束
2.2.2 副作用链
进行更新workInProgress(简称WIP) tree的时候,这棵树在构建每个fiber节点时,会收集这个节点的副作用信息, 当WIP tree构建完成后,將有副作用的节点通过 firstEffect、lastEffect、nextEffect 形成一条副作用单链表,最后在渲染阶段,通过副作用链完成更新
<div id="A1">
A1
<div id="B1">
B1
<div id="C1">C1</div>
<div id="C2">C2</div>
</div>
<div id="B2">
B2
</div>
</div>
上面的代码会形成如下的副作用链
2.2.2 Reconciliation过程
上图就是完成Reconciliation后的状态,左边是旧树,右边是WIP树,对于需要变更的节点,标记effectTag信息,在最后的render阶段,根据effectTag进行应用变更。
具体的流程如下:
-
如果当前节点不需要更新,直接把子节点clone过来,跳到5;要更新的话打个tag
-
更新当前节点状态(
props, state, context等) -
调用
shouldComponentUpdate(),false的话,跳到5 -
调用
render()获得新的子节点,并为子节点创建fiber(创建过程会尽量复用现有fiber,子节点增删也发生在这里) -
如果没有产生child fiber,该工作单元结束,把effect list归并到return,并把当前节点的sibling作为下一个工作单元;否则把child作为下一个工作单元
-
如果没有剩余可用时间了,等到下一次主线程空闲时才开始下一个工作单元;否则,立即开始做
-
如果没有下一个工作单元了(回到了
workInProgress tree的根节点),第1阶段结束,进入pendingCommit状态
实际上1-6是任务循环,7为最后的结果。任务循环结束后,WIP tree的根节点身上的effect list就是收集到的所有副作用(side effect)(因为每做完一个都向上归并)
所以,构建workInProgress tree的过程就是diff的过程,通过requestIdleCallback来调度执行一组任务,每完成一个任务后回来看看有没有插队的(高优先级任务),每完成一组任务,把时间控制权交还给主线程,直到下一次requestIdleCallback回调再继续构建workInProgress tree
2.2.2 双缓存技术
构建完成后就存在两棵树:分別叫做current fiber tree 和workInProgress fiber tree,可以看到,构建时,WIP tree是以current fiber tree为模板进行变更,得到的就是新的fiber tree,接着current指针指向WIP tree
再把旧的fiber tree放一边,它们的fiber节点是通过alternate属性相互建立连接,旧fiber就作为新fiber更新的预留空间,达到复用fiber实例的目的。 好处是:
- 能够复用内部对象(fiber)
- 节省内存分配、GC的时间开销
2.3 Commit(渲染阶段)
这个阶段是不中断的执行:
-
处理
effect list,根据变更信息进行操作(包括3种处理:更新DOM树、调用组件生命周期函数以及更新ref等内部状态) -
处理结束后,把变更都commit到DOM树上
这个过程是同步过程,在这个过程的生命周期里不要干重活
从构建过程生命周期函数可以分为2类
// 第1阶段 render/reconciliation
componentWillMount
componentWillReceiveProps
shouldComponentUpdate
componentWillUpdate
// 第2阶段 commit
componentDidMount
componentDidUpdate
componentWillUnmount
参考资料: