干货时刻
本文首先分析了react 15
架构及其缺陷,进而引入react 16
架构的原理介绍。
调和
在本文的开始首先弄清楚调和的概念:调和指的是通过ReactDOM
等库使VDOM
和DOM
同步。也就是将VOM
映射成DOM
的过程。调和所做的工作包括组件的挂载、卸载、更新等过程,其中更新就用到了Diff
算法。
Diff 算法
Diff
算法的本质是对比新旧VDOM
树的变更差异。其核心思想分为三个方面:
- 同层比较
- 忽略跨层级操作,同层比较中如果发现节点不存在,则该节点及其子节点均会被删除。
- 类型不同,原地替换;类型一致,分层递归
- 当根节点是不同类型的元素时,会放弃比较,原地替换旧节点
- 当根节点是相同类型的
DOM
元素,保留根节点,只更新节点属性 - 当根节点是相同类型的组件,需要在组件
render
执行后,根据render
返回的VDOM
决定如何更新DOM
。在比较完根节点后,会以同样的原则递归比较子节点。
- 通过
key
重用节点
- 在列表元素的比较中,如果定义了
key
,则react
根据key
匹配子节点,每次渲染后,只要子节点的key
不变,则认为是同一个节点,进行复用,提高了更新效率。
react 15 架构
言归正传,react
架构的组成可以分为两点:调和器和渲染器。在V15
版本中的调和器称为栈调和器。
stack reconciler
栈调和器
- 调用函数组件或类组件的
render
方法,将返回的jsx
转化为VDOM
。 - 将
VDOM
和上次更新时的VDOM
对比,找出本次更新中发生变化的VDOM
。 - 通知
renderer
将变化的VDOM
渲染到页面
renderer
渲染器
-
ReactDOM
:浏览器环境渲染 -
ReactNative
:app
原生组件渲染 -
ReactTest
:渲染出纯js
对象用于测试 -
ReactArt
:canvas、svg
等容器上渲染
react 15 架构的缺点
主流浏览器每隔16.7ms
就刷新一次,而浏览器渲染进程中js
线程和GUI
渲染线程是互斥的,不能同时执行,所以在每个16.7ms
内既要执行js
脚本,又要布局和绘制。react 15
的更新属于同步更新策略,要递归遍历所有子节点进行diff
、更新真实的DOM
。并且这个过程是不能被打断的。当组件树的层级很深时,递归调和的时间超过了16.7ms
,就会导致用户的交互出现卡顿。
react 16 的解决思路
与V15
版本不同的是,在V16
版本中的调和器称为纤程调和器(fiber reconciler
),并且新增了调度器调度任务的优先级,使得高优先级任务优先进入fiber reconciler
。从整体来看,新架构的核心解决思路就是降低视图更新的优先级。
fiber reconciler
会把更新过程进行分片,调度器(scheduler
)进行任务分配。当每个片段的任务执行完后就去看看有没有优先级更高的任务去做。如果有,就去把这个高优先级的任务做完,然后重新做更新任务。如果没有,才继续做其它的分片任务。
其中,任务的优先级分为六种:
-
synchronous
,和stack reconciler
一样都是同步执行 -
task
:在next tick
前执行 -
animation
:下一帧前执行 -
high
:不久的将来立即执行 -
low
:稍微延迟执行 -
offscreen
:下一次render
或scroll
时执行
fiber reconciler
在执行中分细分为两个阶段:
-
render | reconciliation
阶段:生成fiber
树,对新旧VDOM
进行diff
,找到需要更新的节点,放入更新队列。这个阶段进行分片处理,可以被高优先级的任务打断。值得注意的是,在类组件中,componentWillMount、componentWillUpdate、componentWillReceiveProps、shouldComponentUpdata
这几个生命周期钩子可能会被多次调用,所以不要在以上钩子中做只需要做一次的操作,比方说ajax
请求。 -
commit
阶段:将需要更新的节点一次性更新完,渲染真实DOM
,不能被打断。
fiber 工作机制
fiber reconciler
在reconciliation
阶段会生成fiber
树用于diff
。fiber
树和react 15
架构中的VDOM
树有什么区别?
我们先看看一个fiber
节点长什么样:
function FiberNode() {
this.tag = tag; // fiber 标签,代表的类型
this.key = key; // 用来调和子节点
this.type = null; // 对应的 dom 元素类型
this.stateNode = null; // 对应的 dom 元素
this.return = null; // 指向父级 fiber
this.child = null; // 指向子级 fiber
this.sibling = null; //指向兄弟 fiber
this.index = 0; //索引
this.ref = null; // 指向 ref 对象。
this.pendingProps = pendingProps; // 在一次更新中,代表 element 创建
this.memoizedProps = null; // 记录上一次更新完毕后的 props
this.updateQueue = null; // 存放 setState 更新队列
this.memoizedState = null; // 类组件保存 state 信息,函数组件保存 hooks 信息
this.dependencies = null;
this.mode = mode; // 描述 fiber 树的模式,比如 ConcurrentMode 模式
this.effectTag = NoEffect; // effect 标签,用于收集 effectList
this.nextEffect = null; // 指向下一个 effect
this.firstEffect = null; // 第一个 effect
this.lastEffect = null; // 最后一个 effect
this.expirationTime = NoWork; // 通过不同过期时间,判断任务是否过期, 在 v17 版本用 lane 表示。
this.alternate = null; // 双缓存树,指向缓存的 fiber。更新阶段,两颗树互相交替。
}
每一个fiber
节点都和一个react element
一一对应,fiber
节点之间是通过return、child、sibling
三个属性相连。
举个例子,同学们请看这个App
组件:
export default class App extends React.Component{
state={ number:666 }
render(){
return <div>
hello world
<p > 东曜说 { this.state.number }</p>
<p>关注走一走</p>
</div>
}
}
它的fiber
树就长这样
既然明白了fiber
节点的结构,以及fiber
是如何联系的。那么接下来讲一下页面初始化时fiber
的工作机制。
第一次挂载过程中,创建fiberRoot
和rootFiber
。
-
fiberRoot
:首次构建应用时,会创建一个fiberRoot
作为应用的fiber
根节点 -
rootFiber
:组件的fiber
根节点,可以通过ReactDOM.render
渲染。
创建fiberRoot
时会将fiberRoot
的current
指针指向rootFiber
。
function createFiberRoot(containerInfo, tag) {
/* 创建一个root */
const root = new FiberRootNode(containerInfo, tag);
const rootFiber = createHostRootFiber(tag);
root.current = rootFiber;
return root;
}
在渲染过程中,会复用当前current
树的alternate
作为workInProgress
树。如果没有alternate
(在第一次挂载时current
树的fiber
节点没有alternate
),则会创建一个新fiber
节点作为workInProgress
树的rootFiber
节点,同时两个颗树的fiber
节点的alternate
均指向彼此。
在workInProgress
树上会完成整个fiber
树的遍历,包括fiber
节点创建。完成后,以workInProgress
树作为新的渲染树,将fiberRoot
的current
指向workInProgress
树的rootFiber
,使其转化为current
树。
如果我们将App
组件修改一下:
export default class App extends React.Component{
state={ number:666 }
handleClick=()=>{
this.setState({
number:this.state.number + 1
})
}
render(){
return <div>
hello world
<p > 东曜说 { this.state.number }</p>
<button onClick={handleClick}>关注走一走</button>
</div>
}
}
此时点击一次按钮,页面就会重新渲染一次。即重新创建一颗workInProgress
树,复用当前current
树上的alternate
作为新的workInProgress
。对于剩余子节点,react
还需要创建一份,和current
树上的fiber
建立alternate
关联。渲染完毕后,workInProgress
再次转化为current
树。
上述更新逻辑称为双缓冲树:workInProgress
树在内存中构建,current
树用作渲染视图,两棵树用alternate
指针互相指向,在下一次渲染的时候直接复用缓存树作为一下次的渲染树,上一次的渲染树这次作为缓存树。这样可以防止只用一棵树更新时出现页面白屏闪烁的情况,又加快DOM
节点的替换和更新。
fiber reconciler
由上节可知,fiber reconciler
可以分为两个阶段:reconciliation
阶段和commit
阶段。
reconciliation
阶段负责fiber
树的调和,其执行过程分细分为beginWork
和completeWork
两个阶段。
beginWork
是workInProgress
树自顶向下DFS
调和的阶段,由fiberRoot
按照child
指针逐层向下调和,期间执行组件render
拿到children
,然后遍历children
,diff
子节点,复用oldFiber
,同时打上不同的副作用标签。completeWork
是自底向上归并的过程,如果有兄弟节点返回sibling
,没有就返回父级fiber
,一直到fiberRoot
。期间将打上副作用标签的fiber
节点放进effectList
单向链表中,在commit
阶段不需要遍历每一个fiber
,只需要执行更新effectList
即可。在页面初始化情况下还会创建DOM
,针对DOM
元素进行事件收集,处理style
等。
commit
阶段可以细分为beforeMutaion、mutation、layout
等三个阶段。
-
beforeMutaion
具体指执行DOM
操作前,如果是类组件则会执行getSnapshotBeforeUpdate
生命周期钩子,如果是函数组件则会异步调用useEffect
。 -
mutation
具体指执行DOM
操作阶段,会进行真实DOM
元素的增、删、改,同时置空ref
。 -
layout
具体指执行DOM
操作后,针对类组件会执行生命周期、setState
的callback
。针对函数组件会执行useLayoutEffect
钩子,如果有ref
则重新赋值。