前端开发入门(2)-React状态刷新

143 阅读7分钟

作为一个负责视图渲染的框架,整体流程跑通重点有两部分,第一部分是创建,第二部分就是ui发生变更的时候。上一篇我们学习了React创建虚拟节点树提交渲染的流程,本篇我们来学习一下第二部分,了解一下React是如何发起刷新并处理ui的变化的。

触发状态变化

我们通过react hooks里的 useState声明我们的ui和ui的改变:

const [state,setState] = useState('');

其中 useState 返回的就是当前状态以及触发状态更新的对象。在React里你会发现 useState也会用多个定义,分别是 mountStateupdateState,updateState 是在组件更新阶段的时候处理state的,所以我们暂时关注一下 mountState 就可以了。这部分的代码定义在 ReactFiberHooks.js里面:

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,currentlyRenderingFiber,queue
  ):any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

所以我们调用 setState 的时候,实际上是在调用 dispatchSetState, 主要就是调用 dispatchSetStateInternal方法,这个方法核心逻辑如下:

function dispatchSetStateInternal<S,A>(
  fiber: Fiber,
  queue: UpdateQueue<S,A>,
  action:A,
  lane: Lane
): boolean {
  const update: Update<S,A> = {
    lane,
    revertLane: NoLane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null:any)
  };
  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    const alternate = fiber.alternate;
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher = null;
        const currentState: S = (queue.lastRenderedState: any);
        const eagerState = lastRenderedReducer(currentState, action);
        update.hasEagerState = true;
        update.eagerState = eagerState;
        if (is(eagerState, currentState)) {
          enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
          return false;
        }
      }
    }
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
      entangleTransitionUpdate(root, queue, lane);
      return true;
    }
  }
  return false;
}
  1. 创建一个Update对象,表示一次更新。如果当前正在更新,就把Update对象加到更新队列。
  2. 如果上一次更新已经完成了,获取本次state(currentState)和上一次state(eagerState)。这里具体的获取逻辑涉及useState这个hooks的逻辑,这里不太影响我们理解刷新状态本身,所以先不关心。
  3. 对比新旧state,如果对比结果是一样的,调用 enqueueConcurrentHookUpdateAndEagerlyBailout。
  4. 新旧state发生改变,把Update加入更新队列,获取当前需要更新的fiber树的root。
  5. 调用scheduleUpdateOnFiber触发刷新,这个函数在上一篇我们遇到过,他会触发 workLoop 的流程,只不过这一次执行的都是Fiber节点刷新的逻辑。

新旧状态对比

React通过is函数进行新旧state的对比,这个函数定义在shared/objectIs.js文件:

function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y)
  );
}

可以看到当state是2个不同对象的时候,会进行严格对比,即使内部字段的值是一样的,仍然会触发刷新。

当对比通过不需要刷新的时候,会调 enqueueConcurrentHookUpdateAndEagerlyBailout,这里会把update塞入更新队列作为一个记录。

不过这里有一个细节我们可以关注下,这段比较逻辑执行的条件是需要满足:

fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes

但是如果一个如下的组件,会在第三次触发更新的时候,此条件才会满足,所以你会发现实际运行的时候,第三次点击才不会看到 render 的这条log打印。这个涉及到 fiber 和 fiber.alternate 也就是 workInProgress 节点的状态变化,具体可以参考这篇文章:juejin.cn/post/725771…

function App() {
  const [count, setCount] = useState(0);
  console.log('render', count);
  return (
    <button onClick={() => setCount(6)}>click me</button>
  );
}

fiber节点更新

回到前一篇文章beginWork -> updateFunctionComponent -> reconcileChildren 的逻辑:

export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  if (current === null) {
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes,
    );
  } else {
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderLanes,
    );
  }
}

//ReactChildFiber.js
export const reconcileChildFibers: ChildReconciler = createChildReconciler(true);
export const mountChildFibers: ChildReconciler = createChildReconciler(false);

之前建树我们看的新创建节点的逻辑,也就是 mountChildFibers,节点状态更新的时候执行的是 reconileChildFibers,实际上他们都是调用的 createChildReconciler,只是更新的时候shouldTrackSideEffects为true。

再回顾一下 reconcileChiildFibersImpl 的逻辑:

// in reconcileChildFibersImpl
if (typeof newChild === 'object' && newChild !== null) {
  switch (newChild.$$typeof) {
    case REACT_ELEMENT_TYPE: {
    const firstChild = placeSingleChild(
      reconcileSingleElement(
      returnFiber,
      currentFirstChild,
      newChild,
      lanes,
      ),
    );
    return firstChild;
  }
  if (isArray(newChild)) {
    const firstChild = reconcileChildrenArray(
      returnFiber,
      currentFirstChild,
      newChild,
      lanes,
    );
    return firstChild;
  }
}

这里单节点会走 reconcileSingleElement,如果是

    这种jsx返回数组的,则会走reconcileChildrenArray。

    单节点更新
    function reconcileSingleElement(
      returnFiber: Fiber,
      currentFirstChild: Fiber | null,
      element: ReactElement,
      lanes: Lanes,
    ): Fiber {
      const key = element.key;  
      let child = currentFirstChild;
      while (child !== null) {
        if (child.key === key) {
          const elementType = element.type;
          if (elementType === REACT_FRAGMENT_TYPE) {
            //...
          } else {
            if (child.elementType === elementType) {
              deleteRemainingChildren(returnFiber, child.sibling);
              const existing = useFiber(child, element.props);
              coerceRef(returnFiber, child, existing, element);
              existing.return = returnFiber;
              return existing;
            }
          }
          deleteRemainingChildren(returnFiber, child);
          break;
        } else {
          deleteChild(returnFiber, child);
        }
        child = child.sibling;
      }
    }
    

    这里能看到大概的节点复用/创建流程:

    循环里面对应有fiber节点的diff逻辑,判断节点是否能复用:

    • 如果当前child是null,也就是上次更新完没有对应的节点,没有节点可以复用了,创建新的。
    • 对比key,如果key不一样,那就只能先把不能复用的子节点标记删除,然后去遍历其他的子节点看看能不能复用。
    • 如果key一样了,那就对比一下elementType,如果是同类型的,说明fiber节点可以复用。复用的时候会把被复用节点的兄弟节点标记删除。
    • 如果elementType也不一样,说明子节点匹配不是复用条件,把子节点标记为删除。
    数组节点更新

    reconcileChildrenArray的主要源码如下:

    function reconcileChildrenArray(
      returnFiber: Fiber,
      currentFirstChild: Fiber | null,
      newChildren: Array<any>,
      lanes: Lanes,
    ): Fiber | null {
      let knownKeys: Set<string> | null = null;
      let resultingFirstChild: Fiber | null = null;
      let previousNewFiber: Fiber | null = null;
    
      let oldFiber = currentFirstChild;
      let lastPlacedIndex = 0;
      let newIdx = 0;
      let nextOldFiber = null;
    
      // 开始循环
      for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
        if (oldFiber.index > newIdx) {
          // 还没有遍历到oldFiber的index
          nextOldFiber = oldFiber;
          oldFiber = null;
        } else {
          // 已经超过oldFiber的index了,只能尝试用oldFiber的兄弟节点
          nextOldFiber = oldFiber.sibling;
        }
        const newFiber = updateSlot(
          returnFiber,
          oldFiber,
          newChildren[newIdx],
          lanes,
        );
    
        if (newFiber === null) {
          // 不能复用
          if (oldFiber === null) {
            oldFiber = nextOldFiber;
          }
          break;
        }
    
        if (shouldTrackSideEffects) {
          if (oldFiber && newFiber.alternate === null) {
            deleteChild(returnFiber, oldFiber);
          }
        }
    
        // 把newFiber添加到节点树
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
    
        previousNewFiber = newFiber;
        oldFiber = nextOldFiber;
      }
      // 循环结束
      
      if (newIdx === newChildren.length) {
        // newChildren遍历完成,oldFiber也遍历完成
        deleteRemainingChildren(returnFiber, oldFiber);
        return resultingFirstChild;
      }
    
      if (oldFiber === null) {
        // oldFiber遍历完成
        for (; newIdx < newChildren.length; newIdx++) {
          // 创建新的节点
          const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
          if (newFiber === null) {
            continue;
          }
          // 添加新节点
          lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
          if (previousNewFiber === null) {
            resultingFirstChild = newFiber;
          } else {
            previousNewFiber.sibling = newFiber;
          }
          previousNewFiber = newFiber;
        }
        return resultingFirstChild;
      }
    
      // 缓存已经存在的子节点到map里
      const existingChildren = mapRemainingChildren(oldFiber);
    
      // 再遍历一次,复用节点去map里面查找
      for (; newIdx < newChildren.length; newIdx++) {
        const newFiber = updateFromMap(
          existingChildren,
          returnFiber,
          newIdx,
          newChildren[newIdx],
          lanes,
        );
        if (newFiber !== null) {
          if (shouldTrackSideEffects) {
            if (newFiber.alternate !== null) {
              existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key);
            }
          }
          lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
          if (previousNewFiber === null) {
            resultingFirstChild = newFiber;
          } else {
            previousNewFiber.sibling = newFiber;
          }
          previousNewFiber = newFiber;
        }
      }
      if (shouldTrackSideEffects) {
        existingChildren.forEach(child => deleteChild(returnFiber, child));
      }
      return resultingFirstChild;
    }
    

    代码比较长,我们把他整理成流程图:

    这里会遍历newChildren和oldFiber 2个数组。新旧2个数组按下标对比,如果不能复用就会结束循环。这种比法会出现3种可能:

    1. newChildren遍历完成,也就是代码里的newIdx === newChildren.length,那说明整个数组都可以复用了。如果旧的fiber节点还没遍历完,那说明新ui里节点数量变少了,就把多出来的这部分节点标记删除。
    2. newChildren没有遍历完成,oldFiber遍历完成(为null),说明新的ui里节点变多了,所以需要创建新节点加在后面。
    3. newChildren和oldFiber都没有遍历完,那说明节点顺序变了,也就是2个数组是错位的,那就不能单靠下标去对应做比较了。这时候的处理比较复杂,会把所有已经存在的节点存在内存的一个map结构里面,然后再开启一轮newChildren的轮训,从map里面去查询是否有可复用的节点。从这里也可以看出,我们写代码的时候也应该尽可能的保持ui组件的顺序是没有错位变化的。

    其中决定节点是否可以复用的函数是updateSlot。如果不能复用节点,这个函数会返回null。具体实现我这里就不分析了,感兴趣的话你可以自己翻阅一下ReactChildFiber.js 这个文件。

    总结

    通过本篇学习我们能整理几个结论

    • useState刷新状态的时候会进行对象的严格对比,一些简单的组件状态可以考虑避免使用object来防止多次刷新。
    • 即使新旧状态的值是一样的,也有可能不会执行到新旧状态对比的逻辑。要具体情况具体分析。
    • 可以设置相同key来复用节点。
    • 数组节点刷新的时候尽量让节点顺序处于只新增/只删减,减少错位移动,可以增加ui刷新的效率。