书籍传送门:React技术揭秘——卡颂

前言:整篇文章,我都将以问题+笔记的形式作为精读的导语展开叙述~文末有彩蛋喔~~~

上一篇文章中,我们已经了解了架构篇Commit阶段中的mutationbefore mutation 阶段,这一节我们继续上一节,聊一下Commit阶段的 layout

在聊之前,我们先来解答一下,上一节留下的问题:

  1. 一个组件中多个 useEffect 的执行顺序是固定的吗?

答:是固定的,一个组件内部的 useEffect 会形成一个环状的链表结构,所以会顺序执行。

  1. 一个组件中如果出现多个 useEffectuseLayoutEffect,执行顺序是怎样的?

答:先执行 useLayoutEffect,后执行 useEffect

  1. 嵌套组件中的 useEffectuseLayoutEffect 的执行顺序又是怎样的?

答:嵌套组件中的 useEffectuseLayoutEffect,会按照最深的子组件,依次往上执行,按照 render 阶段形成的 effectList

补充

const Test = (props) => {
  const [s, setS] = useState(1)

  console.log(`render ${props.name}`)

  useEffect(() => {
    const name = props.name
    console.log(`effect ${props.name}`)
    return () => console.log(`effect cleanup ${name}`)
  })

  useLayoutEffect(() => {
    const name = props.name
    console.log(`layout effect ${props.name}`)
    return () => console.log(`layout cleanup ${name}`)
  })

  return (
    <>
      <button onClick={() => setS(s+1)}>update {s}</button>
      <Child name="a" />
      <Child name="b" />
    </>
  )
}

const Child = (props) => {
  console.log(`render ${props.name}`)

  useEffect(() => {
    const name = props.name
    console.log(`effect ${props.name}`)
    return () => console.log(`effect cleanup ${name}`)
  })

  useLayoutEffect(() => {
    const name = props.name
    console.log(`layout effect ${props.name}`)
    return () => console.log(`layout cleanup ${name}`)
  })

  return <></>
}
# 观测执行顺序

render parent
render a
render b
layout cleanup a
layout cleanup b
layout cleanup parent
layout effect a
layout effect b
layout effect parent
effect cleanup a
effect a
effect cleanup b
effect b
effect cleanup parent
effect parent

layout阶段,同样从以下时机阐述:

layout 阶段也是遍历 effectList ,并且执行 commitLayoutEffects

function commitLayoutEffects(root: FiberRoot, committedLanes: Lanes) {
  while (nextEffect !== null) {
    const effectTag = nextEffect.effectTag;

    // 调用生命周期钩子和hook
    if (effectTag & (Update | Callback)) {
      const current = nextEffect.alternate;
      commitLayoutEffectOnFiber(root, current, nextEffect, committedLanes);
    }

    // 赋值ref
    if (effectTag & Ref) {
      commitAttachRef(nextEffect);
    }

    nextEffect = nextEffect.nextEffect;
  }
}

该函数大致做了两件事:

  • commitLayoutEffectOnFiber调用生命周期钩子hook 相关操作)
  • commitAttachRef(赋值 ref)

commitLayoutEffectOnFiber 时,会针对两种类型的组件做不同的处理:

  • classComponent
    • 会通过 current === null ?区分是 mount 还是 update ,调用 componentDidMountcomponentDidUpdate
    • 如果在 this.setState 中设置了 callback,也会在此时执行。
  • FunctionComponent
    • 会调用 useLayoutEffect hook 的回调函数,调度 useEffect 的销毁与回调函数
    • 对于 HostRoot,即rootFiber,如果赋值了第三个参数回调函数,也会在此时调用。

commitAttachRef 做的事则很简单,获取 DOM 实例,更新 Ref

那么,思考一个问题,componentDidMount 或者 useLayoutEffect 中的 ref 存在DOM吗?

From StackOverflow: componentdidmount-called-before-ref-callback

React 官网传送门 :

React 将在组件挂载时将 DOM 元素传入ref 回调函数并调用,当卸载时传入 null 并调用它。在componentDidMout 和 componentDidUpdate 触发之前,Refs 保证是最新的。

同步源码(mutation阶段):

function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
  // 遍历effectList
  while (nextEffect !== null) {

    const effectTag = nextEffect.effectTag;

    // 根据 ContentReset effectTag重置文字节点
    if (effectTag & ContentReset) {
      commitResetTextContent(nextEffect);
    }

    // 更新ref
    if (effectTag & Ref) {
      const current = nextEffect.alternate;
      if (current !== null) {
        commitDetachRef(current);
      }
    }

    // 根据 effectTag 分别处理
    const primaryEffectTag =
      effectTag & (Placement | Update | Deletion | Hydrating);
    switch (primaryEffectTag) {
      // 插入DOM
      case Placement: {
        commitPlacement(nextEffect);
        nextEffect.effectTag &= ~Placement;
        break;
      }
      // 插入DOM 并 更新DOM
      case PlacementAndUpdate: {
        // 插入
        commitPlacement(nextEffect);

        nextEffect.effectTag &= ~Placement;

        // 更新
        const current = nextEffect.alternate;
        commitWork(current, nextEffect);
        break;
      }
      // SSR
      case Hydrating: {
        nextEffect.effectTag &= ~Hydrating;
        break;
      }
      // SSR
      case HydratingAndUpdate: {
        nextEffect.effectTag &= ~Hydrating;

        const current = nextEffect.alternate;
        commitWork(current, nextEffect);
        break;
      }
      // 更新DOM
      case Update: {
        const current = nextEffect.alternate;
        commitWork(current, nextEffect);
        break;
      }
      // 删除DOM
      case Deletion: {
        commitDeletion(root, nextEffect, renderPriorityLevel);
        break;
      }
    }

    nextEffect = nextEffect.nextEffect;
  }
}

可以看到,在 mutation 阶段就完成了 Ref 更新。


  • current Fiber 树切换
root.current = finishedWork;

在双缓存机制一节我们介绍过,workInProgress Fiber 树在 commit 阶段完成渲染后会变为 current Fiber 树。这行代码的作用就是切换 fiberRootNode 指向的 current Fiber 树。

那么这行代码为什么在这里呢?(在 mutation 阶段结束后,layout 阶段开始前。)

如何回答这个问题呢?其实我觉得看一下 React 源码位置的这几段注释就可以了,WIP 树要 layout就必须在 layout 之前完成转换。componentWillUnmount 代表着一次组件的生命周期结束。

// The work-in-progress tree is now the current tree. This must come after
  
// the mutation phase, so that the previous tree is still current during
   
// componentWillUnmount, but before the layout phase, so that the finished
    
// work is current during componentDidMount/Update.

答: componentWillUnmount 会在 mutation 阶段执行。此时 current Fiber 树还指向前一次更新的 Fiber 树,在生命周期钩子内获取的 DOM 还是更新前的。

文末彩蛋:手写笔记