Post

React与它的小伙伴们(1)—— React

几个月前,机缘巧合,跟着几篇博文对于React、Redux等技术做了一些更深层次的了解。在此,特别整理一下汇整成笔记。

前人之述备矣,这里也仅仅只是汇整,所以大佬们可以跳过啦。当然也欢迎挑刺问题。不正之处,敬请指出。~

一天肯定是写不完了,还是分系列完成吧。今天是第一部分。

React

首先,从React开始。React作为一个当前流行的前端框架,从使用上,API的调用,生命周期的流转,作为一名前端工程师来说相信都是十分了解和完备的了。但有时候确实也会想要钻研一下,内部的实现逻辑。这时候就要去看看源代码啦~。

(下面的内容,主要为这篇文章的摘要笔记,基本省去了看代码的环节,详情可参考这篇好文 -> React技术揭秘(文章内容已经比我几个月前看要详细不少了))

React的设计理念

七个字就能概括,速度快,响应自然。当然如果从数学的角度来看,也就是一个y=f(x)的对应关系。视图y根据状态x的变化而变化。从jQuery直接操控DOM的方式解放出来。 个人理解,其实说到底React所做的就是完成y=f(x)中f的变化映射关系。也就是把想要的视图和所拿到的数据进行连接的工作。

React架构的演变历程 —— V15之前

在ReactV15版本重构之前的架构可以一览如下。

Reconciler(协调器)-> 找出变化的组件 Renderer(渲染器)-> 将变化的组件渲染到页面上

流程为: 用户出发update => Reconciler => Renderer

接下来,就来详细看一下这两个组件干了些什么,以及为什么会被重构的原因。

构成

  1. Reconciler: 当有更新发生时(this.setState、forceUpdate、render等可出发),这个部分组件完成以下工作: a. 调用函数组件,或class的render方法,将返回的JSX内容转化为虚拟DOM结构。 b. 对比上一次生成的DOM结构 c. 找出要被更新的DOM结构 d. 通知renderer组件更新到页面上。

  2. Renderer: 这部分其实根据React运行环境的不同,React将其拆分出了几个部分。如果宿主为浏览器,那么进行这部分工作的为ReactDOM,App原生组件的话是ReactNative,如果为测试环境或者canvas环境也有着各自的渲染组件。这些渲染组件的存在,使得React可以用同一套理念进行不一样端的渲染,减少开发的工作量。

这个部分的作用其实就一句话,接收上一层Reconciler的通知,把视图更新到宿主环境即可。

此架构的缺点

Reconciler中,被mount的组件会调用mountComponent、被Update的组件会调用updateComponent,这样产生的后果就是会递归更新子组件。递归更新子组件的话,因为大多显示器的展示帧率为60Hz,且JS的计算运行线层与渲染视图是互斥的,那么递归深度过沈的话,就会导致部分UI的更新不完全,产生业务上不必要的困扰。

那么新的架构是如何解决这个问题的呢?

React架构的演变历程 —— V16及之后

新的架构引入了一个新的组件,称之为Scheduler(调度器),顾名思义,这个是用来调度任务的优先级的组件。

其余的两个部分还是和之前的一样,是Reconciler和Renderer。

构成

那么主要来看一下这三个部分分别做了些什么事情。 首先是新引入的Scheduler组件。

  1. Scheduler: 主要任务就是判断当前更新工作的优先级,进行调度。 部分浏览器已经实现了一种名为requestIdleCallback的机制,来让我们可以从浏览器知道是否有剩余时间作为任务中断的标准。但是,虽然部分浏览器已经有这个回调函数功能,但是因为兼容性以及触发概率不稳定等原因,React并没有采取这个,而是亲自实现了这个可以独立于整个React框架使用的Scheduler库。

  2. Reconciler 协调器当然也有变化。根据之前版本的痛点,把更新的过程,从递归变化了可中断的循环过程。每次循环都会以调用shouldYield作为标准来判断当前是否有剩余时间。

那么React16是如何解决中断更新时DOM渲染不完全的问题呢? 在React16中,ReconcilerRenderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增_删_更新的标记。只有当所有组件都完成了Reconciler,才会将其交给下一步的render。所以,如果上一个渲染被高优先级任务打断了,那么这个被打断的流程也不会走到render上,让用户看到错误的数据。

这个部分中采用了Fiber架构

  1. Renderer 这个部分并没有什么过多的变化,同样是根据上一步给出的虚拟DOM标记,同步执行用户界面的渲染工作。

Fiber

Fiber何许人也?也就是React16之后内部的虚拟DOM结构。 为什么要有Fiber? 因为之前的虚拟DOM结构适用于递归寻找更新,由于递归有可能很深,造成用户界面卡顿,所以转化了为了循环。为了适应这个可中断循环,那么就要对内部的虚拟DOM结构进行改造。

  1. 作为静态的数据结构来说,每个Fiber节点对应一个React element,保存了该组件的类型(函数组件_类组件_原生组件…)、对应的DOM节点等信息。

  2. 作为动态的工作单元来说,每个Fiber节点保存了本次更新中该组件改变的状态、要执行的工作(需要被删除_被插入页面中_被更新…)。

Fiber树的双缓存

双缓存? 在内存中构建并直接替换的技术叫做 双缓存 (opens new window) 。React使用“双缓存”来完成Fiber树的构建与替换——对应着DOM树的创建与更新。 React在构建中最多同时有两棵Fiber树,一个对应着当前的视图结构(current Fiber),一个对应着之后将要变化到的结构,也就是正在构建中的(workInProgress Fiber)。 每次状态更新都会产生新的workInProgress Fiber树 ,通过current 与workInProgress的替换,完成DOM更新。

Fiber树的构建替换流程

这里考虑的是这个简单的React组件。

1
2
3
4
5
6
7
8
9
10
function App() {
  const [num, add] = useState(0);
  return (
    <p onClick={() => add(num + 1)}>{num}</p>
  )
}

ReactDOM.render(<App/>, document.getElementById('root'));

Render时

  1. 首次执行ReactDOM.render时,会创建一个fiberRootNode和rootFiber。其中前者是整个应用的根节点而rootFiber是所在组件树的根节点。 Why区分?因为应用中可以多次调用ReactDOM.render来渲染不同的组件树,但是整个应用的根节点只会有一个。

那么fiberRootNode的current指针就会指向当前已经渲染内容的Fiber树,这个就被称为current Fiber树。

  1. 接下来进入render阶段。根据组件返回的JSX在内存中创建Fiber树,并且构建成workInProgress树,这个树会尝试复用current中的Fiber元素,作为alternate的智者。

  2. 构建完成的workInProgress Fiber树将会在commit阶段渲染到页面中。

Update时

  1. 用户触发了改变,这样会启动一个render阶段并构建新的workInProgress树。和amount时候一样,这个fiber的创建可以复用数据,也就是alternate的节点。 (判断是否可以复用,就要用到之后的diff算法)

  2. workInProgress树在render阶段完成以后在commit之后渲染到页面上,同时变为current树。

大致的流程就是如此,那么每个Fiber节点是如何创建的呢?请继续往下。

React构建Fiber树(render阶段)

React构建Fiber树是通过深度优先遍历进行的。主要的流程是: beginWork => reconcileChildren => completeWork (根据深度优先遍历进行迭代,直至最终结束)

beginWork

该方法根据传入的Fiber节点创建子节点,并将这两个节点连接起来。这个方法的三个参数为:current,workInProgress以及renderLanes。作用分别为对应节点上一渲染的结果,当前组件对应的Fiber节点以及优先级相关内容。 并且可以通过current是否为null来进行区分是mount阶段还是update阶段。

这两者的区别在于,如果是update,那么就可以尝试复用current来优化,克隆current.child作为workInProgress.child,而不需要重新创建。而mount时候则根据不同的fiber.tag来创建不一样的子fiber节点。

update时候有两种情况可以直接使用:

  1. oldProps === newProps && workInProgress.type === current.type, 即props与fiber.type 不变
  2. !includesSomeLane(renderLanes, updateLanes) ,即当前Fiber节点优先级不够。

mount时,也就是当不满足优化路径是,则要新建子Fiber(fiber.stateNode === nul,且接下来的步骤中不会赋值effectTag,而根节点拥有)。

根据不一样的Fiber类型(FunctionComponent,ClassComponent, HostComponent),进入到最核心的reconcileChildren方法。

reconcileChildren

对于mount来的,创建新子Fiber节点。 对于update来的,进行diff算法,对应比较上次的Fiber和这次的更新,将比较的结果生成Fiber子节点。 无论是哪一种方式,都会生成新的子节点作为本次beginWork的返回值,作为下次workInProgress的传参。

通知Renderer进行更新的两个条件:

  1. fiber.stateNode存在。
  2. fiber.effectTag存在。 何为effectTag? effectTag就是用来告诉renderer执行DOM操作的具体类型。利用二进制数据表示。 那么既然mount阶段没有stateNode和effectTag,首屏渲染的完成是如何完成的呢?其中fiber.stateNode将会在之后的completeWork中创建。而后一个effectTag的问题,mount时候只有第一个root结点会有effectTag,那么整个树只要在执行一次插入的DOM操作即可。

completeWork

这个部分接收三个参数,和beginWork同样,并且针对不同类型的fiber会有这不同的处理逻辑。

这里的主要工作分别是处理一些props内容,并且对已经存在Fiber结点进行一些更新处理或者回调函数的注册。

effectList

其实整个render阶段的工作已经接近完成了。那么在之后commit阶段如何避免再次对Fiber树进行递归寻找effectTag不为null的节点呢? 这里,React在completeWork的上层方法completeUnitOfWork中,每个执行完成的completeWork且存在effectTag的Fiber节点会被保存在effectList的单向链表中。那么之后的阶段只要对这个链表进行遍历就可以执行完所有的effect了。

借用React团队成员Dan Abramov的话:effectList相较于Fiber树,就像圣诞树上挂的那一串彩灯。

React进行渲染(commit阶段)

commit阶段的起点,就是调用commitRoot方法,并且传参root这个结点。 这个里面包含了rootFiber.firstEffect,也就是上面提到的单向链表的起点。

在commit阶段做的工作是副作用对应的DOM操作,部分DId系列生命周期钩子以及hook的useEffect都需要在这个阶段进行执行。

那么整个commit阶段分为三个部分,分别是before mutation(执行DOM操作前)、mutation(执行DOM操作)、layout(执行DOM操作之后)

在before之前和layout之后其实还有一些例如useEffect的出发,优先级相关的充值以及ref的绑定等。

before mutation之前

主要完成一些变量赋值,状态重置的工作。主要还是最后的firstEffect的赋值。

layout之后

包含三点内容: useEffect的处理、性能追踪相关以及生命周期钩子函数的触发。

before mutation

概述就是遍历effectList,并调用commitBefore MutationEffects方法进行处理。 这个方法大致可以分为三个部分:

  1. 处理DOM节点渲染、删除之后的autoFocus和blur逻辑
  2. 调用getSnapshotBeforeUpdate生命周期钩子 这里提出的问题,为什么在重构之后,will系列的生命周期加上了UNSAFE前缀。主要原因就是前一个render阶段可能会中断或者重新开始,那么在render阶段调用的will系列生命周期钩子就有可能触发多次。为了解决这个问题,React给出了getSnapshotBeforeUpdate。在commit阶段进行调用因为是同步的就可以避免多次调用的问题。
  3. 调度useEffect(流程有点复杂,可以参考原文 -> React技术揭秘) useEffect的调用是异步的。 为什么要异步?

    与 componentDidMount、componentDidUpdate 不同的是,在浏览器完成布局与绘制之后,传给 useEffect 的函数会延迟调用。这使得它适用于许多常见的副作用场景,比如设置订阅和事件处理等情况,因此不应在函数中执行阻塞浏览器更新屏幕的操作。

可见,useEffect异步执行的原因主要是防止同步执行时阻塞浏览器渲染。

mutation

同样是遍历effectList链表,并执行commitMutationEffects方法,主要进行了三个操作:

  1. 根据ContentReset effectTag重置文字结点
  2. 更新ref
  3. 根据effectTag分别处理(DOM的增删改等操作)

effectTag的处理主要是以下几种

  1. Placement类型: 该Fiber结点对应的DOM结点,需要插入到页面中,首先获取父级DOM结点,其次获取兄弟DOM结点,最后根据DOM兄弟结点是否存在来决定是否调用parentNode insertBefore或者parentNode.appendChild来进行DOM的插入操作。
  2. update类型: 说明该Fiber结点需要更新。根据fiber.tag分别处理。 Function的话调用useLayoutEffect这个hook的销毁函数(return出来的函数)。 Host类型的话就调用commitUpdate
  3. deletion类型:需要从DOM中删除,调用的方法是commitDeletion。 做的操作是递归调用Fiber结点和子孙结点中fiber.tag为ClassComponent的componentWillUnmount生命周期,并且从页面中移除DOM,其次解绑ref,最后调用useEffect的销毁函数。

layout阶段

该阶段触发的生命周期函数以及hook已经可以访问到改变之后的DOM了。 layout阶段同样需要遍历effectList,这里执行的就是commitLayoutEffect方法了。主要做了两件事

  1. commitLayoutEffectOnFiber(调用生命周期钩子和hook的相关操作) 对于Class组件,会通过current是否为null来决定调用DIdMount或者是DidUpdate. setState的第二个回调函数参数,也会在这里被调用(所以里面拿到的state数据已经是新的了) 对于Function的组件,会调用useLayoutEffect的回调函数,调度useEffect的销毁与回调函数,useEffect将会在layout结束以后异步执行。 对于HostRoot,如果有第三个回调函数,也会在这时候被执行。
  2. commitAttachRef(赋值ref) 这个很简单,获取DOM实例,并且绑定到ref上

最后,双缓存机制需要把结束的工作挂载到current上。

而root.current = finishedWork 这行代码是在mutation结束后,layout前执行的。 Why? 我们知道componentWillUnmount会在mutation阶段执行。此时current Fiber树还指向前一次更新的Fiber树 ,在生命周期钩子内获取的DOM还是更新前的。 componentDidMount和componentDidUpdate会在layout阶段执行。此时 current Fiber树已经指向更新后的Fiber树,在生命周期钩子内获取的 DOM## 就是更新后的。

最后的总结,其实commit的三个阶段就是在三次遍历上一个render阶段生成的effectList,并且根据这个list来对DOM进行操作,将数据驱动到视图上。这个effectList的生成,其实规避了commit阶段三次对Fiber树的遍历,优化了性能。

DIff算法

这里只简单概述。 这里需要完成的内容就是DOM结点是否可以复用的问题。 根据Fiber的children的类型不同可以分为单节点diff和多节点diff 主要的流程: 1 看key是否相同 2 key不同,直接不复用。 3 key相同,如果type相同那么复用,如果type不同,那么久不复用。

状态更新

可以出发React状态更新的主要有几种方法:ReactDOM.render、this.setState、this.forceUpdate、useState以及useReducer. React通过创建一个Update对象,在render阶段的beginWork中根据这个对象来计算新的state。

关键节点: 触发状态更新 -> 创建Update对象 -> 从Fiber到root(从触发状态更新的fiber一直向上遍历到rootFiber,并返回rootFiber。) -> 调度更新(Scheduler的工作) -> render阶段 -> commit阶段。

关于优先级:(具体:React技术揭秘

  • 生命周期方法:同步执行。
  • 受控的用户输入:比如输入框内输入文字,同步执行。
  • 交互事件:比如动画,高优先级执行。
  • 其他:比如数据请求,低优先级执行。
This post is licensed under CC BY 4.0 by the author.