持续创作,加速成长!这是我参与「掘金日新计划 · 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会打印几次以及为什么。
以下关于打印三次的解释会涉及到大量源码,请谨慎阅读。文末会有不涉及源码的简单总结。
第一次
函数组件的本质还是函数,函数执行:
- 执行到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上,并且是以单向循环链表的格式。
- 执行到console.log函数,第一次打印发生。
第二次
使用
useEffect
完成副作用操作。赋值给useEffect
的函数会在组件渲染到屏幕之后执行。你可以把 effect 看作从 React 的纯函数式世界通往命令式世界的逃生通道。
还记的刚刚看到这句话吧。
接下来,在组件渲染到屏幕之后执行,要执行create函数了。而此时的create函数要执行setCount事件,也就是我们通常所说的函数组件中的setState,那么此时又要引起函数组件的更新了,也就是再执行这个组件函数,再次回到刚刚说的第一次。
- 执行到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。
- 此时再执行到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,就是标记当前组件是否需要更新的,需要更新的条件有这么几种:
在这里,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次~
严格模式检查仅在开发模式下运行;它们不会影响生产构建。
平常时候,严格模式作用就是上面截图中显示的。对我们最大的影响就是开发环境下会造成有些函数执行两次,实现原理也很简单,fiber上就有个mode属性记录是否执行严格模式,如果是严格模式,会执行某些if条件里的代码。造成执行两次的现象。
最后的最后
不知道这次有没有彻底解决大家的疑问,欢迎继续拍砖~
我该做核酸去了。