面试官:useEffect和useLayoutEffect有什么区别?

5,631 阅读8分钟

您好,如果喜欢我的文章,可以关注我的公众号「量子前端」,将不定期关注推送前端好文~

Effect数据结构

顾名思义,React底层在函数式组件的Fiber节点设计中带入了hooks链表的概念(memorizedState),在此变量上专门存储每一个函数式组件对应的链表。

而对于副作用(useEffect or useLayoutEffect)来说,对应其hook类型就是Effect

单个的effect对象包括以下几个属性:

  • create: 传入useEffect or useLayoutEffect函数的第一个参数,即回调函数;

  • destroy: 回调函数return的函数,在该effect销毁的时候执行,渲染阶段为undefined

  • deps: 依赖项,改变重新执行副作用;

  • next: 指向下一个effect

  • tag: effect的类型,区分是useEffect还是useLayoutEffect

单纯看这些字段,和平时使用层面来联想还是很通俗易懂的,这里还是补充一下hooks链表的概念,有如下的例子:

const Hello = () => {
    const [ text, setText ] = useState('hello')
    useEffect(() => {
        console.log('effect1')
        return () => {
            console.log('destory1');
        }
    })
    useLayoutEffect(() => {
        console.log('effect2')
        return () => {
            console.log('destory2');
        }
    })
    return <div>effect</div>
}

挂载到Hello组件fibermemoizedState如下:

image.png

可以看到,打印出来结果和组件中声明hook的顺序是一样的,不难看出这是一个链表,这也是为什么react hook要求hook的使用不能放在条件分支语句中的原因,如果第一次mount走的是A情况,第二次updateMount走的是B情况,就会出现hooks链表混乱的情况,保证官方范式是比较重要的原因。

Hook

从上图的例子中可以看到,memorizedState的值会根据不同hook来决定。

  • 使用useState时,memorizedState对应是string(hello);
  • 使用useEffectuseLayoutEffect,对应的是Effect

Hook类型如下:

export type Hook = { 
    memoizedState: any, // Hook 自身维护的状态 
    baseQueue: any,
    baseState: any,
    queue: UpdateQueue<any, any> | null, // Hook 自身维护的更新队列 
    next: Hook | null, // next 指向下一个 Hook 
};

创建副作用流程

基于上面的数据结构,对于use(Layout)Effect来说,React做的事情就是

  • render阶段:函数组件开始渲染的时候,创建出对应的hook链表挂载到workInProgressmemoizedState上,并创建effect链表,也就是挂载到对应的fiber节点上,但是基于上次和本次依赖项的比较结果, 创建的effect是有差异的。这一点暂且可以理解为:依赖项有变化,effect可以被处理,否则不会被处理。
  • commit阶段:异步调度useEffect或者同步处理useLayoutEffecteffect。等到commit阶段完成后,更新应用到页面上之后,开始处理useEffect产生的effect,或是直接处理commit阶段同步执行阻塞页面更新的useLayoutEffect产生的effect

第二点提到了一个重点,就是useEffect和useLayoutEffect的执行时机不一样,前者被异步调度,当页面渲染完成后再去执行,不会阻塞页面渲染。 后者是在commit阶段新的DOM准备完成,但还未渲染到屏幕之前,同步执行。

创建effect链表

useEffect的工作是在currentlyRenderingFiber加载当前的hook,具体流程就是判断当前fiber是否已经存在hook(就是判断fiber.memoizedState),存在的话则创建一个effect hook到链表的最后,也就是.next,没有的话则创建一个memoizedState

先看一下创建一个Effect的入口函数:

function mountEffect(
    create: () => (() => void) | void,
    deps: Array<mixed> | void | null
): void {
    return mountEffectImpl(
        UpdateEffect | PassiveEffect,
        HookPassive,
        create,
        deps,
    );
};

可以看到本质上是调用了mountEffectImpl函数,传了上一节所说的Effect type中的字段,这里有个问题,为什么destroy没传呢?获取上一次effectdestroy函数,也就是useEffect回调中return的函数,在创建阶段是第一次,所以为undefined

这里看一下创建阶段调用的mountEffectImpl函数:

function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  // 创建hook对象
  const hook = mountWorkInProgressHook();
  // 获取依赖
  const nextDeps = deps === undefined ? null : deps;

  // 为fiber打上副作用的effectTag
  currentlyRenderingFiber.flags |= fiberFlags;

  // 创建effect链表,挂载到hook的memoizedState上和fiber的updateQueue
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps,
  );
}

接下来我们都知道,ReactVue都是状态改变导致页面重渲染,而useEffect or useLayoutEffect都会会根据deps变化重新执行,所以猜都猜得到,在更新时调用的updateEffectImpl函数,对比mountEffectImpl 函数多出来的一部分内容其实就是对比上一次的Effect的依赖变化,以及执行上一次Effect中的destroy部分内容~代码如下:

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;

  if (currentHook !== null) {
    // 从currentHook中获取上一次的effect
    const prevEffect = currentHook.memoizedState;
    // 获取上一次effect的destory函数,也就是useEffect回调中return的函数
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      // 比较前后依赖,push一个不带HookHasEffect的effect
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  currentlyRenderingFiber.flags |= fiberFlags;
  // 如果前后依赖有变,在effect的tag中加入HookHasEffect
  // 并将新的effect更新到hook.memoizedState上
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps,
  );
}

可以看到在mountEffectImplupdateEffectImpl中,最后的结果走向都是pushEffect函数,它的工作很纯粹,就是创建出effect对象,把对象挂到链表中。

pushEffect代码如下:

function pushEffect(tag, create, destroy, deps) {
  // 创建effect对象
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    // Circular
    next: (null: any),
  };

  // 从workInProgress节点上获取到updateQueue,为构建链表做准备
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    // 如果updateQueue为空,把effect放到链表中,和它自己形成闭环
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    // 将updateQueue赋值给WIP节点的updateQueue,实现effect链表的挂载
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    // updateQueue不为空,将effect接到链表的后边
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }
  return effect;
}

这里的主要逻辑其实就是本节开头所说的,区分两种情况,链表为空或链表存在的情况,值得一提的是这里的updateQueue是一个环形链表。

以上,就是effect链表的构建过程。我们可以看到,effect对象创建出来最终会以两种形式放到两个地方:单个的effect,放到hook.memorizedState上;环状的effect链表,放到fiber节点的updateQueue中。两者各有用途,前者的effect会作为上次更新的effect,为本次创建effect对象提供参照(对比依赖项数组),后者的effect链表会作为最终被执行的主体,带到commit阶段处理。

提交阶段

commitRoot

当我们完成更新,进入提交重渲染视图时,主要在commitRoot函数中执行,而在这之前创建Effect以及插入到hooks链表中,useEffectuseLayoutEffect其实做的都是一样的,也是共用的,在提交阶段,我们会看出两者执行时机不同的实现点。

// src/react-reconciler/src/ReactFiberWorkLoop.js
function commitRoot(root) {
  // 已经完成构建的fiber,上面会包括hook信息
  const { finishedWork } = root;

  // 如果存在useEffect或者useLayoutEffect
  if ((finishedWork.flags & Passive) !== NoFlags) {
    if (!rootDoesHavePassiveEffect) {
      rootDoesHavePassiveEffect = true;
      // 开启下一个宏任务
      requestIdleCallback(flushPassiveEffect);
    }
  }

  console.log('start commit.');
  
  // 判断自己身上有没有副作用
  const rootHasEffect = (finishedWork.flags & MutationMask) !== NoFlags;
  // 如果自己的副作用或者子节点有副作用就进行DOM操作
  if (rootHasEffect) {
    console.log('DOM执行完毕');  
    commitMutationEffectsOnFiber(finishedWork, root);  
  
    // 执行layout Effect  
    console.log('开始执行layoutEffect');
    commitLayoutEffects(finishedWork, root);
    if (rootDoesHavePassiveEffect) {
      rootDoesHavePassiveEffect = false;
      rootWithPendingPassiveEffects = root;
    }
  }
  // 等DOM变更之后,更改root中current的指向
  root.current = finishedWork;
}

这里的rootDoesHavePassiveEffect是核心判断点,还记得Effect类型中的tag参数吗?就是依靠这个参数来标识区分useEffectuseLayoutEffect的。

rootDoesHavePassiveEffect === false,则执行宏任务,将Effect副作用推入宏任务执行栈中。我们可以简单理解成useEffect的回调函数包装在了requestIdleCallback中去异步执行,根据fiber的知识接下来会去走浏览器当前帧是否有空余时间来判断副作用函数的执行时机。

继续往下走,如果rootHasEffect === true,代表有副作用,如果是useEffect,副作用已经在上面进入宏任务队列了,所以如果是useLayoutEffect,就会在这个条件中去执行,所以在这里我们可以理解到那一句"useEffect和useLayoutEffect的区别是,前者会异步执行副作用函数不会阻塞页面更新,后者会立即执行副作用函数,会阻塞页面更新,不适合写入复杂逻辑。"的原因了。

结尾

useEffectuseLayoutEffect十分相似,就连签名都一样,不同之处就在于前者会在浏览器绘制后延迟执行,而后者会在所有DOM变更之后同步调用effect,希望你看到这里,可以对于这个结论的来源有一定的了解和学习,希望可以帮到你~

如果喜欢我的文章,可以关注我的公众号「量子前端」,将不定期关注推送前端好文~