一、历史版本 react 的简单分析
1.v15.x.pre:
- 拥有2个重要的功能 reconciler、render,对后续版本react影响巨大,基本奠定此后react框架大方向。
- 有优化批量更新。
- 缺点是业务大时,数据更新还是会卡顿,用户体验差。
2.v16(fiber架构):
- 解决(优化)15版本前的卡顿问题,每隔一段时间响应一次用户操作
- 处理方案:给每个不同的更新设置优先级:
- 生命周期更新、用户操作——同步更新,优先级最高
- 交互的事件——优先级高
- 数据请求等——优先级低
- 对于优先级低的,但是很早就已经执行了的任务,会因为时间的流失而逐渐提高优先级,优先级具体的优先程度涉及到 scheduler 调度器。
- 某个执行过程是:一个优先级低的任务 taskA 被先执行,然后一个优先级高的任务 taskB 被后执行,最后执行一个优先级高的任务 taskC;那么由于 taskB 是优先级高的任务,所以他会被最先调度,然后判断 taskA 的优先级是否因为时间的流逝而超过或相等于 taskC 的优先级,如果如此那么先执行 taskA ,否则先执行 taskC。
- 处理方案:给每个不同的更新设置优先级:
- 在v16.8引入了hooks的概念,react风格向FC模式转变,高阶组件等使用都很方便。
- 遗留问题:高优先级 cpu 任务会打断低优先级 io 任务。
3.v17:
- 解决v16遗留问题,将优先级定义从指定一个优先级修改为指定一个优先级区间,区间的表示为32位二进制数,
在某个区间内的任务将被任务优先级相同。
二、react 内部功能及概念
- fiber节点:运行时,react中每一个组件已经render函数的节点都是一个fiber节点
- fiber tree:fiber节点之间通过 child、return、sibling 构成了 fiber tree
- lanes:v17优先级通道,react 设置优先级为一个通道(区间),每一个通道都是一个32位二进制数,判断优先级时按照位运算计算。
- fiber:
- 需要完成或已完成的在组件上的任务,一个组件可以有多个 fiber 。
- 重要的节点:
- return:指向父节点:Fiber
- child:指向子节点:Fiber
- sibling:指向兄弟节点:Fiber
- tag:表示当前 fiber 节点处于哪种类型的组件,比如 class、function
- stateNode:初始化为null,渲染后指向真实dom节点信息
- key:fiber 节点实例的唯一标识,用于 dom diff
- flag:标记更新的类型,表示此节点应该执行的副作用,比如对应的 dom 节点应该执行增删改
- lanes:优先级车道(区间)
- childrenLanes:子节点车道(区间),为了方便调用
- alternate:指向前一次构件的 fiber tree 所对应的节点(workingProgress fiber node)
- scheduler:调度器。
- reconciler:调节器。
三、scheduler调度
一、调度流程:
- 同步任务 -> 优先级最高,立刻执行
- 异步任务 -> 判断当前是否已经有一个 fiber 在运行:
- 有 fiber 在运行,判断优先级:
- 优先级相同 -> 进入批处理逻辑
- 优先级不同:
- 根据当前任务的优先级,将立即执行的任务放到及时任务列表 taskQueue
- 延时任务放到延时任务列表 timeQueue
- 没 fiber 在运行,进入 2.1.2 的判断
- 有 fiber 在运行,判断优先级:
- 最终所有任务都会被放到 performSyncWorkOnRoot ,它可以看做调度器的出口。它会将所有任务通过宏任务 MessageChannel 中执行。
二、taskQueue 、timeQueue
- 在 taskQueue 中的任务,会被放在宏任务中执行,立即执行(虽然宏任务本身是异步的)。
- 档执行完 taskQueue 中的任务时,会清空 taskQueue 任务列表,然后判断在 timeQueue 中的 delay 任务,如果某些任务的优先级已经达到需要立即执行的时候,将这些任务添加到 taskQueue。
三、scheduleUpdateOnFiber 判断任务是否立刻执行
- 立即执行:
- v16:过期时间 与 当前时间的比较
- v17:是非批处理任务
- 非立即执行:
- 批处理任务,首次进入判断的任务节点会被添加一个 callback 方法,再次有任务进入判断时,会判断当前任务和前一个任务的优先级关系:
- 如果相同,则使用同一批次更新
- 如果不同,取消前一个 callback 任务。v17判断优先级前会进行合并,所以理论上优先级总是最高的,除非遇到一个非常高的完全不同等级的任务,才会取消
- 批处理任务,首次进入判断的任务节点会被添加一个 callback 方法,再次有任务进入判断时,会判断当前任务和前一个任务的优先级关系:
四、设定过期时间 expirationTime
- 获取当前时间 currentTime
- 计算任务开始时间:
- 如果有设置延迟时间,则开始时间 = 当前时间 + 延迟时间
- 如果没设置有延迟时间,则开始时间 = 当前时间
- 根据优先级,添加时间间隔。优先级也是时间概念,越大的数字表示越晚的过期时间,表示越低的优先级,-1为最小。
- 所以,过期时间 = 当前时间 + 配置的延迟时间 + 优先级时间
比较 开始时间 和 过期时间:
- 如果 开始时间 晚,那么此任务将被添加到 timerQueue,然后延迟到 开始时间 后运行。
- 如果 开始时间 早,那么次任务将被添加到 taskQueue,然后通过创建 messageChannel 调度即使任务。
五、MessageChannel
messageChannel 是一个宏任务,一共只有2个通道: port1 、port2,port2.postMesssage() 将会调用 port1 的回调。
Q&A:
- 功能上,完全可以用 setTimeout 代替。但 setTimeout 有定时功能,用在创建一个宏任务有点浪费它的能力。
- setInterval 同理。
- 不用微任务是因为无法解决卡顿问题,微任务运行时,主线程将会被占用。
- 题外话:实际上任何宏任务产生的时间间隔都大于0ms,setTimeout(fn,0)也不例外;而微任务在这方面会好很多。
四、reconciler 调节器
负责找出变化的节点,并打上标记。为了方便打断,v16 起,节点按链表结构组织。
reconciler 会将渲染机制分为2个阶段:
- 会执行 dom diff,并生成 dom 元素,但不会立刻渲染,这时如果有更高优先级的任务可以打断渲染。
- 下一次 commit 阶段才会渲染,而 commit 阶段是不能被打断的。
一、fiber tree 的双缓存结构
react 中最多同时存在两棵 fiber tree,这么做的原因是方便比较哪些节点发了变化。
正在显示中的称为 current fiber tree ;第二次(本次)构建的称为 workInProgress fiber tree ,其中,current fiber 的 alternate 属性指向 workInProgress fiber tree 中相对应的fiber。
current fiber workInProgress fiber
fiberNode1 ←→ fiberNode1`
↓ ↓
fiberNode2 ←→ fiberNode2`
↓ ↓
fiberNode2 ←→ fiberNode3`
当任意层级的节点在进行比较时,可以进行对比,如果判断结构想通过可以直接拿来复用;当比较完所有的节点时,将 workInProgress tree 设置为 current fiber,current fiber 就回收掉;相当于 canvas 离屏缓存。
二、构建 fiber tree
const Fn = () => {
return (
<div>
<p>文本</p>
<input type="text"/>
</div>
)
}
对于以上代码,将会构建成 div → p → 文本 → p → input → div 的链表结构
流程大致是一个深度优先遍历的过程(默认认为已经是第N次构建,对于 fiber tree 删的某一个节点):
- 执行 performUnitOfWork 函数
- 将当前指针指向某节点,调用 beginWork。
- 先判断当前节点是否有子节点
- 如果有子节点,将当前指针指向子节点,执行流程1。
- 如果没有子节点,执行流程2。
- 执行 completeUnitOfWork 函数
- 判断是否有兄弟节点
- 如果有兄弟节点,将当前节点标记为已经访问过,将当前指针指向兄弟节点,重新进入流程2。
- 此时既没有子节点也没有兄弟节点,返回到父节点,重新进入流程2。
- 判断是否有兄弟节点
beginWork(构建 fiber tree):
- 获取 current-tree,如果为 null 说明是第一次渲染;否则处理更新逻辑:
- 有属性变化则一定有更新;否则尝试复用,在复用中还会判断是否有更新。
- 对于 {this.xxx} 和 <XXX/>,前者可以很好的缓存,后者需要做额外处理。
- 根据 current fiber 的不同的 tag ,创建并返回不同的 fiber 节点。在非初次创建 fiber 节点时,会做 dom diff:
- 新创建的节点与 current fiber 作比较,主要分为:
- 单节点 diff
- 老节点不存在,标记建新
- key、类型相同,标记复用
- 否则标记删除
- 多节点 diff,一般指数组
- 对比相同 index 下的 key 是否相同,是则标记复用
- 判断 key 值不同的节点是否移动,(能够找出新的位置,用一个哨兵变量++,对比完老数组),表示复用
- 老节点不存在,标记创建(新创建了节点)
- 剩余的是多余的,标记删除(老节点多余)
- 单节点 diff
- 新创建的节点与 current fiber 作比较,主要分为:
五、commit 阶段
负责渲染页面的过程,分为3个阶段:dom操作前、时、后
一、dom 操作前
- 遍历 fiber tree,判断删除标记,提交删除操作。先判断删除是因为删除是与众不同的,删除后没有自己点,不用更深的遍历了
- 做深度优先遍历,对子节点进行相同的判断
- 调用 getSnapshotBeforeUpdate ,一个钩子函数
- 注册一次异步调度的操作的回调 useEffect,它不在 dom 操作完成之后注册并调用的原因是,为了防止阻塞(react 作者)。另 useLayoutEffect 是在 dom 操作完后同步执行的,同步意味着阻塞。
二、dom 操作时
步骤与 dom 操作前的逻辑几乎类似,根据 fiber 的标记做真正的 dom 操作
三、dom 操作后
- 深度优先级遍历各类 effect ,调用生命周期,执行钩子函数
- 赋值 ref
- 处理异步回调(如果有挂载)
- 删除老的 fiber tree
六、生命周期、钩子函数的顺序
对于 A 组件内嵌套 B 组件,问两个组件中,各类生命周期及钩子函数执行顺序的问题
判断钩子函数位于哪一个大步骤,reconciler 阶段还是 commit 阶段,reconciler 阶段的钩子函数比 commit 阶段的钩子函数靠前。
然后判断这些钩子函数执行和遍历子节点的顺序,先执行钩子函数,那就先执行 A 组件的钩子;否则执行 B 组件的钩子。
七、setState、hooks
它们都会通往 scheduler 的逻辑。
hooks 不允许被函数内的额外的作用域包裹,因为:hooks 在 react 的内部是按照链表结构保存的,所以如果将 hooks 不放在函数内的最顶级,那么有可能造成更新错位。
useEffect 模拟 didmount、didupdate、willunmont 三个生命周期,利用第二个参数。
useCallback ( + React.meno ) 模拟 shouldComponentUpdate 。
context + useRedcuer 实现一个 redux。
设置不批处理的函数:unbatchedUpdates,直接跳过 scheduler 阶段
调用它的有:ReactDOM.render