前言
2023年8月,毕业2年后被裁员失业,只能说塞翁失马,焉知非福。
在21年毕业时,React Hook 已经替代了 Class 组件成为官方推崇的主流,平时上班一直想抽空学习 React 的源码,但是零散时间根本没法集中。趁着去年8月失业的这段时间,集中最近看了卡颂老师的React 技术揭秘,本文记录了学习过程中对 React 源码和架构的理解,文中有对原文的直接或间接引用。
2024年1月25日再一次被裁员而失业,但需温故知新,重新出发。
React架构
React 15 架构
分为两层:
- Reconciler(协调器):负责找出变化的组件
- Renderer(渲染器) :负责将变化的组件渲染到页面上
Reconciler 协调器
对触发更新进行一系列操作:
- 调用函数组件、类组件的render方法,将返回的JSX借助babel转换成js然后递归创建虚拟DOM
- 将虚拟DOM和上次更新时的虚拟DOM对比
- 通过对比找出本次更新中变化的虚拟DOM
- 通知Renderer将变化的虚拟DOM渲染到页面上
Render 渲染器
接受 Reconciler 通知的更新,重新渲染发生变化的组件
React 15 架构产生的问题
在 React 15 的版本中,协调器和渲染器交替执行,即找到了差异就直接更新差异,让后将多个差异进行批处理
协调器使用递归进行DOM树的差异对比,当DOM树一定深度或者页面元素较多时,整个递归更新时间超过了1帧的生成时间16.6ms,如果页面存在交互或者动画,那么就会产生卡顿。
React 16 架构
分为三层:
调度层:调度任务的优先级,将任务细分优先级
协调层:构建Fiber 数据结构,通过 Fiber 对象对比差异,记录产生的差异对 DOM 的操作
渲染层:负责将更改内容渲染到页面上,也就是 VDOM 到 DOM
浏览器空闲时间
这里指的浏览器空闲时间是在现代浏览器以60帧每秒的屏幕刷新率下,每一帧的生成时间16.6ms中,主线程执行完任务的空闲时间,React 16 正是利用了这部分时间,将不同优先级的任务分配到这些有限的时间片中,来避免对主线程产生长时间的占用。
Scheduler 调度层
React 15 没有调度层,使用了 js 自身的递归去遍历 虚拟DOM,js的递归一旦开始就无法暂停,如果当虚拟DOM树过于庞大,或者说层次比较深,那么就会长时间占用js主线程,影响页面的交互,和动画。
React 16 于2016年开发,17年下半年发布,放弃了 React 15 的递归 VDOM 对比,使用循环来模拟递归,对比过程使用浏览器的空闲时间完成,不会长期占用js主线程,避免了对比 VDOM 时对页面造成的卡顿
MDN:
requestIdleCallback 是一个用于在浏览器空闲时执行任务的 API,它允许开发者在不影响用户体验的情况下执行一些较为耗时的任务。这个 API 最早由 Google Chrome 团队提出,并于2015年在 Chrome 浏览器中首次实现。由于不是每个浏览器都支持requestIdleCallback,React最后没有采用该api
React 16 中使用的是React 团队自己实现的任务调度库package/scheduler,可以实现在浏览器空闲时执行任务,而且还可以设置任务的优先级,高优先级任务先执行,低优先级任务后执行。
使用循环模拟可中断任务实现的伪代码:
1 | let nextUnitOfWork = null |
Reconciler 协调层 (负责计算更新)
React 15 中 的协调器绑定了渲染器,只要协调器找到了差异就立即调用渲染器更新页面,寻找差异和渲染是交叉进行的,也就是说渲染完上一个差异后,再寻找下一个差异。
但是在React 16 中,将协调器reconciler和渲染器render 分开了,使用了一个新的Fiber 数据结构 对象代替了 React 15 的 虚拟DOM,并且 Fiber 配合协同 scheduler ,可以将 render 阶段的任务拆分。
协调器会找出所有差异后,再将差异统一交给渲染器进行更新,协调器的任务就是将差异部分Fiber打上标记
所以说,从React15到React16,协调器(Reconciler)重构的一大目的是:将老的同步更新的架构变为异步可中断更新。
Render 渲染器
根据 Fiber 节点的标记,同步更新对应的DOM
Render在执行渲染操作的过程中被设定为不可被打断,而 scheduler 和 reconciler 的任务设定为可以被打断的
代数效应(Algebraic Effects)
React核心团队成员Sebastian Markbåge(opens new window)(React Hooks的发明者)曾说:我们在React中做的就是践行代数效应(Algebraic Effects)。
代数效应是函数式编程中的一个概念,用于将副作用从函数调用中分离。
函数式编程与副作用
在函数式编程中,强调的是函数的”纯粹性”(purity),纯粹的函数不会产生副作用,其输出只依赖于输入,不会影响外部状态。这种纯粹性使得函数更易于测试、推理和组合。
副作用是指函数的执行会对函数程序外部的状态或其他部分产生可观察的影响,这些影响不仅仅体现在函数的返回值上。这种影响可以包括但不限于:
- 修改外部状态: 如果函数修改了外部变量、数据结构或状态,这被认为是副作用。例如,修改全局变量、数组或对象的值。
- I/O 操作: 与外部世界的交互,如读取或写入文件、发送或接收网络请求、数据库查询等,都会引发副作用,因为它们会影响系统状态。
- 界面交互: 当函数导致了用户界面上的可见变化,例如弹出对话框、更新屏幕上的内容或改变网页中的DOM结构,这也被认为是副作用。
代数效应思想
由于js没有原生实现代数效应的机制,代数效应的思想可以用类似于 try catch 的模式进行虚构一个语法
平时我们编写业务时需要在同步函数里面依次使用请求获取一些值,这些都是异步操作例如:
1 | function getTotalPicNum(user1, user2) { |
假如getPicNum
是一个异步请求,如果我们需要通过这个异步请求获取器结果,我们通常需要使用async/await
1 | async function getTotalPicNum(user1, user2) { |
虽然可以解决问题,但是async改变了getTotalPicNum作为同步函数的调用方法,一般来说async function 具有传染性,会使其调用函数也变为async,会破坏本应该为同步函数的特性
那么有没有一种办法能保持getTotalPicNum 的同步特征呢?js没有提供,但是我们可以使用代数效应思想结合try/catch 虚构一个 语法 try/handle 关键字preform/resume
传统的try/catch在 try 代码块抛出异常时,那么就会终止执行代码块,转去执行 catch 代码块,错误之后的代码无法被执行,而try/handle 则不同。
1 | function getPicNum(name) { |
在上述语法中,当遇到perform 关键字时,就会携带参数转去handle 代码块中执行分支代码,最终使用resume 将结果返回给 perform 左边的变量,而函数getPicNum getTotalPicNum的类型也都未改变,这样分离函数的副作用到handle中,避免了对原函数的影响,实现了关注点分离的效果。
React中的代数效应
React Hooks 是对 代数效应的一种实践。
Hooks为React 函数组件带来了生命周期和状态管理,并且,则不需要改变函数组件的性质(例如前面类似于加个hook function之类的),函数组件内部使用 useState useEffect 等,开发者不需要关注其中的实现,React 会为我们进行处理,开发者的关注点是这个hooks api 给我带来了状态管理和生命周期,我只需要使用他们来编写组件就好了
代数效应和生成器Generator
从React15到React16,协调器(Reconciler)重构的一大目的是:将老的同步更新的架构变为异步可中断更新。
异步可中断更新可以理解为:更新在执行过程中可能会被打断(浏览器时间分片用尽或有更高优任务插队),当可以继续执行时恢复之前执行的中间状态。
浏览器原生支持类似异步中断更新的实现,也就是生成器函数
生成器函数Generator
返回一个迭代器,通过调用迭代器来惰性执行函数体,其中函数体内使用yield关键字进行分步,Generator 是 js 对协程的一种实现
但是 React 团队因为生成器函数的一些缺陷放弃了使用他实现 协调器Reconciler:
- 生成器函数会改变函数调用的方式,也就是说具有传染性,其返回的是一个迭代器,要通过context.next()的方式进行调用。
- 生成器函数在调用的过程中具有中间状态,并且与上下文关联,也就是中间状态在生成器函数的作用域中
例如Hooks 发明者 sebmarkbage 回答的issue :
1 | function* doWork(a, b, c) { |
每当浏览器有空闲时间都会依次执行其中一个doExpensiveWork
,当时间用尽则会中断,当再次恢复时会从中断位置继续执行。
只考虑“单一优先级任务的中断与继续”情况下生成器函数可以很好的实现异步可中断更新。
但是当我们考虑“高优先级任务插队”的情况,如果此时已经完成doExpensiveWorkA与doExpensiveWorkB计算出x与y。此时B组件接收到一个高优更新,我们要重新计算 var y = x + doExpensiveWorkB(b);
由于每一次更新任务队列,都需要重新创建一个生成器的上下文,而我们想复用的x
又是在之前的生成器上下文中,那么就无法重用x
的值了
Fiber
Fiber 是 React 16中 的一个数据结构,用于 reconciler 中对比差异,Fiber这个词在计算机科学中不是什么新名词,中文名为纤程,Wiki 中描述到:
在计算机科学中,纤程(英语:Fiber)是一种最轻量化的线程(lightweight threads)。它是一种用户态线程(user thread),让应用程式可以独立决定自己的线程要如何运作。作业系统内核不能看见它,也不会为它进行排程。
在 React 的官方文档中有这样的描述:
“fiber” reconciler 是一个新尝试,致力于解决 stack reconciler 中固有的问题,同时解决一些历史遗留问题。Fiber 从 React 16 开始变成了默认的 reconciler。
它的主要目标是:
- 能够把可中断的任务切片处理。
- 能够调整优先级,重置并复用任务。
- 能够在父元素与子元素之间交错处理,以支持 React 中的布局。
- 能够在 render() 中返回多个元素。
- 更好地支持错误边界。
可以理解为 Fiber 是 React 自己实现的一套机制,支持任务不同优先级,可中断与恢复,并且恢复后可以复用之前的中间状态。
React Fiber 节点
Fiber包含三层含义:
- 作为架构来说,之前React15的Reconciler采用递归的方式执行,数据保存在递归调用栈中,所以被称为stack Reconciler。React16的Reconciler基于Fiber节点实现,被称为Fiber Reconciler。
- 作为静态的数据结构来说,每个Fiber节点对应一个React element,保存了该组件的类型(函数组件/类组件/原生组件…)、对应的DOM节点等信息。
- 作为动态的工作单元来说,每个Fiber节点保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新…)。
react 16 使用 Fiber 树代替了 虚拟DOM树,每个fiber 节点中有许多与 dom fiber 更新,副作用 等相关的属性
- 与DOM实例相关:
- tag: 代表Fiber 所部表示的React 元素的类型,以number表示
- type: 保存组件的基础类型,或者构造函数、类
- stateNode: 当前fiber 对应的 原生DOM 的实例
- 与构建 Fiber 树相关的
- return: 指向该fiber 节点的 父级fiber 节点的指针
- child: 指向当前fiber的第一个子fiber
- sibling: 指向当前fiber 的下一个兄弟节点
- index: fiber索引
- alternate: 与 workInProgress 树中 的fiber 节点互相指向,
- 组件状态 props相关的
- pendingProps: fiber即将更新的props
- memoizedProps:上一次的 props
- memoizedState: 上一次的 state
- 副作用相关属性
- updateQueue: 当前 fiber 的 state 的更新操作 和 回调函数 的队列
- flags: 当前 fiber 需要执行的dom 操作
- subtreeFlags: 记录当前 fiber节点的子树 要执行的 DOM 操作
- deletions: 记录需要删除的子fiber
- nextEffect: 下一个需要执行副作用的 兄弟 fiber 节点
- firstEffect、lastEffect: 记录当前 fiber 节点的 子fiber 中,第一个和最后一个需要执行副作用的子fiber节点
- mode:更新页面的渲染模式,不同组件可能使用不同模式
Fiber 树结构
Fiber 树是一个 单链表树结构(Singly Linked List Tree Structure)
例如下面的组件结构:
1 | function App() { |
对应的 Fiber 树为:
从fiber 树的 结构以及 更新的方式来看,fiber之间使用的链表结构来进行收集更新,也就是为什么能在判断中使用React hooks
深入理解JSX
JSX
JSX是 一种 声明式的语法,用来描述React 的组件内容结构
JSX代码会在编译时会被Babel编译为React.createElement方法。所以在每一个JSX文件中需要导入React
在React 17 中,已经不需要编写 React 导入,babel与React 团队合作,将jsx文件默认导入了一个_jsx
函数用于转换JSX代码
React Element 和 React Component 与 JSX 的 关系
React.createElement
1 | export function createElement(type, config, children) { |
通过 type config children 创建一个 描述 DOM 的 React Element
- 从 config 中分离内部预留属性:
key ref self source
- 初始化 props,如果type是组件,那么将type.defaultProps与props 合并
- 处理 children, createElement 支持在从children参数后传入多个children,
- 如果只有一个children,那么props.children = children
- 如果有多个children,那么props.children = childArray
- 将处理好的几个数据交给 ReactElement 函数生成 element 对象
- 如果是开发环境,通过 defineProperty 为 props上的key 和 ref 设置 一个getter 函数, 在开发者在组件内部访问props.key 或者 props.ref 时给予使用错误,
React.ReactElement
1 | const ReactElement = function(type, key, ref, self, source, owner, props) { |
创建 react element 对象的工厂方法,其作用就是将传递的参数type,props,key,ref,owner,self
放入js对象中,再使用$$typeof
将这个js对象标记为 React ElementREACT_ELEMENT_TYPE
类型(Symbol),然后返回该对象
React Component
React Component 指的是我们在编写时,使用class 或者 function 声明的 React 组件,这是一个抽象的组件概念,其背后都会生成对应的 Element 对象,由于class 只是 es6 构建对象的语法糖,所以使用 instanceof无法区分class 组件 和 function 组件,React 通过 在编写 class 组件时 组件继承的 ClassComponent 实例的原型上的isReactComponent
来标记class 组件
1 | ClassComponent.prototype.isReactComponent = {}; |
JSX 与 Fiber 的关系
JSX是一种描述组件内容的类似HTML的语法,目的是生成React Element数据结构
React Element 用来提供在 Reconciler 中创建 对应 fiber 节点的一些基本DOM信息,例如 type,key,props 等
双缓存
React 16后 的 DOM更新使用的双缓存,也就是在当前已有的计算结果下,在内存中保存下一次更新的结果,从而达到快速更新的目的
React 16+ 使用 双缓存完成FIber树的构建以及DOM的快速更新,在React 运行时会同时存在 2最多 个Fiber 树结构,
第一个是current fiber tree ,也就是记录了当前页面展现的内容的树,当发生更新时,会重新构建一颗 WorkingInProgress fiber tree,其中记录即将要渲染到页面的内容,当WorkingInProgress 构建完成之后,会直接在内存中替换 current fiber tree,在替换之前,current fiber tree 上的每个节点内的 alternate 属性都指向 WorkingInProgress fiber tree 中对应的节点,同理 WorkingInProgress fiber tree 中的每个节点的 alternate 也指向 current fiber tree 的对应节点
React 初始构建时,会将root fiber作为 current fiber tree ,且只有一个fiber 节点,然后复制一份 root fiber ,让 current fiber tree的 根 fiber 中alternate 指向 复制后的 new root fiber 节点,让 new root fiber 作为 WorkingInProgress fiber tree 的根节点,然后在WorkingInProgress fiber tree中构建子fiber树,更新完毕之后,将 current fiber tree 替换为 WorkingInProgress fiber tree,WorkingInProgress fiber tree 就 成为 了current fiber tree
1 | currentFiber.alternate === workInProgressFiber; |
在每个fiber 中 都存储了 DOM ,fiber 完成构建后,所有 DOM 也都被创建(更新)了
fiberRoot 和 rootFiber 的区别
fiberRoot 指的是Fiber 数据结构的最外层对象,可以看做是 Fiber树的控制器,记录当前fiber树的信息信息等数据
rootFiber 指的是 fiber 树的根节点,对应的是组件挂载时对应的 root div
fiberRoot 可以 包含多个 rootFiber,因为可能会有多个 render 的 root
rootFIber 中的stateNode 指向 fiberRoot, fiberRoot中 的current 指向 rootFiber
React 协调过程
React 的更新分为2个阶段: render 、commit
- render 阶段:
- 此阶段的任务随时可以被打断
- 此阶段分为两个子阶段
- 首次渲染阶段 (mount)
- 更新阶段 (update)
render 阶段的流程大致为:
- 先创建 fiberRoot 和 rootFiber ,
- 根据 React Element 树结构为每一个 react 元素构建 fiber 对象,并创建相应的DOM ,
- 构建 WorkingInProgress fiber tree ,为需要更新的fiber的标注DOM操作类型flag(初始化渲染只有rootFiber 被标注)
- commit阶段:
- 该阶段不可被打断
- 获取WIP fiber tree, 根据每个Fiber节点的Tag 进行不同类型的DOM操作,也就是渲染器执行阶段,
Render 阶段
render 会先判断 container 是否为DOM,然后将参数交给**legacyRenderSubtreeIntoContainer**
函数
render 接受3个参数:
- element:React.createElement 返回的 React 元素js对象
- container:DOM 元素
- callback: 完成渲染后的回调函数
1 | // ReactDOM.render() 渲染方法 |
创建 Fiber 数据结构, 创建 fiberRoot 和 rootFiber
legacyRenderSubtreeIntoContainr 函数将子树渲染到容器中,开始创建fiberRoot,此函数接受5个参数:
- parentComponent:父 react 组件
- children:子节点列表
- container:要渲染子树的容器
- forceHydrate:是否启用服务器渲染
- callback: 渲染完成后的回调
在初始渲染时,只需传入element 到 children ,container , callback 三个参数
- 访问
container._reactRootContainer
判断该 container 是执行初始化渲染,还是更新渲染 - 初始渲染下,使用 DOM 元素 container,调用
legacyCreateRootFromDOMContainer(container)
创建fiberRoot
和rootFIber
- 此时 上面说到的current fiber tree 已经构建完成
- 处理 callback 的 this 指向,指向 root DOM的实例,函数组件作为 root DOM 时初始化渲染时没有实例的
- 调用
updateContainer
使用非批量更新,防止初始化渲染时,更新被打断 - 返回
ReactDOM.render
中第一个参数的 DOM 实例对象,如果是 React 组件 那么返回 null ,在初始化时,这里返回null
1 | function legacyRenderSubtreeIntoContainer( |
进入 legacyCreateRootFromDOMContainer(container,forceHydrate),清空root DOM 内容
1 | function legacyCreateRootFromDOMContainer( |
- 先判断是否服务器渲染,在服务器渲染下,不会清空节点内容
- 不是服务器渲染,那么使用循环从DOM内部的最后一个子元素开始删除所有内部DOM元素
- 返回
createLegacyRoot(container, shouldHydrate)
调用后的结果,由于后续均为非服务器渲染,这里先忽略服务器渲染的参数
- 问题:为什么要先清除DOM中的内容?
- React 需要将DOM 中的内容进行全面托管,避免了不受控制的DOM掺杂进去影响页面
- 避免React渲染结果与现有的元素冲突
- 清除无用的事件监听,清除DOM,那么也就清除了监听事件
所以说,清除 DOM 内容是为了确保 React 在开始渲染之前处于一个干净、一致且可控制的状态,以便它可以有效地构建和管理整个应用的渲染。
进入 createLegacyRoot(container, options),创建 fiberRoot 的外层对象 rootType
1 | export type RootType = { |
- 此函数中,直接返回了
ReactDOMBlockingRoot(container, LagacyRoot, options)
实例化的js对象,类型为 RootType,其中第二个参数 LegacyRoot 是一个值为 0 的类型常量, - 此处的 LegacyRoot 其实代表的是使用 ReactDOM.render 渲染的 root
- React 每个版本都在添加一些实验性的功能来改变渲染的方式:
1 | // react v17.0.2 |
进入 new ReactDOMBlockingRoot(container, LegacyRoot, options),创建一个 fiberRoot实例
1 | function ReactDOMBlockingRoot( |
ReactDOMBlockingRoot 其实就是返回了一个只有一个_internalRoot
属性的对象,this._internalRoot
使用createRootImpl(container, tag, options)
创建的
进入 createRootImpl(container, tag, options)
这个方法内主要是 使用createContainer(container, tag, hydrate, hydrationCallbacks)
创建fiberRoot对象,并且标记DOM 为fiber的根节点,然后再使用事件委托接管DOM上的所有事件,最后返回fiberRoot
1 | // createRootImpl 函数创建 fiberRoot 后 并委托 root DOM上的所有事件,返回 fiberRoot 对象 |
进入 createContainer(container, tag, hydrate, hydrationCallbacks)
createContainer
其实内部就返回了createFiberRoot(containerInfo, tag, hydrate, hydrationCallbacks);
的返回结构,真是套娃😓,这下createFiberRoot
总算进入创建 fiberRoot 阶段了吧
1 | export type FiberRoot = { |
进入 createFiberRoot(containerInfo, tag, hydrate, hydrationCallbacks)
createFiberRoot 内,首先通过 构造函数FiberRootNode
实例化 fiberRoot 对象
然后再使用createHostRootFiber(tag) 创建 一个未初始化的fiber节点uninitializedFiber,此fiber节点就是rootFIber
将fiberRoot.current -> rootFIber rootFiber.stateNode -> fiberRoot
最后再初始化rootFIber中的更新队列updateQueue对象,第一个更新任务是空更新
1 | // 赋值先创建 fiberRoot 然后再创建 rootFiber ,初始化 rootFiber.updateQueue |
进入 new FiberRootNode(containerInfo, tag, hydrate), 创建F
FiberRootNode
是fiberRoot对象的构造函数,初始化了一堆默认的属性
1 | // FiberRoot 构造函数 |
进入 createHostRootFiber(tag)
createHostRootFiber主要是创建 fiber的根 rootFiber,此时会通过tag的类型来决定渲染模式mod,使用ReactDOM.render
渲染下,mode 为 同步模式
1 | export type TypeOfMode = number; |
这里的 mode 使用 按位或|
来巧妙的将不同模式进行归类或者说是聚合,这样后续就可以将mode使用按位与&
运算来将结果和目标模式进行对比,以此判断mod中是否包含该目标模式,例如:
1 | const mode = 0b1100; // 二进制表示的 mode 变量,假设是 0b1100 |
进入 createFiber(HostRoot, null, null, mode),创建rootFiber
HostRoot表示该fiber节点是一个根Fiber,mode指明该fiber 是什么渲染模式,这里使用的是noMode,即同步渲染
在其中返回了一个FiberNode
函数的实例化对象 类型为 Fiber
, 初始化的 props
和 key
都为空
1 | const createFiber = function( |
进入 new FiberNode(tag, pendingProps, key, mode),实例化 rootFiber
1 | // Fiber对象实例构造函数, |
更新阶段 fiberRoot 以及 rootFiber
接着调用updateContainer
会正式进入 render 阶段,遍历生成 WIP fiber 树,React 16 + 使用了可中断的循环来模拟递归unbatchedUpdates
指的是不进行批量更新,首屏渲染需要尽快将画面呈现在页面上
1 | // Initial mount should not be batched. |
workLoop 模拟可中断递归
生成WIP fiber 树主要开始于performSyncWorkOnRoot
或performConcurrentWorkOnRoot
方法的调用,取决于本次更新是同步还是异步可中断更新(初始化的时候调用的是同步更新)
1 | // performSyncWorkOnRoot会调用该方法 |
在workLoopConcurrent
中新增了一个shouldYield
函数,shouldYield
可以决定工作循环是否继续执行,如果当前帧没有剩余的线程空闲时间,那么就会等到下一个帧再决定是否继续遍历workInProgress
代表的是当前已经创建的 workInProgress fiber 节点通过循环调用performUnitOfWork
来模拟递归
在 workLoopConcurrent
并发模式下我们创建一个比较大的列表:
1 | function App() { |
这个时候我们打开浏览器的performance调试,就会看到以下的结果:performWorkUntilDeadline
的执行时间基本都是在 5ms 以内
而在当我们在点击一次 ul ,也就是触发一次更新后,疯狂移动鼠标,那么就会发现这样的一种情况
这里的 蓝色 Hit Test 指的是浏览器确定用户的鼠标事件,也就是在处理我们疯狂移动鼠标的过程,
因为在下一帧生成之前 鼠标事件检测占用了太多的 时间,导致只有几个帧的空闲时间给协调器做差异对比,导致后面React 直接决定调用 renderRootSync
来处理剩下的 fiber,再极端一点,如果整个过程中线程空闲时候非常少,那么React 就不会启用 并发渲染模式,而是直接使用 同步渲染
performUnitOfWork 递归创建 fiber 单链树结构
performUnitOfWork
会从 WIP rootFiber
节点开始,参考其对等的 unitOfWork.alternate
进行深度优先遍历,该函数工作会有两个阶段:递 和 归
1 | function performUnitOfWork(unitOfWork: Fiber): void { |
“递”阶段
performUnitOfWork
内会将 current fiber节点(current) 与 当前已创建的WIP fiber 节点(unitOfWork) 交给beginWork,进行深度优先遍历来创建子fiber节点,直到遍历到叶子fiber节点(没有子组件的节点),就进入 “归” 阶段
“归”阶段
归阶段会调用completeUnitOfWork
处理当前 fiber 节点,当前Fiber 节点如果有兄弟节点 unitOfWork.sibilings !== null
那么就会进入到兄弟 fiber 节点的 “递”阶段
如果不存在兄弟 fiber 节点,那么进入unitOfWork.return
的 “归” 阶段,也就是当前 fiber 节点的父节点
最终,递归交替执行,会归到 rootFiber
,render
阶段结束
以上面的 App 结构为例:
1 | function App() { |
对应的递归 fiber 树顺序为:
performUnitOfWork总结
现在我们大致了解:
React 16+ 使用 workLoopSync/workLoopConcurrent
配合 performUnitOfWork
以循环的方式深度遍历整个 fiber 树,以此来达到同步、异步可中断的更新
其中workLoopSync
的更新是不可中断的,其更新模式也也叫 Legacy 模式,用于兼容旧的同步更新,而workLoopConcurrent
则为并发模式,其任务可以被打断,避免协调器长时间占用主线程对界面产生卡顿的问题。performUnitOfWork
是用于在循环中通过深度遍历 fiber 单链结构树 来构建 workingInProgress
fiber 树的,实现了树的非递归遍历,因此可以配合workLoopConcurrent
实现将任务分配到时间切片中进行调用
beginWork
beginWork 做的事情,就是创建当前WIP fiber 节点的子节点 workInProgress.child
,相关的代码有 接近 1200 行
1 | function beginWork( |
在首次渲染的时候,除了rootFiber,其余的 WIP fiber 的 current 都为 null ,也就是说,current fiber 树是一颗只有一个节点的树
这里使用 current 是为 null 将 beginWork 工作分为两个部分:
- update:本次是更新,那么满足一定调节可以复用 current
- mount:初始化挂载,那么需要创建每一个 子 fiber
复用 current 一般发生在该节点在 更新时并没有发生任何变化, 这样可以在 beginWork 中优化
在确实需要更新的路径上,通过 fiber 不同的 tag ,以不同的逻辑创建 子 fiber
1 | // mount时:根据tag不同,创建不同的Fiber节点 |
常见的函数组件、类组件的创建子 fiber 逻辑最终都会进入 reconcileChildren
1 | export function reconcileChildren( |
reconcileChildren
实现了 Reconciler 的核心逻辑,而 mountChildFibers
、reconcileChildFibers
都来自于同一个函数 ChildReconciler
,他们逻辑基本一致
1 | export const reconcileChildFibers = ChildReconciler(true); |
函数 reconcileChildFibers
的目的就是通过 diff 算法,生成一个新的 fiber 节点,mountChildFibers
不同的是,生成的 fiber 节点没有标识更新的 flag 属性(之前为effectTag属性)
在 src/react/packages/react-reconciler/src/ReactFiberFlags.js
中可以看到Flag 的 所有类型,都是以二进制表示,以便使用位操作赋值多个 flag effect
并且在mount 的情况下,WIP fiber 树中只有 rootFiber 会有 flag 标签,也就是说 mount 情况下 只有一次对 DOM 的操作
beginWork 阶段做的就是以当前 WIP fiber 节点,区分mount 和 update 状态,创建 或者 复用 current fiber 子节点 来构建 WIP fiber 子树,给需要更新的 current fiber 子节点 对应的 WIP fiber 节点 打上 flag 更新标志,整个过程属于前面 performUnitOfWork 中的 “递” 阶段,直到叶子节点,那么就会调用completeWork
进入 “归” 阶段
completeWork
类似beginWork
,completeWork
也是针对不同 fiber.tag
调用不同的处理逻辑。
1 | function completeWork( |
completeWork 主要对 WIP fiber 做以下操作:
- 如果是挂载(mount)类型
- 调用
createInstance
,创建该 fiber 对应的 离屏DOM(offScreen DOM)节点,然后赋给fiber.stateNode
- 调用
appendAllChildren
,将所有孩子节点都插入到当前的离屏 DOM节点中 - 处理 传递给 子节点的 props 和 事件监听
- 调用
- 如果是更新(update)类型
- diff props,返回一个需要更新props的 数组 like:
[propName1, propValue1, propName2, propsValue2 ]
- 将 props 更新数组放入 updatePayload 赋给 当前 completeWork的 fiber.updateQueue
- diff props,返回一个需要更新props的 数组 like:
- 将有所有 使用 flag标记的 子 fiber 挂载到当前 fiber 的 effectList 末尾,也就是
fiber.firstEffect
和fiber.lastEffect
指向的更新单向链表,这样在 commit 阶段就不需要再遍历一次 WIP fiber 树 来判断每个节点是否有 更新 flag 了,在 commit 阶段,只需要从 rootFiber.firstEffect 开始访问,就可以找到所有需要更新的 fiber 节点
React团队成员Dan Abramov:
effectList
相较于Fiber 树
,就像圣诞树上挂的那一串彩灯。
在 workLoop 完成之后,接下来就进入到 commit 阶段
Commit 阶段
当整个fiberRoot中的rootFiber 通过 模拟递归 完成 WIP fiber 树的创建之后,会调用 commitRoot(fiberRoot)
进入 commit 阶段
commit 阶段的主要工作就是 调用 Renderer ,包括执行链表rootFiber.firstEffect
上的副作用、调用生命周期Hooks等,该阶段分为三个子阶段,分别对应操作DOM前、中、后:
- before mutation:主要处理 useEffect 回调、处理 rootFiber 的副作用
- mutation: 操作DOM
- layout :操作 DOM 之后
在 commit 阶段中的大致流程为:
- 执行或者调度 useEffect 的回调,确保所以 useEffect 的回调都完全被调度,保证他们延迟执行
- 处理 rootFiber 副作用,如果 rootFiber 有副作用,那么添加到自身的 effectList 末尾
- 触发生命周期hook,包括
componentDidXxx
和useLayoutEffect
useEffect
等,如果这些方法中产生了新的副作用,那么就会开启新的一轮 render -> commit 流程
Before Mutation 子阶段
此阶段主要工作是:
- 处理DOM节点渲染/删除后的 autoFocus、blur逻辑
- 调用 getSnapshotBeforeUpdate 钩子函数
- 遍历 effectList ,目的是调度 useEffect 的 callback
为什么要调用 getSnapshotBeforeUpdate?
为了代替 componentWillXXX 的钩子,在react 16 之前,componentWillXXX钩子是在render 阶段调用,由于 之前使用的是不可中断的递归遍历,使得 componentWillXXX 在同步递归中只会执行一次,而 react 16 render 阶段可能被中断、重新开始,使得 componentWillXXX 可能会被调用多次
官方在react 16 中 使用 getSnapshotBeforeUpdate 作为新的替代钩子,由于commit 阶段是同步的,不可被中断,那么 getSnapshotBeforeUpdate 钩子就只会在执行commit 时 执行一次
如何调用/调度 useEffect 的 callback
在React 16 中,before mutation 阶段同步地执行完毕了所有的 callback,而在React 17 中所有的 useEffect 的 callback 都是被异步调用的,也就是延迟到页面渲染之后
1 | // 调度useEffect |
scheduleCallback 是 Scheduler 库提供的,目的是以某个优先级调度callback,flushPassiveEffects 是真正执行 useEffect 方法的函数,也就是说 调用flushPassiveEffects() 会执行 副作用回调函数
effectList中链式保存了需要执行副作用的子Fiber节点,包括对DOM 的插入 更新 和删除 等操作,并且如果该Fiber对应的是一个函数组件,该函数组件含有 useEffect 或者 useLayoutEffect,那么该Fiber 的 flag 也会被赋值为 hooks副作用 HasEffect
useEffect 是在 before mutation 阶段进行异步调度,在 layout 阶段之后进行 callback 调用链的赋值,浏览器完成布局和绘制之后的异步延迟时间中进行的链式调用,这样做是为了避免这些副作用在执行时,阻塞浏览器的渲染过程
而 react 提供了 useLayoutEffect ,可以使 effect 在 浏览器 绘制下一帧之前执行
Mutation 子阶段
渲染页面阶段,也是遍历 effectList 副作用链,只不过执行的是 commitMutationEffects
函数 ,这些副作用都与页面上的内容有关,可以理解为执行WIP fiber 上需要更新的副作用,这里的操作主要是操作DOM,对DOM进行插入、更新、删除commitMutationEffects
函数中对不同的DOM 操作 有不同的处理函数
Placement effect
该操作是指需要将flag为 Placement 的fiber 节点中的 DOM 内容插入到页面中,其中调用react-dom中的方法操作DOM
Update effect
指该fiber 节点需要更新,一般来说该Fiber 节点类型为 一个函数组件FunctionComponent
或者原生DOM组件HostComponent
如果是函数组件,那么会调用 commitHookEffectListUnmount
遍历 effectList 中的 useLayoutEffect callback中返回的销毁函数
如果是DOM组件,则会调用 commitUpdate
,最终会处理style children 以及一些 props ,将fiber.updateQueue 中更新的内容渲染到页面上
Deletion effect
如果fiber的flag是 Deletion ,那么fiber对应的DOM阶段需要从页面中删除,这个阶段会调用与生命周期结束相关的钩子 例如 useEffect callback 的返回函数, 类组件的 componentWillUnmount
,同时解绑释放ref
Layout 阶段
这个阶段的代码均是在 操作完所有的DOM 后被调用的
这里有一个时间差:由于js是同步执行了修改DOM这一系列操作,此时DOM 信息已经在浏览器中改变,但是浏览器并没有渲染这些DOM到页面上,也就是说,这个时机是在浏览器主线程执行因DOM的改变而产生的渲染之前,也就是下一帧更新画面到来之前。
在这个阶段,会同步执行commitLayoutEffect
,这可能会让浏览器延迟渲染更新的画面commitLayoutEffect
同样是去遍历effectList,执行所有useLayoutEffect
的callback
其过程会调用 commitLayoutEffectOnFiber
函数,在其中,会调用Class 组件中 设置状态的第二个callback
1 | this.setState({ xxx: 1 }, () => { |
这里可以发现, useLayoutEffect
和 useEffect
的本质区别, 前者的callback 和 销毁return 都是 同步调用,分别layout之后渲染之前、DOM更新之前,而后者 useEffect
则需要先调度,然后再layout 阶段完成之后(代码执行完之后)再异步执行
ReactDOM.render的第三个参数callback也会在此时同步地调用
在layout开始前 mutation结束后,会将 WIP rootFiber 赋值到 fiberRoot.current ,这样就将 WIP fiber 变为了 current fiber,此时切换是为了 layout 阶段中的生命周期函数能获取到更新后的DOM
Diffing 算法
该算法的主要使用场景是:在 render 阶段循环递归 fiber 树时,对于 Update 操作,对比更新前后的fiber 节点,将结果生成新的 fiber 节点,结果可能是复制(没有发生变化),部分复制,其余更新(发生变化)
DOM 节点的关系
在React 中,一个DOM 节点会有4个相关对象:
- current fiber 节点,当前页面上 DOM 对应的 fiber
- workingInProgress fiber 节点,代表即将即将对该DOM 进行 更新的 fiber
- DOM 本身
- JSX 对象,也就是该DOM节点对应的React Element 对象
Diff 算法运行的本质其实就是将 1 和 4 对比之后,生成 2
Diff 算法限制
如果要对比两棵fiber树的所有节点,带来指数级的算法复杂度 O(n^3)
这里的n指的是树中节点的数量,为了降低复杂度,React的Diff 算法做了三个复杂度限制:
- 只对同级元素进行Diff 计算,如果 一个节点在更新中跨域了层级,那么直接会对其进行销毁并重构,
例如:
如果父节点下的子节点有一颗子树,如果更新后子节点变成了父节点的兄弟节点,那么react 会直接删除掉子节点以及所有的子树,然后再父节点的后面的兄弟节点上 重新创建新的 子节点 和 下面的子树
- 针对不同类型的元素会生成不同的树,如果元素由div 更新 为了 p ,但其子树都不变,那么,React会销毁 div 以及子树,然后新建 p 以及子树
- 要求开发者在循环渲染的中使用key props进行性能优化
Diff 的实现
Diff 算法的入口在reconcileChildFiber
这里是使用父Fiber 进行 孩子的 Diff
1 | // 根据newChild类型选择不同diff函数处理 |
Diff的同级比较有两种类型:
- newChild是单一节点,例如文字节点,单个元素
- newChild 是数组节点,也就是同级下有多个节点
单节点Diff
当 newChild 为 jsx 对象或者 string、number 时,进入 reconcileSingleElement
进行对比reconcileSingleElement
中主要对比 React element 与 current fiber 的 key 以及 type,当key 和 type都相同时,会根据 新的 React element 的props 和复用 现有的 current fiber 创建新的fiber 名为 existing
,
如果 type 不相同,那么说明两者不属于一个组件,切需要清空 兄弟元素
由于React 的 fiber 节点的child 是一个链表结构,当新旧节点的key不相同时,那么说明该fiber 不能被复用,需要删除当前 fiber ,但是兄弟元素不会被删除,因为可能后面的fiber可以复用
最后会通过new react element,也就是new child 创建一个新的 fiber
多节点Diff
此处会处理子节点数组的情况,这是diff 算法的核心部分,比较难以理解,先放到后面再说
状态更新
在React 中,触发更新的基本都是以下几个api:
- ReactDOM.render
- this.setState
- this.forceUpdate
- useState
- useReducer
虽然使用场景不相同,但是他们背后会接入一套统一的更新机制,去触发 render 到 commit ,以更新视图
每次状态更新 都会生成一个 Update
对象,来保存更新状态的内容,切这个对象保存在触发状态更新的current fiber 节点上
调用markUpdateLaneFromFiberToRoot
方法,会一直沿着 fiber 的 return 属性,找到 顶层的 rootFiber,然后返回rootFiber
拿到rootFiber 后 ,react 就要 调度本次更新任务,决定优先级Lane
以及同步或者异步更新
调度的回调函数为 render 的 更新阶段入口,也就是:
1 | performSyncWorkOnRoot.bind(null, root); |
说以说,状态更新的主要路径为
1 | 触发状态更新(根据场景调用不同方法) |
心智模型
React 的更新方式分为 两种: 同步更新 和并发更新
将两个更新类比与代码版本控制:
同步更新
使用ReactDOM.render
创建的应用都是通过同步更新的方式进行状态更新,他们的状态更新顺序是没有优先级的概念类似于版本迭代
其中 蓝色 代表普通优先级, 红色代表高优先级
并发更新
在React中,通过ReactDOM.createBlockingRoot
和ReactDOM.createRoot
创建的应用会采用并发的方式更新状态。
也就类似于,使用 git flow 中应对 hotfix 的操作,hotfix 为优先级最高的修复分支
当项目出现bug时,需要从 master 切 出一个 hotfix 分支用于紧急修复,并且优先级大于dev分支
在修复完成后就hotfix 分支就尽快合并到 master 上,然后 此时 dev 分支上的 旧提交就和 新 master 分支一不一致,那么就要对dev 分支使用 git rebase master
来重新接入 dev 分支,保证包含了 hotfix 修复的内容。
此时的 D 可以理解为 React 更新中的高优先级更新,他可以打断 其余 蓝色 低优先级的更新,先完成 render - commit 阶段
也就是说,React并发模式的低优先级任务会根据高优先级任务更新后的结果来进行更新
React Hooks
理念
正在整理中,未完待续