react 到底从哪里 fiber 开始 "diff"

595 阅读3分钟

昨天偶然在前端群里面开始讨论的一个话题:

react 在发生 update 后是否需要对整棵树进行 diff?

实际上之前我的答案都是:“是”。直到在和群里大佬激烈讨论并测试才发现我一直以来对这个问题都有着错误的答案。

diff

在深入到这个问题之前先统一对 diff 的理解。本文中提到的 diff 都只是指diff子节点,而不是指整个协调过程。

基本流程

当 update 发生后,react 会生成(或复用)现有的任务。对于每一个任务,都是从 RootFiber 开始,不断的遍历到每一个 fiber,然后进行如下判断:

  1. 该 fiber 有 context, state 或者 props 更新吗,如果有则需要 diff 子节点。
  2. 该 fiber 没有以上更新,但是该 fiber 的子节点可能有更新,此时不需要 diff 子节点,但是需要继续访问子树。
  3. 如果以上都没有,则直接进入 completeWork。直接跳过该节点的子树。

条件 2 是本文的重点。当一个 fiber 的 context, state 或者 props 都没有变化,那么它的子节点不需要 diff。此时 react 会直接克隆 current 到 wip。在 react 中,这个步骤通过 bailoutOnAlreadyFinishedWork 实现,对应的代码是:

  // This fiber doesn't have work, but its subtree does. Clone the child
  // fibers and continue.
  cloneChildFibers(current, workInProgress);

从注释也可以看出,这段代码的表示了当前 fiber 啥工作也没有,继续探索子节点吧。在 cloneChildFibers props 是原封不动直接复制,不是浅拷贝也不是深拷贝。这意味着拷贝完成之后,对于子节点来说 (wip.pendingProps === current.pendingProps) === true 成立。

由于 <App/> 节点不存在 props,因此当 <App/> 节点如果不存在更新,那么此时会命中条件 2,其子节点的 props 会直接克隆。而如果子节点也没有更新,那么这个逻辑会一直继续下去直到遇到任何一个发生了状态改变的 fiber,从这个 fiber 开始,才会对子节点进行 diff

继续深入

上面提到命中逻辑 2 时子节点不会更新。在逻辑 2 有一个重要的步骤是对 props 是否变化进行判断,在代码里面:

    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;
    if (
      oldProps !== newProps ||
      hasLegacyContextChanged()
    ) 

这里的 oldProps = current.memoizedProps = workInProgress.pendingProps。因此在上面的内容中提到,如果某个 fiber bailout,那么 wip 子节点的 pendingProps 直接拷贝 current 子节点。如果子节点没有 state 改变,会继续 bailout。那么如果并不是走 bailout 逻辑,但是子节点的 props 同样没有改变,子节点还会继续 bailout 吗? 例如:

function Child(props){
  console.info("child call render")
  return (
    <p>{props.text}</p>
  )
}
function App() {
  const [value, setValue] = useState()
  const [text, setText] = useState({text: 'hello'})
  return  (
    <>
      <input onChange = {e => setValue(e.target.value)} />
      <Child text={hello}/>
    </>
  )
}

例如这里的 App 中有两个状态,分别是 text 以及 value,但是 text 变动并不影响 Child 组件。如果此时更改输入框的内容,Child 会进行 diff 吗?答案是:"会"。

原因就是此时 Child 的 props 虽然没有改变,但是 App 并没有命中 bailout 逻辑,因此 Child 的 props 是来自于 React.createElement:

React.createElement(Child, {text: "hello"})

我们知道,创建两次内容相同的 object 其引用并不相等,例如:

{text: "hello"} === {text: "hello"} // false

Child 在进行 oldProps !== newProptrue 自然无法命中 bailout 逻辑,需要 diff。当然,Child 既没有状态也 props 也明显没有改变,从优化的角度来讲确实不应该 render 和 diff,我们可以通过 React.memo 来达到这个优化效果。

结论

我们最终可以得出结论(不使用 memo,shouldUpate 以及 context 不变的情况):

  1. 如果某个组件的状态和 props 都没发生变化,其是通过父组件的 bailout 生成的,那么该组件会继续 bailout。
  2. 如果某个组件的状态和 props 都没发生变化,但是不是通过父组件 bailout 生成的,该组件仍然会 diff 子组件。

同样,我们还可以得出这样的结论:

react 存在 update 后并不会对所有的 fiber 都进行 diff 子节点的操作