这是我参与8月更文挑战的第2天,活动详情查看:8月更文挑战
了解了React Fiber架构的原理和基本过程,看一些关键问题上的具体实现也会更加有条理了~
本文就React中的Diff算法与状态更新具体是如何实现的做个总结,一起来看吧~
Diff算法
Diff 发生在 render 阶段的“递”操作中,对于需要更新的组件,比较current Fiber和JSX对象生成workInProgress Fiber。
我们知道,将前后两棵树完全比对的算法的复杂程度为 O(n^3 ),其中n是树中元素的数量。
为了降低算法复杂度,React的diff会预设三个限制:
- 只对同级元素进行
Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。 - 两个不同类型的元素会产生出不同的树。如果元素由
div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。 - 开发者可以通过
key prop来暗示哪些子元素在不同的渲染下能保持稳定。
单节点
判断key和type是否相同,都相同时一个DOM节点才能复用。
多节点
React团队发现,在日常开发中,相较于新增和删除,更新组件发生的频率更高。基于以上原因,Diff算法的整体逻辑会经历两轮遍历:第一轮遍历:处理更新的节点;第二轮遍历:处理剩下的不属于更新的节点。
举个例子:
- 有一组待更新的列表,首先进行第一遍遍历;
- indexOld = 0, indexNew = 0, 发现key不变,组件可复用,A不变,lastPlacedIndex = 0;
- indexOld = 1, indexNew = 1,key改变,不能复用,则跳出第一轮遍历;
- 第二轮遍历开始,新旧列表都没遍历完,将旧列表保存为map结构,只遍历新列表;
- indexNew = 1,在旧列表中是否存在相同的key,找到了indexOld = 2,因为indexOld > lastPlacedIndex(0), 所以C可以复用,不用移动,赋值lastPlacedIndex = 2;
注意: 如果没有找到,就创建新的节点,并标记新增; 找到了,且indexOld >= lastPlacedIndex,则组件复用,无需移动,赋值lastPlacedIndex为indexOld; 找到了,但是indexOld < lastPlacedIndex,则组件复用,但是向右移动(indexOld - lastPlacedIndex)位。
- 继续遍历新列表,indexNew = 2,找到了indexOld = 3,indexOld > lastPlacedIndex(2), 所以D也可以复用,不用移动,赋值lastPlacedIndex = 3;
- 继续遍历新列表,indexNew = 3,找到了indexOld = 1,indexOld < lastPlacedIndex(3), 所以B标记移动;
- 假如旧列表存在没有遍历到的节点,标记删除,结束。
状态更新
流程
Update对象分类
首先,我们将可以触发更新的方法所隶属的组件分类:
- ReactDOM.render —— HostRoot
- this.setState —— ClassComponent
- this.forceUpdate —— ClassComponent
- useState —— FunctionComponent
- useReducer —— FunctionComponent
ClassComponent与HostRoot共用一套Update结构,FunctionComponent单独使用一种Update结构。
ClassComponent的Update对象
下面是ClassComponent对用的Update结构:
export type Update<State> = {
eventTime: number, // 任务时间
lane: Lane, // 优先级相关字段
tag: 0 | 1 | 2 | 3, // 更新的类型,包括`UpdateState` | `ReplaceState` | `ForceUpdate` | `CaptureUpdate`
payload: any, // 更新挂载的数据,不同类型组件挂载的数据不同。对于`ClassComponent`,为`this.setState`的第一个传参。对于`HostRoot`,为`ReactDOM.render`的第一个传参
callback: (() => mixed) | null, // 更新的回调函数
next: Update<State> | null, // 与其他`Update`连接形成链表
};
export type SharedQueue<State> = {
pending: Update<State> | null, // 触发更新时,产生的`Update`会保存在`shared.pending`中形成单向环状链表。当由`Update`计算`state`时这个环会被剪开并连接在`lastBaseUpdate`后面
interleaved: Update<State> | null,
lanes: Lanes,
};
// UpdateQueue 对象被存放在fiber.updateQueue
export type UpdateQueue<State> = {
baseState: State, // 本次更新前该`Fiber节点`的`state`,`Update`基于该`state`计算更新后的`state`
// 之所以在更新产生前该`Fiber节点`内就存在`Update`,是由于某些`Update`优先级较低所以在上次`render阶段`由`Update`计算`state`时被跳过
firstBaseUpdate: Update<State> | null, // 本次更新前该`Fiber节点`已保存的`Update`链表头
lastBaseUpdate: Update<State> | null, // 本次更新前该`Fiber节点`已保存的`Update`链表尾
shared: SharedQueue<State>,
effects: Array<Update<State>> | null, // 保存`update.callback !== null`的`Update`
};
小结一下:
fiber.updateQueue.firstBaseUpdate和fiber.updateQueue.lastBaseUpdate保存上次更新时因为优先级低被跳过的Update- 新的
Update以环状链表的方式保存在fiber.updateQueue.shared.pending - 更新调度完成后进入render阶段,此时
fiber.updateQueue.shared.pending的环被剪开并连接在fiber.updateQueue.lastBaseUpdate后面 - 以
fiber.updateQueue.baseState为初始state,从fiber.updateQueue.firstBaseUpdate开始依次与遍历到的每个Update计算并产生新的state,如果有优先级低的Update会被跳过 state的变化在render阶段产生与上次更新不同的JSX对象,通过Diff算法产生effectTag,在commit阶段渲染在页面上- 渲染完成后
workInProgress Fiber树变为current Fiber树,整个更新流程结束
优先级
React在render阶段前,会通过Scheduler调度任务,调用Scheduler提供的方法runWithPriority,优先级最终会反映到update.lane变量上。
两个问题:
render阶段可能被中断。如何保证updateQueue中保存的Update不丢失?
答:得益于Fiber的双缓存,当render阶段被中断后重新开始时,会基于current updateQueue克隆出workInProgress updateQueue。由于current updateQueue.lastBaseUpdate已经保存了上一次的Update,所以不会丢失。当commit阶段完成渲染,由于workInProgress updateQueue.lastBaseUpdate中保存了上一次的Update,所以 workInProgress Fiber树变成current Fiber树后也不会造成Update丢失。
- 有时候当前
状态需要依赖前一个状态。如何在支持跳过低优先级状态的同时保证状态依赖的连续性?
答:Update对象内部维护baseState,作为该更新发生之前的state, 而fiber上还会保留memoizedState,作为上一次render阶段之前计算出来的state,会用于渲染在页面。当有Update更新被跳过时,baseState不等于fiber.memoizedState。而Update链还是原来的顺序,不会随着状态一起跳过。
FunctionComponent的Hook对象
不同于ClassComponent,更新对象保存在fiber.updateQueue上,FunctionComponent的更新对象以Hook对象连成的链表的形式,保存在fiber.memoizedState上。
Hook对象结构如下:
export type Hook = {
memoizedState: any, // 保存的单一`hook`对应的数据
baseState: any,
baseQueue: Update<any, any> | null,
queue: UpdateQueue<any, any> | null,
next: Hook | null,
};
export type UpdateQueue<S, A> = {
pending: Update<S, A> | null, // 保存update对象
interleaved: Update<S, A> | null,
lanes: Lanes,
dispatch: (A => mixed) | null, // 保存dispatchAction.bind()的值,保存了参数
lastRenderedReducer: ((S, A) => S) | null, // 上一次render时使用的reducer
lastRenderedState: S | null, // 上一次render时的state
};
type Update<S, A> = {
lane: Lane,
action: A,
eagerReducer: ((S, A) => S) | null,
eagerState: S | null,
next: Update<S, A>,
};
注意:
- 一个Hook对应一个useState / useReducer
- Hook内的queue是对应Hook的触发情况
Hook的memoizedState数据
不同类型hook的memoizedState保存不同类型数据,具体如下:
- useState:对于
const [state, updateState] = useState(initialState),memoizedState保存state的值 - useReducer:对于
const [state, dispatch] = useReducer(reducer, {});,memoizedState保存state的值 - useEffect:
memoizedState保存包含useEffect回调函数、依赖项等的链表数据结构effect,你可以在这里 (opens new window)看到effect的创建过程。effect链表同时会保存在fiber.updateQueue中 - useRef:对于
useRef(1),memoizedState保存{current: 1} - useMemo:对于
useMemo(callback, [depA]),memoizedState保存[callback(), depA] - useCallback:对于
useCallback(callback, [depA]),memoizedState保存[callback, depA]。与useMemo的区别是,useCallback保存的是callback函数本身,而useMemo保存的是callback函数的执行结果
有些hook是没有memoizedState的,比如:
- useContext
Hook嵌套问题
因为FunctionComponent组件mount时的hook与update时的hook来源于不同的dispatcher对象,其对应的fiber通过current === null || current.memoizedState === null来判断是否是mount,从而赋值ReactCurrentDispatcher.current。
另外,不同的调用栈上下文ReactCurrentDispatcher.current的赋值也不一样。
因此,在一个Hook中调用另一个Hook会出错。
不同类型Hook的具体实现
这一部分说实话没太弄明白,先留个坑吧~等多看几遍源码之后再来填它~