【建议细品】要就多来几道ReactHooks编程题一路狂飙-useState&useEffect(1w字用心整理)

avatar
SugarTurbos Club 成员

前言

你盼世界,我盼望你无bug。Hello 大家好!我是霖呆呆!

已经非常久非常久没有在霖呆呆这个号上发表文章了,你是不是也很怀念与我在文章中产生心理碰撞的每个时刻。[微笑~]

为了庆祝失踪博主的回归,我打算先来一篇 ReactHooks类型的文章。 如果你熟悉呆呆的话,你可能知道我最喜欢出类似《【建议星星】要就来45道Promise面试题一次爽到底(1.1w字用心整理)》这种形式的文章了,从一道道实战题中去掌握某个知识点的使用和原理。

这篇文章也是一样的,我安排了十几道 useStateuseEffect 相关的题目,覆盖了能说明它们主要特性的大部分使用场景。 在介绍相关 hooks 使用的时候,会结合它们的特性去探索部分实现原理,并实现它们的“简易版”。

但由于本文面向的读者群体主要还是 “想巩固 hooks 的使用,且又有一些想了解原理的苗头” 的小伙伴,所以对于原理部分的讲解会点到为止。在后续的章节中才会深入探讨。

文章整体的篇幅还是比较长的,前面几道会比较简单,但后面会越来越有意思,希望你能在某个惬意的午后,一边品味西瓜,一边品味文章。每道题目的后面都会带有案例的在线链接,方便你的调试。让我们一起在探索 ReactHooks 的路上一路狂飙吧!

最后,如果在阅读完之后你认为有所帮助的话,请不要吝啬你的点赞与收藏哦💗,感谢~

OK👌,让我们来看看通过阅读本篇文章你可以学习到:

  • useState 各种特性的用法
  • 实现简易版的 useState
  • useEffect 各种特性的用法
  • 实现简易版的 useEffect

useState

用法

老规矩,我们还是先来看看最基本的用法吧:

const [state, setState] = useState(initialState);
=>
const [状态,更新状态] = useState(状态初始值)

stateReact 组件中定义的某个状态,它作为渲染视图的数据源。

setState 或者称为 dispathAction 是改变 state 的函数, state 值的修改只能通过它进行。

接下来我将划分几种不同类型的题目来帮你了解它的使用和大致的实现原理:

  • 最基本的使用
  • 初始值为函数
  • 初始值为某个计算表达式
  • 不同情况下的更新方式
  • 短时间内多次执行更新函数
  • 实现简易版的 useState

题目一

OK,我们先来看一道最简单的题目:

定义变量 count,并在每次点击按钮的时候累加,同时打印出 count 的值。

function App() {
  const [count, setCount] = React.useState(0);

  const handleClick = () => {
      setCount(count + 1)
      console.log('我是点击时的count:', count);
  }
  console.log('我是render时的count:', count);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={handleClick}>
        Click me
      </button>
    </div>
  );
}

这道题挺简单的吧?

如果我们点击了一次按钮,会发生什么呢?

思考一下…

现象:

  • 页面初始化时,打印了 "我是render时的count: 0"

  • 点击按钮,执行 setCount(count + 1),界面上显示的 count1 了,但是此时控制台打印的却是:

    • "我是点击时的count:" 0
    • "我是render时的count:" 1

由此,会给我们造成一个什么感觉呢?

我尝试在 setCount 之后马上获取 count 的值,但却拿不到最新的, setCount 表现的就像是异步更新一样。

没接触过 hooks 的小伙伴可能会觉得有点神奇,但稍微用过且了解过一些 React 的同学就不那么大惊小怪了。好,我们这边先买个关子,等你看完了几个题目后,然后结合原理部分说你就懂了。

在线案例:codepen.io/LinDaiDai/p…

题目二

useState 的初始值为函数:

function App() {
  console.log('我被 render 了');

  const [count, setCount] = React.useState(0);

  const [name, setName] = React.useState(() => {
    console.log('init name state');
    return 'LinDaiDai'
  });
 
  const handleClick = () => {
    setCount(count + 1)
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <p>My name is {name}</p>
      <button onClick={handleClick}>
        Click me
      </button>
    </div>
  );
}

我定义了两个变量, count 的初始值是基本数据类型, name 的初始值则为函数。

如果点击按钮,就会更新 count ,那么也就会触发 render,那 "init name state" 会被打印吗?

思考一会…

现象:

  • 页面初始化时,打印了 "我被render了""init name state"
  • 点击按钮,执行 setCount(count + 1),改变了 count 的值,页面上的 count 变为了 1,且打印了 "我被render了",但不会再触发 "init name state"

由此,我们可以看出,useState 的初始值有两种情况:第一种情况是非函数,将作为 state 初始化的值。 第二种情况是函数,函数的返回值作为 useState初始化的值。

在线案例:codepen.io/LinDaiDai/p…

image.png

题目三

接下来,我们来看一道有趣些的题目:

let num = 0;
function App() {

  function getNum() {
    num = num + 1;
    console.log('getNum', num);
    return num;
  }

  const [count, setCount] = React.useState(0);
  const [count2, setCount2] = React.useState(getNum() * 10);
	
  console.log('我被render了');

  const handleClick = () => {
    setCount(count + 1)
  }

  return (
    <div>
      <p>count: {count}</p>
      <p>count2: {count2}</p>
      <button onClick={handleClick}>
        Click me
      </button>
    </div>
  );
}

我定义了 count 变量以及更新方式还是和之前的案例一样,点击按钮时进行累加。

但是对于 count2 的初始值,我没有传入一个函数,而是传入了一行计算。

现在你猜猜,如果我每次改变 count 的值触发 render,那么对于 getNum() 会不会重复执行呢?

好的,不管你了哈,我们来揭晓答案[微笑~],现象为:

  • 初始化时,依次打印: getNum 1"我被render了" ,页面显示: count: 0; count2: 10
  • 每次点击按钮,依次打印: geetNum 2"我被render了",页面显示: count 会累加,count2不变,一直都是10

咦~ 确实有点意思了,如果 useState 的初始值是一个计算内容的话,好像每次都会计算一遍,但是并不会影响 count2 的值,因为打印了 getNum ,页面上的 count2 还是 10

从这两个案例,你是不是就看出区别了呢?

如果我们 useState 的初始值传递的是一个函数,那么这个函数只会在第一次渲染的时候执行:

const [name, setName] = React.useState(() => {
  console.log('我渲染一次');
  return 'LinDaiDai'
});

如果我们 useState 的初始值传递的是函数中的计算内容,那么这个计算会在每次渲染的时候都执行,但不会重新赋值:

function getNum() {
  num = num + 1;
  console.log('getNum', num);
  return num;
}
const [count2, setCount2] = React.useState(getNum() * 10);

在线案例:codepen.io/LinDaiDai/p…

题目四

上面我们主要看了初始化时的不同题目,对于更新函数( dispatchAction ) 会有什么有意思的情况呢?

[奸笑~]先来看个简单的:

如果更新传入的值和之前相等会怎样呢?

function App() {
  const [count, setCount] = React.useState(0);
  const [person, setPerson] = React.useState({ name: 'LinDaiDai' });

  console.log('我被 render 了');

  return (
    <div>
      <p>You clicked {count} times</p>
      <p>my name is {person.name}</p>
      <button onClick={() => {
        setCount(0);
       }}>
        Click me
      </button>
      <br />
      <button onClick={() => {
         setPerson({ name: 'LinDaiDai' });
      }}>
        Modify name
      </button>
    </div>
  );
}

这次,我定义了一个名 countstate,它是一个基本数据类型 number,且又定义了一个名为 personstate,它是一个对象。

且点击按钮的时候,都传入和初始值一样的值。

若是我分别点击第一个 button 和第二个 button 会发生什么呢?

现象:

  • 点击第一个按钮,更新 count,页面以及控制台都不会有反应
  • 点击第二个按钮,更新 person,页面看起来没什么变化,但是控制台会打印 "我被render了"

第一个按钮点击无反应好理解, count 在初始的时候是 0,再次调用也是更新为 0,所以对于 dispatchAction 来说,传入相同的值并不会触发组件的更新(从原理上来说, React 内部会实现一个类似 Object.is 的浅比较,这个在下一个例子中进行说明)。

第二个按钮其实也好理解,由于 person 是一个对象(引用数据类型),我们在调用 setPerson 的时候,传入了一个新的对象进去,也就触发了组件更新。

这道题,如果我们改造一下呢?

<button onClick={() => {
  person.name = 'Gisika';
  setPerson(person);
}}>

这次没有传入新的对象了吧,传入的还是之前的 person,但我会手动改一下 name,现象却是:

  • 额,好像怎么点击按钮都没有反映耶

小伙伴们别被这个 person.name 所误导了哈,别忘了我们 state 的原则,只能通过 dispatchAction 这样的更新函数进行更新才行哦,所以直接这样是不行的。

我们获取到 person 并尝试着通过 person.name 去修改它,由于 person 是一个对象(引用数据类型),所以在内存中的地址是并没有发生改变,也就没有触发组件更新。

在线案例:codepen.io/LinDaiDai/p…

题目五

在上面的案例中,我们验证了基本数据类型如果值没有改变的话是不会触发重新渲染的。

也提到了 React 内部实际是实现了一个类似于 Object.is() 这样浅比较,而不是 ===

这一块也很好验证,刚好来复习一下基础知识哈,可以想一下这两种浅比较有什么区别:

  • Object.is(+0, -0)的结果为false+0 === -0的结果为true
  • Object.is(NaN, NaN)的结果为trueNaN === NaN的结果为false

所以,下面的代码你知道会发生什么了吗?

function App() {
  const [count, setCount] = React.useState(-0);
  const [count2, setCount2] = React.useState(NaN);
  const handleClick = () => {
      setCount(+0);
  }
  const handleClick2 = () => {
      setCount2(NaN);
  }
  console.log('render count1:', count);
  console.log('render count2:', count2);

  return (
    <div>
      <p>count: {count}</p>
      <p>count2: {count2}</p>
      <button onClick={handleClick}>
        Click me
      </button>
      <button onClick={handleClick2}>
        Click me2
      </button>
    </div>
  );
}

现象:

  • 点击第一个按钮第一次,会触发 render 日志,后面不会了
  • 点击第二个按钮,不会触发 render 日志

这个现象也是符合我们的预期的。

在线案例:codepen.io/LinDaiDai/p…

image.png

题目六

下面再来看看一道比较经典的题目。

我在点击按钮的时候,一次性更新好几次 count

function App() {
  const [count, setCount] = React.useState(0);

  const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    console.log('count', count);
  }

  console.log('render count:', count);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={handleClick}>
        我想要批量更新
      </button>
    </div>
  );
}

看完代码后,如果让你点击按钮会发生什么?

1、 counthandleClick 回调中打印的值是多少?页面最终显示的值是多少?

2、 render count 会被打印几次呢?

咦~这里好像也挺有意思的哈,不急,我先来揭晓下现象:

  • 初始化时,打印 render count: 0
  • 点击一次按钮,打印 count: 0render count: 1
  • 第二次点击按钮,打印 count: 1render count: 2

好的,从结果看,我们发现, handleClick 中拿到的总是上一次的值,且在这个过程中,无论调用多少次 setCount,都会被合并为一个来处理。这也就使得 render count 会打印一次。

这个有趣的现象,你也许听的比较多的一种解释是: 闭包

但具体闭的是哪?包的是谁啊?好像没啥人和我说过。

恭喜你,看到下一题,你马上就能懂了。Let’s go~

在线案例:codepen.io/LinDaiDai/p…

题目七

想理解原理?不如我们来手撸一个简易的 useState 吧?[奸笑~]

是的,这个题目中,我们就是要做这个事。

首先大家都知道 ,useState 是这样用的:

const [count, setCount] = React.useState(0);

它接收的是一个初始值,返回的有两个部分:

1、变量的值

2、更新变量的方法

以你的聪明才智,你应该很快就能想到,它大概可能是长这样的:

let _state;
function useMyState(initialState) {
  _state = ( _state === undefined ? initialState : _state);
  function setState(newState) {
    _state = newState;
  }
  return [_state, setState];
}

是吧,非常的简单,且合理!

初次调用 useState 的时候,判断 _state 是否有值,没有的话就赋值。

然后提供 setState 方法更新 _state,并把这俩玩意都导出去。

外面就可以使用它来进行更新了:

const [count, setCount] = useMyState(0)

嗯…好像可以这样玩,但,我记得每次调用完 setState 之后,会更新页面的哦,你这个怎么和页面结合起来呢?

好的,那还不简单,满足你:

let _state;
function useMyState(initialState) {
    _state = ( _state === undefined ? initialState : _state);
    function setState(newState) {
	_state = newState;
+	render();
    }
    return [_state, setState];
}

咱们假设 render 函数就是执行页面重新渲染的操作,那么只需要在每次 setState 之后,调用一下就可以了。(你懂得,肯定没这么简单,但你先这样理解吧,一步一步来哈)

现在,这个冒牌的 useMyState 已经可以用在你的代码里了,你看看:

let _state;
function useMyState(initialState) {
    _state = ( _state === undefined ? initialState : _state);
    function setState(newState) {
	_state = newState;
        render();
    }
    return [_state, setState];
}

function App() {
  const [count, setCount] = useMyState(0);
  const handleClick = () => {
      setCount(count + 1)
      console.log(count);
  }
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={handleClick}>
        Click me
      </button>
    </div>
  );
}
const render = () => ReactDOM.render(
  <App />,
  document.getElementById('root')
);
render();

至少在这个案例中,和正牌的 useState 效果是一样的吧,哈哈哈。

在线案例:codepen.io/LinDaiDai/p…

题目八

在上面的案例中,我们是在模块内(你可以理解为在这个 .tsx 文件里)定义了一个 _state 变量来放 useMyState 想要存储的状态。

这里的关键词是什么?

"一个"

如果我想要使用多个状态呢?那我第二次使用 useMyState 是不是就会把第一次的覆盖了:

const [count, setCount] = useMyState(0);
const [count2, setCount2] = useMyState(1);

这显然不是我想要的,因为我们知道这样的 useMyState 根本就不能支持定义多个状态。

有什么解决办法吗?

思考一下….

在前端中,如果涉及到要存储一堆一堆的东西时,你首先会想到什么呢?

嗯…没错,就是数组了。这是最基本最简单的一种方式。

我们可以定义一个数组,每调用一次 useMyState 就向里面添加一项,同时,添加到数组里这一项的下标我们也得保存起来,这样下一次更新的时候,就能找到要更新数组里的哪一项了!

好的,让我们基于上面案例来完善一下:

let _state = [];
let index = 0;
function useMyState(initialState) {
    let currentIndex = index;
    _state[currentIndex] = ( _state[currentIndex] === undefined ? initialState : _state[currentIndex]);

    function setState(newState) {
	_state[currentIndex] = newState;
        index = 0;
        render();
    }

  index += 1;
  return [_state[currentIndex], setState];
}

简单的讲解一下吧,我猜你应该也能看得懂:

// 1. 初始定义数组和下标
let _state = [];
let index = 0;

function useMyState(initialState) {
     // 2. 在每次调用 useMyState 的时候,把当前这个 state 的下标缓存起来
     let currentIndex = index;
     // 3. 赋值
    _state[currentIndex] = ( _state[currentIndex] === undefined ? initialState : _state[currentIndex]);

    function setState(newState) {
	// 5. 外界更新的时候,由于 setState 和 useMyState 形成了闭包,这里能获取到 currentInex,并成功更新数组中对应下标的值
        _state[currentIndex] = newState;
        index = 0;
        render();
    }

    // 4. 在调用完一次 useMyState 之后,就要把 index 累加了,方便下一个 state 调用的时候知道自己应该排在哪
    index += 1;
    return [_state[currentIndex], setState];
}

现在调用多次 useMyState 也能符合我们的预期了:

调用:
const [count, setCount] = useMyState(0);

结果为:
_state = [0]; count 的 currentIndex = 0; 此时 index = 1;

调用:
const [count, setCount] = useMyState(0);
const [count2, setCount2] = useMyState(1);

结果为:
_state = [0, 1]; 
count 的 currentIndex = 0;
count2 的 currentIndex = 1;
此时 index = 2;

好的,看完了上面的讲解,相信你对 React 中的某个状态初始化定义应该有了一个大致的了解,如果有疑惑的话,可能是对于 setState 里,在这里面我们会将 index 重置为 0

function setState(newState) {
    _state[currentIndex] = newState;
    index = 0; // 这里会重置为 0
    render();
}

不知道我有没有猜对你的疑问呢?哈哈哈~

如果有的话,可以先简单的思考一下,思考的方向主要是:

  • 在每次调用 setState 的时候,除了更新 state的值,还会做什么其他的事?
  • 如果有做其他的事,那么这个事情为什么就一定要依赖 index

思考中…

好的,时间到,回来吧,不管你想没想出来,我们接着往下面看。

index 这个值的更新是在每次调用 useMyState 之后,我们会进行累加:

// 调用第一次
const [count, setCount] = useMyState(0); => index + 1
// 调用第二次
const [count2, setCount2] = useMyState(1); => index 又 +1
...

const [count, setCount] = useMyState(0); 这个语句除了在某个函数组件初始化的时候会调用,还会在什么时候调用?

如果某个函数组件重新渲染会不会调用?

没错,也会,否则在题目三中, getNum 就不会每次渲染的时候都打印了:

function getNum() {
  num = num + 1;
  console.log('getNum', num);
  return num;
}
const [count2, setCount2] = React.useState(getNum() * 10);

我们也不必在 useMyState 初始化 state 的时候做 _state[currentIndex] 是否存在的判断了:

_state[currentIndex] = ( _state[currentIndex] === undefined ? initialState : _state[currentIndex]);

那函数组件又是什么时候重新渲染呢?

手动[狗头~]

不就是在使用 setState 去更新状态的时候吗?

function setState(newState) {
    _state[currentIndex] = newState;
    index = 0;
    render(); // 触发重新渲染
}

嗯…有点意思了,也就是说:

  • 在组件初始化渲染的时候执行了若干次 useMyState,定义好了各个状态,以及 index
  • 在后续的过程中,我们手动调用了一次 setState 去更新某个状态,更新完后又主动触发了整个组件的重新渲染
  • 组件重新渲染,又会执行若干次 useMyState

所以如果在重新渲染之前,不把 index 重置会发生什么呢?那它是不是会继续累加,我是不是就找不到之前定义那些状态在数组 _state 里的位置了。

[掌声~] 感谢感谢~

你会发现如果你理解了这些之后,之前听到的很多话都豁然开朗了,比如

(一)为什么说 React 在更新的时候不是像 Vue 一样精准更新组件的某个位置,而是会更新整个组件?

因为 React 触发更新的时机是依靠某个状态的更新,这个状态更新是通过类似 setState 这样的 API 去触发的,更新之后,还会去调用一个类似 render 这样的函数来重新渲染。

(二)为什么说 ReactHooks 不能写在条件判断语句( if/else ) 中?

因为如果有条件判断的话,就可能会打乱 hooks 连接的顺序,导致当前 hooks 拿到的不是自己对应的 Hook对象。

这个也好理解吧,对应我们上面的案例,就像是:

如果初始化时候 count2 对应的 currentIndex1,重新渲染的时候又把 currentIndex = 0 传给它,那显然是不对的。

同时在这个过程中,我们也可以大致的猜想,在每一次 render 的时候,都会生成一个闭包,每个闭包都有自己的 stateprops,同时 React 一定是把组件当前的各种状态存在某个地方,然后在下一次又 render 的时候,拿到之前的各种状态和这次新的状态做一些对比,然后更新。

先不管我们的猜想正确还是不正确,可以先这样想着,接着再一步一步深入,人嘛!有时候总要大胆点!

在线案例:codepen.io/LinDaiDai/p…

看到这里,你心里对探索 React Hooks 原理的yu望是不是更加的强烈了,还有太多的疑问等着我们去探索:

  • React 内部不会真的用数组这样的方式进行存储各个 state 吧?
  • 上面的 useMyState 好像并不能解决很短的时间内重复调用 setState 却只触发一次 render 的问题耶?
  • 使用 setState 就会触发组件的重新 render,那这样性能不是会很差?
  • setState 更新某个状态真的是采用 _state[currentIndex] = newState 这样覆盖的方式吗?会不会合并属性呢?

题目九

好吧,上面提出的疑问好像每个都有些棘手,那我们先来挑个最简单的 "软柿子" 来看吧。

无疑就是最后一个问题了,直接验证就行了:

function App() {
  const [person, setPerson] = React.useState({ name: 'LinDaiDai', sex: 'boy' });
  const handleClick = () => {
      setPerson({ name: 'Gisika' });
  }
  
  console.log('render person is:', person);

  return (
    <div>
      <p>My name is {person.name}</p>
      <p>I'm a {person.sex}</p>
      <button onClick={handleClick}>
        Click me1
      </button>
    </div>
  );
}

上面的题目,如果点击按钮会发生什么呢?

看看 person.sex 这个属性还在不在吧。

不多说,直接看结果:

很显然,点击按钮之后,person 的名字改了,但是 sex 却丢了!呜呜呜~没有性别了~

以此,可以看出, React HookssetState 还是和类组件里的 setState 还是有区别的哈,类组件里的 setState 会对传入进来的对象做合并:

// 初始
this.state = {
    name: 'LinDaiDai',
    sex: 'boy',
}

// 调用
this.setState({
    name: 'Gisika',
});

// 结果
this.state = {
    name: 'Gisika',
    sex: 'boy',
}

在线案例:codepen.io/LinDaiDai/p…

题目十

在上面我们多次谈到了每次渲染都是独立闭包的特性,那我想下面这道题应该就难不倒你了:

  • 我定义了两个更新 count 的按钮,一个点击后2秒触发,一个点击后立即触发
  • 我先点击了一次【延迟2秒触发】的按钮,然后在接下来的2秒内狂点【立即触发】的按钮
  • 2秒后, count 会变成多少呢?
function App() {
  const [count, setCount] = React.useState(0);
  const handleClick = () => {
    setCount(count + 1);
  }
  const handleClickDelay = () => {
    setTimeout(() => {
      setCount(count + 1);
    }, 2000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={handleClickDelay}>
        延迟2秒触发
      </button>
      <button onClick={handleClick}>
        立即触发
      </button>
    </div>
  );
}

思考中…

揭晓答案:

  • 2秒内 count 会随着你的点击累加
  • 2秒后, count 会变成 1

这个结果是你预想的不~

如果你把闭包这件事记在心中,应该就能明白了,每次 state 拿到的都是它在调用前那时的值,对应这里就是初始值0,所以 setTimeout 里再次执行 setCount(count + 1) 就是 setCount(0 + 1)

有一些教程中可能会描述为:”每次state拿到的都是初始状态0”,这样好像会误导为无论我点击几次【延迟2秒触发】的按钮, count 始终都会从 0 开始去加一。但其实并不是的。

比如上面的案例,我点击一次【延迟2秒触发】,2秒后变为了 1,然后又点击一次【延迟2秒触发】,2秒后变为了 2。而不是 1,这就说明第二次点击的时候,它是能拿到更新后的值的。

如果我们现在就是想要在 setTimeout 里拿到最新的值进行更新,该怎么办呢?

也不是没有办法,其中最简单一种,就是将传入 setState 里的值改为一个回调函数,在这个回调函数中可以获取到最新的 state,我们将按钮点击事件改一下:

const handleClickDelay = () => {
  setTimeout(() => {
    // setCount(count + 1);
    setCount((count) => {
      return count + 1;
    });
  }, 2000);
}

现在你可以试试,已经可以正常更新了。[开心~]

在线案例:codepen.io/LinDaiDai/p…

总结

好的,至此,让我们来做下 useState的总结:

  • useState 的初始值有两种情况:第一种情况是非函数,将作为 state 初始化的值; 第二种情况是函数,函数的返回值作为 useState初始化的值
  • useState 的初始值传递的如果是函数中的计算内容,那么这个计算会在每次渲染的时候都执行,但不会重新赋值
  • 在调用 setState 去更新状态的时候,React 内部会进行类似 Object.is() 这样的浅比较,如果值相等则不会重新渲染
  • 当调用 setState 在当前执行上下文中是获取不到最新的 state, 只有在下一次组件 rerender 中才能获取到
  • setState 除了能传递一个值外,还可以传入回调函数,回调函数的参数就是最新的 state 的值

useEffect

用法

(一)
useEffect(() => {
    // todo...
})
=>
useEffect(每次渲染都会执行的回调函数)

useEffect(() => {
    // todo...
}, [])
=>
useEffect(首次渲染才会执行的回调函数, [])

useEffect(() => {
    // todo...
}, [state, props])
=>
useEffect(后面的值发生了改变才会执行的回调函数, [某个状态,某个props])

我还是会以以下几个题目类型来进行讲解,帮助你了解它的使用和基本原理:

  • 基本使用,没有任何依赖
  • 有依赖项:定义一个变量,并依赖,并看 return 的执行时机
  • 依赖项为空数组,定义一个组件,模拟初始化和销毁
  • 依赖项是函数
  • 简易版的 useEffect

题目一

OK,同样的,我们先来看一个最基本的用法,如果不传递 useEffect 的第二个参数,会怎样?

下面这个例子中:

  • 定义 state,并在点击按钮的时候进行累加,同时就触发了组件渲染
  • 定义了 useEffect 并进行日志输出
  • useEffect 的前面和后面都分别打上日志
function App() {
  const [count, setCount] = React.useState(0);
  
  console.log('before useEffect');
  
  React.useEffect(() => {
    console.log('useEffect', count);
  });
  
  console.log('after useEffect');
  
  const handleClick = () => {
      setCount(count + 1)
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={handleClick}>
        Click me
      </button>
    </div>
  );
}

想一想,初始化时,以及点击一次按钮,打印的日志顺序是怎样的呢?

思考中…

最终结果为:

  • 初始化时,依次打印 'before useEffect''after useEffect''useEffect 0'
  • 点击一次后,依次打印 'before useEffect''after useEffect''useEffect 1'

通过这个现象,我们可以得出怎样的结论呢?

我想到的是这么几点:

  • useEffect 如果没有传第二个参数的时候,它的第一个参数回调函数会在每次组件渲染的时候都执行,且内部是可以拿到最新的 state
  • useEffect 的第一个参数回调函数的执行时机与setTimeout的 的回调函数类似,会被放入任务队列,等到后续再执行。(这点我们从日志的打印顺序上就能看出来,'before useEffect''after useEffect' 都会在 useEffect 0 日志之前)

通过这道题,我们可以暂时先知道这两件事,至于它具体的调用时机,咦~ 同样的,说原理的时候咱们再来谈。

在线链接:codepen.io/LinDaiDai/p…

题目二

上面我们介绍了 useEffect 不传递第二个参数的情况,接下来看看传递会有怎么样的效果?

首先我们知道,第二个参数是一个数组类型的:

useEffect(() => {
    // todo...
}, [a, b])

如果 a 变量或者 b 变量有一个改动,回调函数都会执行。

另外, useEffect 还支持返回一个回调函数,这个回调函数会在 useEffect 重新执行之前,执行一次,用法如下:

useEffect(() => {
    // todo...
    return () => {
	// todo...
    }
}, [a, b])

那么一起来看看这个案例:

和上面一个案例没啥大的差别,多了 count 这个依赖,然后多了一个回调函数。

我们还是讨论两种情况:

  • 初始化组件时,打印什么
  • 点击一次按钮,打印什么
function App() {
  const [count, setCount] = React.useState(0);
  
  console.log('before useEffect');
  
  React.useEffect(() => {
    console.log('useEffect', count);
    return () => {
      console.log('useEffect 的 return 回调', count);
    }
  }, [count]);
  
  console.log('after useEffect');
  
  const handleClick = () => {
      setCount(count + 1)
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={handleClick}>
        Click me
      </button>
    </div>
  );
}

这里需要注意的可能就是 useEffect的return回调 打印的时机了,还有它这里获取到的 count 的值。

注意我们提到的, useEffect 返回的回调函数,会在 useEffect 重新执行之前,执行一次。

根据这句话, useEffect的return回调 肯定就在 "useEffect" 之前打印了。同时在这个回调内是获取不到最新的 count 的值的。

因此现象为:

  • 初始化时打印:

    • "before useEffect"
    • "after useEffect"
    • "useEffect" 0
  • 点击一次按钮后:

    • "before useEffect"
    • "after useEffect"
    • "useEffect 的 return 回调" 0
    • "useEffect" 1

好的,又学会了一个知识点[微笑~]

你会发现这玩意还挺好用的,有点像是 Vue 里的监听,但用法会更灵活。

如果 useEffect 不加参数的时候,每次渲染都会执行,但实际这种题目应该很少。如果不希望它这样的话,就可以使用它的第二个参数,使用一个数组指定副效应函数的依赖项,只有依赖项发生变化,才会再次执行。

通过这道题,我们可以简单画一下函数组件生命周期内 hooks 它的调用顺序:

    render

	|
	V

    浏览器绘制

	|
	V

    清理上一次的 effects(就是执行 useEffect 的返回值函数)

	|
	V

    运行本次的 effects

在线链接:codepen.io/LinDaiDai/p…

题目三

如果第二个参数我们传递一个空数组的话,会有什么不同吗?

而且如果组件的层级稍微多一些,这些 useEffect 的执行顺序会是怎样呢?

一起来看看这道题:

  • App 组件引用了 Child 组件,且用一个 state 来控制其显隐,默认显示组件
  • 点击按钮可以切换显隐
  • 分别在两个组件上都使用 useEffect ,且 render 的时候也打印日志

思考:

  • 初始化时打印什么?
  • 第一次点击按钮,隐藏 Child 打印什么?
function App() {
  const [visible, setVisible] = React.useState(true);

  React.useEffect(() => {
    console.log('app useEffect', visible);
    return () => {
      console.log('app useEffect return', visible);
    }
  }, [visible]);
  
  console.log('app render');
  
  const handleClick = () => {
      setVisible((visible) => {
        return !visible;
      });
  }
  
  const Child = () => {
    React.useEffect(() => {
      console.log('child useEffect');
      return () => {
        console.log('child useEffect return');
      }
    }, []);
    console.log('child render');
    return (
      <div>I' m child</div>
    )
  }

  return (
    <div>
      <button onClick={handleClick}>
        Click me toggle child1
      </button>
      {visible ? <Child /> : null}
    </div>
  );
}

对于这个题目的答案,其实你只需要记住, useEffect 是在 render 之后执行的就好解决了。

现象:

初始化时:

  • "app render"
  • "child render"
  • "child useEffect"
  • "app useEffect" true

第一次点击按钮,隐藏 Child

  • "app render"
  • "child useEffect return"
  • "app useEffect return" true
  • "app useEffect" false

不知道这两次的结果和你的猜想一样不一样呢?

或者说你之前有没有注意过这样的现象。

我们常说: "透过现象看本质"。嗯… 首先要知道会有什么现象,然后我们再深入去了解为什么会是这样。

看到这里,你是不是觉得它和某个组件的生命周期非常像,比如这里就有些像是组件首次渲染,还有销毁。

OKK,这对于我们初次了解 Hooks 可以这样去类比,但你的心里得种下一颗种子,那就是两者的概念还是会存在割裂感,等后续我们学习到了源码部分就能很好的看待这个问题了。

所以如果单纯的将它类比组件的生命周期来使用,那其实还是有挺多应用题目的,比如:

  • 获取数据,请求数据
  • 事件监听,解绑
  • 改变 DOM
  • 日志输出
  • ……

在线链接:codepen.io/LinDaiDai/p…

题目四

我们平常用的比较多的是依赖某个 state 或者 props,有没有想过如果依赖的是一个函数会发生什么?

要不要来试试?[奸笑~]

下面这道题:

  • 定义了 count,并在点击按钮的时候累加它的值
  • 定义一个 useEffect 依赖某个函数 getCount() ,它返回 count 的值
  • useEffect 调用之前和之后分别打印日志

提问:

  • 初始化时打印什么?
  • 点击一次按钮打印什么?
function App() {
  const [count, setCount] = React.useState(0);
  
  const getCount = () => {
    console.log('getCount', count);
    return count;
  }

    console.log('before useEffect');
  
  React.useEffect(() => {
    console.log('useEffect', count);
  }, [getCount()]);
  
  console.log('after useEffect');
  
  const handleClick = () => {
      setCount(count + 1)
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={handleClick}>
        Click me
      </button>
    </div>
  );
}

对于这道题, before useEffectafter useEffectuseEffect 的日志没有什么好疑问的,前面那两个肯定是在 useEffect之前打印,那 getCount 这个日志呢?你觉得它是在哪。

它是随着 useEffect 一起打印呢,还是由代码顺序决定的呢。

思考中…

Come on,揭晓答案:

初始化时:

  • "before useEffect"
  • "getCount" 0
  • "after useEffect"
  • "useEffect" 0

看到没! getCount 这个函数的调用竟然是和 before useEffectafter useEffect 它们一样,当成类似同步代码来执行的。

整个流程就好像:

代码开始执行 -> 遇到 "before useEffect" 并打印它
代码继续执行 -> 遇到一个 useEffect,检查它的依赖项数组,如果依赖数组中有函数调用则执行它
          -> 同时将 useEffect 的回调推入一个任务队列,延迟执行
代码继续执行 -> 遇到 "after useEffect" 并打印它
render完后 -> 检查到有一个 useEffect 的回调,执行它,打印 "useEffect 0"

肥肠nice~

下面再来看看点击按钮后会发生什么:

  • "before useEffect"
  • "getCount" 1
  • "after useEffect"
  • "useEffect" 1

对于顺序上其实没什么好疑惑的了,和初始化一样。但你会发现,即使我依赖的是一个函数,React 其实也会执行这个函数,并把这个函数的返回值给 useEffect 做比较,再来决定后续要不要执行 useEffect 的回调。

所以对于这道题目,等价于直接依赖 count

const getCount = () => {
  return count;
}

React.useEffect(() => {
  console.log('useEffect', count);
}, [getCount()]);

// 等价于 =>

React.useEffect(() => {
  console.log('useEffect', count);
}, [count]);

如果是这样的话,就有点意思了。

在上面我们好像提到了一个很眼熟的词: 比较

我记得还有哪也用到了比较?

没错,就是 setState 的时候!在调用更新状态的API时,也会去比较状态前后是否发生改变,改变了才触发渲染。这两者之间的比较会不会有什么共性呢?会不会就是用的同一种比较方式?咦~让我们到下一题来验证验证。[微笑~]

在线链接:codepen.io/LinDaiDai/p…

题目五

如果想要验证我们的猜想,可以先用一个不那么准确的方式。

在上面 useState 的第五道题目中,我们验证了 React 内部对 state 的比较是采用类似 Object.is() 这样的方式,对于 useEffect 我们也来这么试试看。

一起来看看这道题:

  • count 部分没有变,但是 useEffect 的依赖变了,依赖了一个名为 getNaN() 的函数,它固定返回 NaN
  • 为了能触发 render,我们在点击按钮的时候累加 count

提问:

  • 点击一次按钮打印什么?
function App() {
  const [count, setCount] = React.useState(0);
  
  const getNaN = () => {
    console.log('getNaN');
    return NaN;
  }
  
  React.useEffect(() => {
    console.log('useEffect', count);
  }, [getNaN()]);
  
  console.log('render');
  
  const handleClick = () => {
    setCount(count + 1)
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={handleClick}>
        Click me
      </button>
    </div>
  );
}

点击按钮时会触发 render,也会触发 useEffect 的对比,如果和我们猜想一样的话, Object.is(NaN, NaN) 的结果是true,那么 useEffect 这个日志就不会打印。

结果如下:

  • "getNaN"
  • "render"

从日志上看会执行 getNaN,但它确实没有打印 useEffect 了!也就是说我们的猜想可能是正确的,喔哦~

题目六

来吧来吧!也做了五道题了,让我们总结总结,再来看看怎么实现一个简单的 useEffect 。[奸笑~]

目前我们看到的 useEffect 的特性:

  • 第二个参数不传则会在每次渲染的时候执行
  • 第二个参数如果传空数组,会在组件首次渲染的时候执行
  • 第二个参数如果不是空数组, React 会将数组的内容当成依赖项存起来,在下次渲染的时候进行比较,发生改变了就会把 useEffect 的第一个参数回调函数推入任务队列等待执行

使用方式:

useEffect(() => {
    // todo...
}, [])

好的,根据这些特性,再结合使用方式,我们来尝试写一个中文简易版的 useEffect

function useMyEffect(回调函数, 依赖数组) {
    if (回调函数不是函数) {
        // 不是则抛错
    }

    if (没有依赖数组) {
        // 执行回调函数
    } else {
        if (依赖数组是否是数组) {
            // 不是则抛错
        } else {
            // 判断当前依赖值和上一次相比是否发生了改变,如果是的话则执行回调函数
        }
    }
}

将中文版转换为英文代码版:

function useMyEffect(callback, curDeps) {
  if (Object.prototype.toString.call(callback) !== '[object Function]') {
    throw new Error('The first argument must be a function');
  }
  
  if (typeof curDeps === 'undefined') {
    callback();
  } else {
    if (Object.prototype.toString.call(curDeps) !== '[object Array]') {
      throw new Error('The second argument must be an array');
    } else {
      // TODO: 判断是否发生改变
      const hasChanged = true;
      if (hasChanged) {
        callback();
      }
    }
  }
}

OKK,上面的代码相信你应该也能看的懂,基本上是使用 Object.prototype.toString.call 来判断类型。

大体的框架就是这样,接下来,我们只要着重关注一下怎么判断是否发生了改变。

涉及到与之前数据的对比,我们很容易想到肯定又有个地方来存储这些状态值,而且 useEffect 在一个组件中也是可能被多次调用的,从这两点看,它与 useState 是不是很像。不同点只是 useEffect 要存储的每一项本身就是一个数组。

所以,这里我们可以定义一个二维数组,以及一个下标,在合适的地方进行比较与赋值:

// 定义
const preDepsCollect = [];
let effectIndex = 0;

// 比较
const preDeps = preDepsCollect[effectIndex]; // 获取之前的依赖数组
// 如果之前的依赖数组不存在,则说明是首次调用,那么就设置为 true
// 如果之前的依赖数组存在,就遍历比较每一项
const hasChanged = preDeps ? curDeps.every(dep, index) => Object.is(dep, preDeps[index])) === false : true;

// 赋值
preDepsCollect[effectIndex] = curDeps;
effectIndex++;

还有一点要注意的,那就是 callback 的执行时机。我们知道,它总是在 render 之后,这边我们就用一个定时器来进行模拟:

setTimeout(callback);

把这部分代码写到对应的位置:

// 定义
const preDepsCollect = [];
let effectIndex = 0;

function useMyEffect(callback, curDeps) {
   if (Object.prototype.toString.call(callback) !== '[object Function]') {
     throw new Error('The first argument must be a function');
   }
  
  if (typeof curDeps === 'undefined') {
    setTimeout(callback);
  } else {
    if (Object.prototype.toString.call(curDeps) !== '[object Array]') {
      throw new Error('The second argument must be an array');
    } else {
      // 对比
      const preDeps = preDepsCollect[effectIndex];
      const hasChanged = preDeps ? curDeps.every((dep, index) => Object.is(dep, preDeps[index])) === false : true;
      if (hasChanged) {
        setTimeout(callback);
      }
      // 赋值
      preDepsCollect[effectIndex] = curDeps;
      effectIndex++;
    }
  }
}

肥肠的Nice~

看起来好像可以了,但是大家别忘了非常重要的一步,那就是对于 effectIndex 的重置。

useState 那里我们已经提到了,每次状态的改变都会触发 render,那么也会触发 useEffect 的再执行,所以我们得确保下一次执行的顺序,也就是在每一次 render 前把 effectIndex 重置。

在这边,我们就在某个函数组件(案例中的 App 函数)最顶层去赋值 effectIndex = 0,来模拟 effectIndex 的重置,完整代码如下:

const preDepsCollect = [];
let effectIndex = 0;

function useMyEffect(callback, curDeps) {
  if (Object.prototype.toString.call(callback) !== '[object Function]') {
     throw new Error('The first argument must be a function');
  }
  
  if (typeof curDeps === 'undefined') {
    setTimeout(callback);
  } else {
    if (Object.prototype.toString.call(curDeps) !== '[object Array]') {
      throw new Error('The second argument must be an array');
    } else {
      const preDeps = preDepsCollect[effectIndex];
      const hasChanged = preDeps ? curDeps.every((dep, index) => Object.is(dep, preDeps[index])) === false : true;
      if (hasChanged) {
        setTimeout(callback);
      }
      preDepsCollect[effectIndex] = curDeps;
      effectIndex++;
    }
  }
}

function App() {
  // 重制下标
  effectIndex = 0;
  const [count, setCount] = React.useState(0);
  
  console.log('before useEffect');

  useMyEffect(() => {
    console.log('每次都执行')
  });

  useMyEffect(() => {
    console.log('只在第一次执行');
  }, []);
  
  useMyEffect(() => {
    console.log('只在改变时执行', count);
  }, [count]);
  
  console.log('after useEffect');
  
  const handleClick = () => {
    setCount(count + 1)
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={handleClick}>
        Click me
      </button>
    </div>
  );
}

效果:

首次渲染:

  • "before useEffect"
  • "after useEffect"
  • "每次都执行"
  • "只在改变时执行" 0

点击一次按钮:

  • "before useEffect"
  • "after useEffect"
  • "每次都执行"
  • "只在改变时执行" 1

在线案例:codepen.io/LinDaiDai/p…

再次Nice~

MM,我会写 React Hooks 源码了!

啪!要真这么简单, Facebook 还不招你去?!

你懂得,实际上真没这么简单,还有很多功能我们都没实现的,比如:

  • 第一个参数回调函数的返回值如何实现
  • 子组件层级嵌套
  • ……

在这边呆呆只是先教大家基础的原理与思想,后续咱们再来深入哈。

总结

至此,我们再来做一下 useEffect 的总结吧:

  • 第二个参数不传则会在每次渲染的时候执行

  • 第二个参数如果传空数组,会在组件首次渲染的时候执行

  • 第二个参数如果不是空数组, React 会将数组的内容当成依赖项存起来,在下次渲染的时候进行比较,发生改变了就会把 useEffect 的第一个参数回调函数推入任务队列等待执行

  • 暂且可以将其与组件的生命周期钩子做类比,但得知道它俩并不是同一抽象层次可以互相替代的概念

  • 通过 useEffect 我们可以在其中做:

    • 监听某个 state 或者 props 的变动
    • 获取数据,请求数据
    • 事件监听,解绑
    • 改变 DOM
    • 日志输出

参考文章

知识无价,支持原创。

参考文章:

React useState原理》:juejin.cn/post/713080…

React Hook原理及使用之useState》:juejin.cn/post/703623…

深度学习React Hooks系列 - useState》: juejin.cn/post/686195…

「React 进阶」 React 全部 Hooks 使用大全 (包含 React v18 版本 )》:

juejin.cn/post/711893…

useEffect钩子实现原理》:juejin.cn/post/697049…

[译]5个技巧:避免React Hooks 常见问题》:juejin.cn/post/684490…

轻松掌握React Hooks底层实现原理》:segmentfault.com/a/119000003…

React技术揭秘》:react.iamkasong.com/

后语

你盼世界,我盼望你无bug。这篇文章就介绍到这里。

在这一章节中我们主要介绍了两个比较常用的 Hooks,其它的 Hooks 我们也会在后面来进行补充。如果你也喜欢这种风格的文章或者认为有什么需要改善的地方请评论区告诉我哦~

喜欢霖呆呆的小伙伴还希望可以关注霖呆呆的公众号 LinDaiDai

我会不定时的更新一些前端方面的知识内容以及自己的原创文章🎉。

你的鼓励就是我持续创作的主要动力 😊。

本文正在参加「金石计划」