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

简介: 源码角度剖析useEffect和useLayoutEffect区别

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

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,希望你看到这里,可以对于这个结论的来源有一定的了解和学习,希望可以帮到你~

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

目录
相关文章
|
3月前
|
Java
【Java集合类面试二十八】、说一说TreeSet和HashSet的区别
HashSet基于哈希表实现,无序且可以有一个null元素;TreeSet基于红黑树实现,支持排序,不允许null元素。
|
3月前
|
Java
【Java集合类面试二十三】、List和Set有什么区别?
List和Set的主要区别在于List是一个有序且允许元素重复的集合,而Set是一个无序且元素不重复的集合。
|
3月前
|
存储 Java 索引
【Java集合类面试二十四】、ArrayList和LinkedList有什么区别?
ArrayList基于动态数组实现,支持快速随机访问;LinkedList基于双向链表实现,插入和删除操作更高效,但占用更多内存。
|
2月前
|
Android开发 Kotlin
Android经典面试题之Kotlin的==和===有什么区别?
本文介绍了 Kotlin 中 `==` 和 `===` 操作符的区别:`==` 用于比较值是否相等,而 `===` 用于检查对象身份。对于基本类型,两者行为相似;对于对象引用,`==` 比较值相等性,`===` 检查引用是否指向同一实例。此外,还列举了其他常用比较操作符及其应用场景。
187 93
|
15天前
|
存储 缓存 网络协议
计算机网络常见面试题(二):浏览器中输入URL返回页面过程、HTTP协议特点,GET、POST的区别,Cookie与Session
计算机网络常见面试题(二):浏览器中输入URL返回页面过程、HTTP协议特点、状态码、报文格式,GET、POST的区别,DNS的解析过程、数字证书、Cookie与Session,对称加密和非对称加密
|
1月前
|
编译器
经典面试题:变量的声明和定义有什么区别
在编程领域,变量的“声明”与“定义”是经典面试题之一。声明告诉编译器一个变量的存在,但不分配内存,通常包含变量类型和名称;而定义则为变量分配内存空间,一个变量必须至少被定义一次。简而言之,声明是告知变量形式,定义则是实际创建变量并准备使用。
|
1月前
|
XML 前端开发 Java
Spring,SpringBoot和SpringMVC的关系以及区别 —— 超准确,可当面试题!!!也可供零基础学习
本文阐述了Spring、Spring Boot和Spring MVC的关系与区别,指出Spring是一个轻量级、一站式、模块化的应用程序开发框架,Spring MVC是Spring的一个子框架,专注于Web应用和网络接口开发,而Spring Boot则是对Spring的封装,用于简化Spring应用的开发。
112 0
Spring,SpringBoot和SpringMVC的关系以及区别 —— 超准确,可当面试题!!!也可供零基础学习
|
1月前
|
前端开发 小程序 JavaScript
面试官:px、em、rem、vw、rpx 之间有什么区别?
面试官:px、em、rem、vw、rpx 之间有什么区别?
38 0
|
2月前
|
Java 关系型数据库 MySQL
面试官:GROUP BY和DISTINCT有什么区别?
面试官:GROUP BY和DISTINCT有什么区别?
90 0
面试官:GROUP BY和DISTINCT有什么区别?
|
3月前
|
Java
【Java集合类面试二十二】、Map和Set有什么区别?
该CSDN博客文章讨论了Map和Set的区别,但提供的内容摘要并未直接解释这两种集合类型的差异。通常,Map是一种键值对集合,提供通过键快速检索值的能力,而Set是一个不允许重复元素的集合。