干货时刻

本文首先分析了react 15架构及其缺陷,进而引入react 16架构的原理介绍。

调和

在本文的开始首先弄清楚调和的概念:调和指的是通过ReactDOM等库使VDOMDOM同步。也就是将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:浏览器环境渲染
  • ReactNativeapp原生组件渲染
  • ReactTest:渲染出纯js对象用于测试
  • ReactArtcanvas、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:下一次renderscroll时执行

fiber reconciler在执行中分细分为两个阶段:

  • render | reconciliation阶段:生成fiber树,对新旧VDOM进行diff,找到需要更新的节点,放入更新队列。这个阶段进行分片处理,可以被高优先级的任务打断。值得注意的是,在类组件中,componentWillMount、componentWillUpdate、componentWillReceiveProps、shouldComponentUpdata这几个生命周期钩子可能会被多次调用,所以不要在以上钩子中做只需要做一次的操作,比方说ajax请求。
  • commit阶段:将需要更新的节点一次性更新完,渲染真实DOM,不能被打断。
fiber 工作机制

fiber reconcilerreconciliation阶段会生成fiber树用于difffiber树和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树就长这样

react架构怎么翻译 react架构图_javascript

既然明白了fiber节点的结构,以及fiber是如何联系的。那么接下来讲一下页面初始化时fiber的工作机制。

第一次挂载过程中,创建fiberRootrootFiber

  • fiberRoot:首次构建应用时,会创建一个fiberRoot作为应用的fiber根节点
  • rootFiber:组件的fiber根节点,可以通过ReactDOM.render渲染。

创建fiberRoot时会将fiberRootcurrent指针指向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均指向彼此。

react架构怎么翻译 react架构图_前端框架_02

workInProgress树上会完成整个fiber树的遍历,包括fiber节点创建。完成后,以workInProgress树作为新的渲染树,将fiberRootcurrent指向workInProgress树的rootFiber,使其转化为current树。

react架构怎么翻译 react架构图_前端_03

如果我们将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树的调和,其执行过程分细分为beginWorkcompleteWork两个阶段。

  • beginWorkworkInProgress树自顶向下DFS调和的阶段,由fiberRoot按照child指针逐层向下调和,期间执行组件render拿到children,然后遍历childrendiff子节点,复用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操作后,针对类组件会执行生命周期、setStatecallback。针对函数组件会执行useLayoutEffect钩子,如果有ref则重新赋值。