字节的一道React面试题

4,265 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第 1 天,点击查看活动详情

给出以下代码,x会被打印几次?

import * as React from "react";
import { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";

function SetStatePage(props) {
  const [ count, setCount ] = useState(-1);

  useEffect(() => {
    setCount(0)
  });

  console.log("x"); //sy-log

  return (
    <div>
      <h3>SetStatePage</h3>
    </div>
  );
}

const root = createRoot(document.getElementById("root"));

root.render(<SetStatePage />);

console.log("React", React.version); //sy-log

这是前段时间小徐发我的一道字节的面试题,我还在b站录制了一个讲解视频,我以为讲清楚了,但是今天早上看到有个从来不发动态的小伙伴在b站一连发了三个动态怼我,我认真看了看评论区,确实有不少人提出疑问。

好吧,我错了,最近醉心于写文,b站回复确实少了。接下来这道题我再来用文章梳理一遍,如果有建议,可以继续提,我会尽力改善。

背景

关于React版本号

视频中用的React版本是18.0.0,本文用的是18.1.0。

关于模式

本文和视频中代码的模式都用的是3,即ConcurrentMode和ProfileMode模式下:

// React18默认模式
export const ConcurrentMode = /*                 */ 0b000001;
// 当DevTools可用时,可以搜集相关的时间参数,观察和测试性能所用
export const ProfileMode = /*                    */ 0b000010;

关于环境

视频中用的DebugReact,在我的github。

开启了Dev。

useEffect用法

useEffect(didUpdate);

该 Hook 接收一个包含命令式、且可能有副作用代码的函数。

在函数组件主体内(这里指在 React 渲染阶段)改变 DOM、添加订阅、设置定时器、记录日志以及执行其他包含副作用的操作都是不被允许的,因为这可能会产生莫名其妙的 bug 并破坏 UI 的一致性。

使用 useEffect 完成副作用操作。赋值给 useEffect 的函数会在组件渲染到屏幕之后执行。你可以把 effect 看作从 React 的纯函数式世界通往命令式世界的逃生通道。

effect 的条件执行

默认情况下,effect 会在每轮组件渲染完成后执行。这样的话,一旦 effect 的依赖发生变化,它就会被重新创建。

然而,在某些场景下这么做可能会矫枉过正。比如,在上一章节的订阅示例中,我们不需要在每次组件更新时都创建新的订阅,而是仅需要在 source prop 改变时重新创建。

要实现这一点,可以给 useEffect 传递第二个参数,它是 effect 所依赖的值数组。更新后的示例如下:

useEffect(
  () => {
    const subscription = props.source.subscribe();
    return () => {
      subscription.unsubscribe();
    };
  },
  [props.source],
);

此时,只有当 props.source 改变后才会重新创建订阅。

关于评论区说的依赖

b站评论区有小伙伴说我没写依赖,要是加个空数组,就是两次。你说的对,是两次。

正文

接下来我们要来认真来说说文章开头的代码执行之后,log会打印几次以及为什么。

以下关于打印三次的解释会涉及到大量源码,请谨慎阅读。文末会有不涉及源码的简单总结。

第一次

函数组件的本质还是函数,函数执行:

  1. 执行到useEffect,useEffect本质还是个函数,而且是React自己的hook函数,此时因为是初次渲染,useEffect函数的执行对应源码中的是mountEffect函数的执行:
function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
    return mountEffectImpl(
      PassiveEffect | PassiveStaticEffect,
      HookPassive,
      create,
      deps,
    );
}

function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps,
  );
}


function pushEffect(tag, create, destroy, deps) {
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    // Circular
    next: (null: any),
  };
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    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;
}

此时我们看到,create函数只是作为effect对象的一部分,而且effect对象被存到了fiber.updateQueue上,并且是以单向循环链表的格式。

  1. 执行到console.log函数,第一次打印发生

第二次

使用 useEffect 完成副作用操作。赋值给 useEffect 的函数会在组件渲染到屏幕之后执行。你可以把 effect 看作从 React 的纯函数式世界通往命令式世界的逃生通道。

还记的刚刚看到这句话吧。

接下来,在组件渲染到屏幕之后执行,要执行create函数了。而此时的create函数要执行setCount事件,也就是我们通常所说的函数组件中的setState,那么此时又要引起函数组件的更新了,也就是再执行这个组件函数,再次回到刚刚说的第一次。

  1. 执行到useEffect,useEffect本质还是个函数,而且是React自己的hook函数,此时因为函数组件更新阶段,useEffect函数的执行对应源码中的是updateEffect函数的执行:
function updateEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}

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

  if (currentHook !== null) {
    const prevEffect = currentHook.memoizedState;
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  currentlyRenderingFiber.flags |= fiberFlags;

  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps,
  );
}

在这里我们看到,更新阶段判断是否添加给fiber添加effect的条件是对比前后两次依赖项是否相同,鉴于我们现在的没写依赖项,即为null。因此这次依然添加effect。

  1. 此时再执行到console.log,由此第二次打印发生

第三次

使用 useEffect 完成副作用操作。赋值给 useEffect 的函数会在组件渲染到屏幕之后执行。你可以把 effect 看作从 React 的纯函数式世界通往命令式世界的逃生通道。

再来看一遍这句话吧。

接下来,在组件渲染到屏幕之后执行,又要执行create函数了。而此时的create函数要执行setCount事件,也就是我们通常所说的函数组件中的setState。

注意,此时的count值是0,要再通过setCount赋值为0?此时函数组件会再次更新吗?

有过React常识的小伙伴都知道,React官网曾经介绍过,在React函数组件中使用useState或者useReducer执行setState,如果前后两次state相同,函数组件是不会重新渲染的。

但是,这是有bug的reactjs.org/docs/hooks-… 这个页面里没有纠正用法,但是源码中从17.1.0以及之后的版本修复了。useState中setState和以前一样,如果前后两次state相同则不会重新渲染组件.但是useReducer则不是,会继续渲染,不过也不用担心,只是渲染这个组件,不涉及子组件。另外Dan也建议,如果你觉得渲染本组件也很昂贵,可以使用memo~

以上两段话,和本题其实并没有什么关系,只是大家容易往这个方面去想,所以我还是解释了一下。因为useState中所谓的比较前后state是否相同,相同则bailout这件事情是发生在fiber.lanes === NoLanes的前提下的,也就是当前fiber还没有任务的前提下。

本题进行到现在,虽然也是setState,但是却是发生在上一个setState的“阴影”下的,此时的fiber.lane=16,也就是DefaultLane,所以刚刚说的那个前后state相同则bailout的条件根本就进不去。

那么到现在为止,相信大家能够理解第三次的log打印了吧。

为什么没有第四次

可是,还有个重要问题,既然第三次中的所谓state相同拦截也没有发生,怎么没第四次呢。

在React源码中有个全局变量叫做didReceiveUpdate,它的初始值是false,就是标记当前组件是否需要更新的,需要更新的条件有这么几种:

image.png

在这里,setCount事件最终会执行源码中的一个函数updateReducer,这个函数里有个判断:

if (!is(newState, hook.memoizedState)) {
  markWorkInProgressReceivedUpdate();
}

这里就是判断setCount前后两次state是否相同的,如果不相同,则标记didReceiveUpdate这个变量为true。

很显然,在count本身就是0的情况下,再次执行setCount(0),那么这里的didReceiveUpdate并没有被标记为true。

鉴于我们我们也没有其他的props或者context value变化,或者forceUpdate、root.render发生,那么这里的didReceiveUpdate依然是初始化的false。

而在关于函数组件的更新的源码中,有以下这样的判断:


function updateFunctionComponent(
  current,
  workInProgress,
  Component,
  nextProps: any,
  renderLanes,
) {
  ... 省略
  
  if (current !== null && !didReceiveUpdate) {
    bailoutHooks(current, workInProgress, renderLanes);
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }

  if (getIsHydrating() && hasId) {
    pushMaterializedTreeId(workInProgress);
  }

  // React DevTools reads this flag.
  workInProgress.flags |= PerformedWork;
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

我们看到这段代码,组件处于更新阶段,那么current显然不是null,而此时didReceiveUpdate依然是true,因此第三次的时候,组件进入bailout了,不再执行后文的对子树的协调reconcileChildren,结束~

在DebugReact项目中,你可以尝试注释掉下面的代码,会陷入死循环。

if (current !== null && !didReceiveUpdate) {
    bailoutHooks(current, workInProgress, renderLanes);
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}

bailoutHooks函数里,把effect也去掉了,因此也没有下一次了。

export function bailoutHooks(
  current: Fiber,
  workInProgress: Fiber,
  lanes: Lanes,
) {

  workInProgress.updateQueue = current.updateQueue;
  workInProgress.flags &= ~(PassiveEffect | UpdateEffect);
  current.lanes = removeLanes(current.lanes, lanes);
}

最后

这篇文章有点长,我梳理了大量的源码,方便大家理解。面试的时候,可以要精简,总结如下:

答案:打印三次。

第一次是函数组件初次渲染,执行log函数,并且此次由于useEffect函数执行,在fiber节点上记录了包含create函数的effect对象;

第二次是源于组件初次渲染完成之后,延迟useEffect的create函数,此时create函数中执行的是setState事件。setState导致函数组件更新,那么再次执行函数组件这个函数,log再次打印。并且此次又记录了create函数的effect对象。

第三次基本同第二次,不同的地方在于第三次的时候,前后两次状态值相同,函数组件检测到没有更新发生,bailout了。

Over~

其他

我看了b站的评论,还提到了一些其他问题:

关于状态值

关于状态值state,我结合属性props一起来说下:

props: 属性。就是父组件传下来的值,不可修改。虽然你也可以通过诸如defaultProps的方法修改,但是不建议,还是尽量保持它本来的模样。

state: 状态值。变量、可修改、一旦变化就引起组件更新,修改state须通过setState事件。

props就相当于DNA,不可修改。state在英语上还有个意思就是财产,财产的变化是我们人为可控的,并且一旦变化,人会发生很大的改变~

举个例子,域名要存到state中吗?没必要吧,这存成个常量就行了。

关于严格模式

以上所说的三次,没有开启严格模式。如果开启,则开发环境下6次~

截屏2022-05-31 16.15.25.png

严格模式检查仅在开发模式下运行;它们不会影响生产构建

平常时候,严格模式作用就是上面截图中显示的。对我们最大的影响就是开发环境下会造成有些函数执行两次,实现原理也很简单,fiber上就有个mode属性记录是否执行严格模式,如果是严格模式,会执行某些if条件里的代码。造成执行两次的现象。

截屏2022-05-31 17.23.36.png

最后的最后

不知道这次有没有彻底解决大家的疑问,欢迎继续拍砖~

我该做核酸去了。