《自顶向下学React源码》学习笔记(二)—— Diff算法与状态更新的实现

602 阅读6分钟

这是我参与8月更文挑战的第2天,活动详情查看:8月更文挑战

了解了React Fiber架构的原理和基本过程,看一些关键问题上的具体实现也会更加有条理了~

本文就React中的Diff算法与状态更新具体是如何实现的做个总结,一起来看吧~

Diff算法

Diff 发生在 render 阶段的“递”操作中,对于需要更新的组件,比较current FiberJSX对象生成workInProgress Fiber

我们知道,将前后两棵树完全比对的算法的复杂程度为 O(n^3 ),其中n是树中元素的数量。

为了降低算法复杂度,Reactdiff会预设三个限制:

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。
  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。
  3. 开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定。

单节点

判断keytype是否相同,都相同时一个DOM节点才能复用。

多节点

React团队发现,在日常开发中,相较于新增删除更新组件发生的频率更高。基于以上原因,Diff算法的整体逻辑会经历两轮遍历:第一轮遍历:处理更新的节点;第二轮遍历:处理剩下的不属于更新的节点。

举个例子:

image.png

  1. 有一组待更新的列表,首先进行第一遍遍历;

1.png

  1. indexOld = 0, indexNew = 0, 发现key不变,组件可复用,A不变,lastPlacedIndex = 0;

image.png

  1. indexOld = 1, indexNew = 1,key改变,不能复用,则跳出第一轮遍历;
  2. 第二轮遍历开始,新旧列表都没遍历完,将旧列表保存为map结构,只遍历新列表;

image.png

  1. indexNew = 1,在旧列表中是否存在相同的key,找到了indexOld = 2,因为indexOld > lastPlacedIndex(0), 所以C可以复用,不用移动,赋值lastPlacedIndex = 2;
注意:
如果没有找到,就创建新的节点,并标记新增;
找到了,且indexOld >= lastPlacedIndex,则组件复用,无需移动,赋值lastPlacedIndex为indexOld;
找到了,但是indexOld < lastPlacedIndex,则组件复用,但是向右移动(indexOld - lastPlacedIndex)位。

image.png

  1. 继续遍历新列表,indexNew = 2,找到了indexOld = 3,indexOld > lastPlacedIndex(2), 所以D也可以复用,不用移动,赋值lastPlacedIndex = 3;

image.png

  1. 继续遍历新列表,indexNew = 3,找到了indexOld = 1,indexOld < lastPlacedIndex(3), 所以B标记移动;
  2. 假如旧列表存在没有遍历到的节点,标记删除,结束。

状态更新

流程

React状态更新.png

Update对象分类

首先,我们将可以触发更新的方法所隶属的组件分类:

  • ReactDOM.render —— HostRoot
  • this.setState —— ClassComponent
  • this.forceUpdate —— ClassComponent
  • useState —— FunctionComponent
  • useReducer —— FunctionComponent

ClassComponentHostRoot共用一套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.firstBaseUpdatefiber.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变量上。

两个问题:

  1. render阶段可能被中断。如何保证updateQueue中保存的Update不丢失?

答:得益于Fiber的双缓存,当render阶段被中断后重新开始时,会基于current updateQueue克隆出workInProgress updateQueue。由于current updateQueue.lastBaseUpdate已经保存了上一次的Update,所以不会丢失。当commit阶段完成渲染,由于workInProgress updateQueue.lastBaseUpdate中保存了上一次的Update,所以 workInProgress Fiber树变成current Fiber树后也不会造成Update丢失。

  1. 有时候当前状态需要依赖前一个状态。如何在支持跳过低优先级状态的同时保证状态依赖的连续性?

答: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数据

不同类型hookmemoizedState保存不同类型数据,具体如下:

  • 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时的hookupdate时的hook来源于不同的dispatcher对象,其对应的fiber通过current === null || current.memoizedState === null来判断是否是mount,从而赋值ReactCurrentDispatcher.current

另外,不同的调用栈上下文ReactCurrentDispatcher.current的赋值也不一样。

因此,在一个Hook中调用另一个Hook会出错。

不同类型Hook的具体实现

这一部分说实话没太弄明白,先留个坑吧~等多看几遍源码之后再来填它~