可能一直在写代码的路上,大多数时候就是个工具人。今天一起看看react18的实现原理,持续学习中。
react 在渲染过程中要做很多事,不会直接通过初始元素直接渲染。还有虚拟节点。除了初始元素能生成虚拟节点外,还有哪些能生成虚拟节点?
react 工作是通过初始元素或者可以生成虚拟节点的东西生成虚拟节点,然后针对不同的节点类型去做不同的事情最终生成 DOM 挂载到页面上。
1.初始元素—DOM 节点
针对初始元素的 type 属性为字符串时,react 会通过 document.createElement 创建真实 DOM。因为初始元素的 type 为字符串,所以会根据 type 属性创建不同的真实 DOM。创建完真实 DOM 后会立即设置该真实 DOM 的属性,比如直接在 jsx 中可以直接书写 className,style 等等都会作用到真实的 DOM 上。
2.初始元素—组件节点
如果初始元素的 type 属性是一个 class 类或者 function 函数时,那么会创建一个组件节点。所以针对类或函数组件,处理是不同的。
函数组件
对于函数组件会直接调用函数,将函数的返回值进行递归处理(看看是什么节点类型,然后去做对应的事情,所以一定要返回能生成虚拟节点的东西),最终生成一颗 vDom 树。
类组件
对于类组件而言相对会麻烦
首先创建类的实例(调用 constructor)
调用生命周期方法 static.getDerivedStateFromProps
调用生命周期方法 render,根据返回值递归处理,跟函数组件处理返回值一样,最终生成一颗 vDom 树
将组件的生命周期方法 componentDidMount 加入到队列中等待真实 DOM 挂载到页面后执行(前面说 render 是一个递归处理,所以如果一个组件存在父子关系的时候,那么肯定要等到子组件渲染完父组件才能走出 render,所以子组件的 compontDidMount 一定是比父组件先入队列的,肯定先运行)
3.文本节点
针对文本节点,会直接通过 document.createTexNode 创建真实的文本节点
4.空节点
如果生成的是空节点,那么将什么都不会做
5.数组节点
react 不会直接渲染数组,而是将数组的每一项拿出来遍历,根据不同的节点类型去做相应的事情,直到递归处理完数组里的每一项
总结:处理完所有的节点后,我们的 vDom 树和真实 DOM 也会创建好,react 会将 vDom 树保存起来,方便后续使用。然后将真实 DOM 都挂载到页面上。
1.组件更新(setState)
经常用 setState 来重新设置组件的状态进行重新渲染。使用 setState 只会更新调用此方法的类。不会设计到兄弟节点以及父级节点。影响范围仅仅是自己的子节点,步骤如下:
2.根节点更新(ReactDOM.createRoot().render)
在 ReactDOM 的新版中,不在直接使用 ReactDOM.render 进行更新,而是通过 createRoot(要控制的 DOM 区域)的返回值来调用 render
对比更新就是将新 vDom 树和之前首次渲染过程中保存的老 vDom 树对比发现差异后做一系列操作的过程。
React 的 diff 算法将之前的复杂度 O(n^3)降为了 O(n),它做了以下几个假设
为了通过旧节点,寻找对应的新节点进行对比提高节点的复用率。
1.先到先得(First-Come-First-Server,FCFS)
最简单调度策略,简单说就是没有调度。谁先来谁就先执行,如果中间某些进程因为 I/O 阻塞,这些进程会挂起移回就绪队列(重新排队)
2.轮转调度
基于时钟的抢占策略,也是抢占策略中最简单的一种:公平的给每一个进程一定的执行时间,当时间消耗完毕或阻塞,操作系统就会调度其他进程,将执行权抢占过来
要点是确定合适的时间片长度:太长了,长进程霸占太久资源,其他进程得不到响应(等待时间过长),太短了,进程抢占和切换都是需要成本的,而且成本不低,时间片太短,时间浪费给在上下文切换上,导致进程干不了什么实事。
因此,时间片的长度最好符合大部分进程完成一次典型交互所需的时间
3.最短进程优先(Shortest Process Next,SPN)
按照进程的预估执行时间对进程进行优先级排序,先执行完短进程,后执行长进程,这是一种非抢占策略
SPN 缺点:如果系统有大量短进程,那么长进程可能会饥渴得不到响应
4.最短剩余时间(Shortest Remaining Time,SRT)
5.最高响应比优先(HRRN)
6.反馈法
Vue 选择的是 1,使用模板让它有了很多优化的空间,配合响应式机制可以让 Vue 精确的进行节点更新。
React 选择的是 2。
对于 Worker 多线程渲染方案有人尝试,但是要保证状态和试图的一致性相当麻烦
为了给用户一种应用很快的假象,不能让一个程序长期霸占资源,可以将浏览器的渲染、布局、绘制、资源加载、事件响应、脚本执行视作操作系统的进程,我们需要通过某些调度策略合理的分配 CPU 资源,从而提高浏览器的用户响应速度,同时兼顾任务执行效率。
react 通过 fiber 架构,让自己的 reconcilation 过程变成可被中断的。适时的让出 CPU 执行权,除了可以让浏览器及时的响应用户的交互,还有其他好处。
人眼最高识别帧数不超过 30 帧,电源帧数大多固定在 24,浏览器最优的帧率是 60,即 16.5ms 左右渲染一次
但是当 JS 执行时间过长,FPS(每秒显示帧数)下降造成视觉上的卡顿
如何解决,就是 fiber reconciler 要做的事。将要执行的 JS 做分片,保证不会阻塞主线程(Main thread)即可
requestIdleCallback 是在空闲时间执行
requestAnimation 是在下一帧渲染之前执行
1.react
react 基础包,提供 react 组件(ReactElement)的必要函数,一般来说(react-dom,react-native)一同使用,在编写 react 应用的代码时,大部分都是调用此包 api
2.react-dom
react 渲染器之一,是 react 和 web 平台连接的桥梁,将 react-reconeiler 中的运行结果输出到 web 界面,在编写 react 应用时,大多数场景下能用到此包的就是一个入口函数 ReactDOM.render
3.react-reconciler
react 得以运行的核心包(综合协调 react-dom,react,scheduler 各包之间的调用与配合),管理 react 应用状态的输入和输出,将输入信号最终转换成输出信号传递给渲染器
4.react-scheduler
调度机制的核心实现,控制有 react-reconciler 送入回调函数的执行时机,在 concurrent 模式 下可以实现任务分片,在编写 react 应用的代码时,同样几乎不会直接用到此包提供的 api
react 核心就是下面两大工作循环
1.react-reconciler
核心:构建 fiber 树,生成任务
2.scheduler
核心:任务调度,任务优先级
在 jsx 语法中书写的节点,都会变编译器转换,最终会以 React.createElement(…)的方式创建出来一个 ReactElement 对象
主要 2 个属性:
1.key:在 reconciler 阶段会用到,默认值是 null,在 diff 算法中会使用到
2.type:这个属性决定了节点的种类:
在 reconciler 阶段会根据 type 执行不同的逻辑
它的值可以是字符串(div,span 等 dom 节点),函数(function,class 等节点),或者 react 内部定义的节点类型(portal,context,fragment 等)
注意:
react-reconciler 包是 react 应用的中枢,连接渲染器(react-dom)和调度中心(scheduler),同时自身也负责 fiber 树的构造
fiber.tag:表示 fiber 类型,根据 ReactElement 组件的 type 进行生成,在 react 内部定义了 25 种 tag
fiber.key:和 ReactElement 组件的 key 一致
fiber.return:指向父节点
fiber.child:指向第一个子节点
fiber.sibling:指向下一个兄弟节点
fiber.index:fiber 在兄弟节点中的索引,如果是单节点默认是 0
fiber.ref:指向 ReactElement 组件上设置的 ref(string 类型的 ref 除外,reconciler 阶段会将 string 类型的 ref 转换为一个 function 类型)
fiber.lanes:本 fiber 节点所属的优先级,创建 fiber 的时候设置
fiber.alternate:指向内存中另一个的 fiber,每个被更新过 fiber 节点在内存中都是成对出现(current 和 workInProgress)
属性解释
1.UpdateQueue:
baseState:表示此队列的基础 state
firstBaseUpdate:指向基础队列的队首
lastBaseUpdate:指向基础队列的队尾
shared:共享队列
effects:用于保存有 callback 回调函数 update 对象,在 commit 之后,会依次调用这里的回调函数
2.Update:
lane:update 所属的优先级
tag:表示 update 种类,有 4 种,UpdateState,ReplaceState,ForceUpdate,CaptureUpdate
payload:载荷,update 对象真正需要跟新的数据,可以设置成一个回调函数或者对象
callback:回调函数,commit 完成之后调用
next:指向链表中的下一个,由于 UpdateQueue 是一个环形链表,最后一个 update.next 指向第一个 update 对象
UpdateQueue 是 fiber 对象的一个属性,所以不能脱离 fiber 存在,他们之间数据结构和应用关系如下
Hook 用于 function 组件中,能够保持 function 组件的状态(与 class 组件中的 state 在性质上是相同的,都是为了保持组件的状态),常用 api 有:useState,useEffect,useCallback 等
属性解释
1.Hook:
Hook.queue 和 Hook.baseQueue(即 UpdateQueue 和 Update)是为了保证 Hook 对象能够顺利更新,与上下文 fiber.updateQueue 中的 UpdateQueue 和 Update 是不一样的(且他们在不同的文件中)
Hook 与 fiber 的关系:
在 fiber 对象中有一个属性 fiber.memoizedState 指向 fiber 节点的内存状态,在 function 类型的组件中,fiber.memoizedState 就指向 Hook 队列(Hook 队列保持了 function 类型的组件状态)
所以 Hook 也不能脱离 fiber 而存在
scheduler 包中,没有为 task 对象定义 type,其定义是直接在 js 代码中
属性解释:
注意:task 中没有 next 属性,他不是一个链表,其顺序是通过排序来实现的(小顶锥数组,始终保证数组中的第一个 task 对象的优先级最高)
react-reconciler 包的主要作用,主要功能分为以下 4 个方面:
在 reconciler 执行过程中
1、Render(基于 task,可以被打断,可以被打断的前提是基于渲染 mode)
2、commit
1、ReactDOM(Blocking)Root 对象
2、fiberRoot 对象
3、HostRootFiber 对象
这 3 个对象是 react 体系得以运行的基本保障,一经创建大多数场景下不会再销毁(除非卸载整个 root.unmount())
这一过程是从 react-dom 包发起的,内部调用了 react-reconciler 包,
无论那种模式下,再 ReactDOM(Blocking)Root 的创建过程中,都会调用一个相同的函数 createRootImpl,查看后续的函数调用,最后会创建 fiberRoot 对象
再 createFiberRoot 中,创建了 react 应用的首个 fiber 对象,称为 HostRootFiber(fiber.tag=HostRoot)
fiber 树中所有节点的 mode 都会和 HostRootFiber.mode 一致(新建的 fiber 节点,其 mode 来源与父节点),所以 HostRootFiber.mode 很重要,决定了以后整个 fiber 树构建过程
可中断渲染(render 可以中断,部分生命周期函数有可能执行多次)
UNSAFE_componentWillMount,UNSAFE_componentWillReceiverProps 只有在 HostRootFiber.mode===ConcurrentRoot 才会开启
如果使用的是 legacy,即通过 ReactDOM.render(
)这种方式启动时 HostRootFiber.mode=NoMode,这种情况下无论是首次 render 还是后续 update 都只会进入同步工作循环,reconciliation 没有机会中断,所以生命周期只会调用一次
React 内部对于优先级的管理,贯穿运作流程的 4 个阶段,根据功能的不同,可以分为 3 种类型
总结:
程序中的所有数在计算机内存中都是以二进制的形式储存的,位运算就是直接对整数在内存中的二进制位进行操作
在 react 原型过程中,调度中心位于 scheduler 包,是整个 react 运行时的中枢
调度中心最核心的代码在 SchedulerHostConfig.default.js 中
该 js 文件一共导出了 8 个函数,核心逻辑就在函数中
在不同的 js 执行环境中,这些函数的实现会有区别,下面基于普通浏览器对函数分析
1、调度相关:请求或取消调度
这 4 个函数目的就是请求执行(或取消)回调函数
主要是及时回调,延时回调在 17.2 之后基本没用
1、在 Scheduler.js 中,维护了一个 taskQueue,任务队列管理就是围绕这个 taskQueue 展开
2、创建任务
3、消费任务
在 react 运行时,fiber 树构造位于 react-reconciler 包,reconciler 的 4 个阶段如下:
fiber 树构造处于第三个阶段,可以通过不同的视角来理解 fiber 树构造在 react 运行时中所处的位置
根据 react 运行的内存状态,分为两种情况
1、ReacElement 对象(type 定义在 shared 包中)
2、fiber 对象(type 类型的定义在 ReactInternaIType.js 中)
3、DOM 对象:文档对象模型
在全局变量中 workInProgress,还有不少 workInProgress 来命名的变量,workInProgress 的应用实际上就是 React 的双缓冲技术
在 react 的上下文中,这种机制主要通过 fiber 架构实现,具体体现在维护两颗 fiber 树上:当前 fiber 树(curren tree)和工作中的树(work-in-progress tree,简称 WIP tree)
工作流程:
react 的双缓冲技术通过分离计划(构建 WIP terr)和执行(替换 current tree)阶段,实现了高效的 UI 更新管理,提示了用户体验。
在整个 react-reconciler 包中,Lane 的应用可以分为 3 个方面
在 react 体系中,有 2 种情况会创建 update 对象:
requestUpdateLane:
返回一个合适的 update 优先级
最后通过 scheduleUpdateOnFiber(current,lane,eventTime)函数,把 update.lane 正式带入到输入阶段
这是一个全局概念,每一次 render 之前,首先要确定本次 render 的优先级
无论是 legacy 还是 Concurrent 模式,在正式 render 之前,都会调用 getNextLanes 获取一个优先级
getNextLanes 会根据 fiberRoot 对象上的属性(expiredLanes,suspendedLanes,pingedLanes 等),确定出当前最紧急的 lanes
此处返回的 lanes 会作为全局渲染的优先级,用于 fiber 树构造过程中,针对 fiber 对象,或 update,只要它们的优先级(如:fiber.lanes 和 update.lane)比渲染优先级低,都将会被忽略
在 fiber 对象的数据结构中,其中有 2 个属性与优先级相关:
每次 fiber 树的构造是一个独立的过程,需要独立的一组全局变量,在 React 内部把这一个独立的过程封装为一个栈帧 stack(简单来说就是每次构造都需要独立的空间)
在进行 fiber 树构造之前,如果不需要恢复上一次的构造进度,都会刷新栈帧