useState原理以及其它react-hook的使用

前言

自react16.8发布了正式版hook用法以来,我们公司组件的写法逐渐由class向函数式组件+hook的方向转移,虽然用了这么久的hook,但是用得多的基本就useStateuseEffectuseMemo,其他的官方hook因为使用场景不明导致基本没用过,所以这两天特地去了解了一下其他hook的使用场景以及useState的原理,然后用这篇文章记录一下。

useState的使用及其原理

在hook版本出来之前,react函数组件无法拥有自身内部的状态,而useState赋予了函数组件拥有内部状态的能力,并且它的使用非常简单。

  • useState用法
    useState是一个函数,它接收一个初始值,并返回一个数组,该数组的第一位是一个state,第二位则是改变这个的state的函数,比如下面这个计数器的例子,当我点击按钮+的时候,数字就+1:

    image.png

    上例中的n就是这个函数组件的内部状态了。

  • useState原理
    在上面的例子中,我们每次点击按钮+的时候,数字都会增加1,也就是说App这个函数会被重新执行一次:

    image.png

    image.png

    既然App会被重新执行,那么useState(0)也会被重新执行一次,但是为什么n的值不会被重置为0呢?
    原因是,第二次useState执行后返回的n并非之前的n,setN改变的并不是之前返回出来的那个n,setN改变的数值存储于其它地方而非n,之后useState通过闭包的形式将这个新的数值返回了出来,并且执行dom的更新,我们可以在下面的实现一个useState看得更清楚。

  • 实现一个useState

    1. 首先通过上面的原理的解析,setN改变的并非n,而是另一个变量,所以我们创建这个变量_state,并创建myUseState函数:

      image.png

    2. 之后myUseState接收一个初始值,并返回一个数组,注意这个数组的第一项返回的并不是接收到的初始值而是第一步_state,而第二项则是改变_state的函数:

      image.png

      另外需要注意的是,在第一次执行myUseState的时候需要将初始值赋值给_state,而第二次执行的时候则是将之前的_state赋值回_state:
      image.png

    3. 接着useState在更新state的时候会重新渲染Dom,所以我们在setState函数中执行重新渲染的步骤(这里为了方便简化了更新步骤):

      image.png

    4. 这时候我们自己的useState就实现完成分了,用来测试一下:

      image.png

      结果可见是成功的:
      image.png

      但是此时我们的myUseState存在一个严重的bug,如果一个组件内存在多个state,而_state却只有一个,就会导致多个state都共用了一个状态,比如下面的组件:
      image.png

      image.png

  • 修复myUseState的bug

    1. 针对上面所说的bug,在组件拥有多个state的情况下,useState的执行存在由上到下的顺序,那么我们就可以将_state改造为一个数组,用于存储多个state,另外还需要新建一个变量index用于表明state在_state中的顺序:

      image.png

    2. 之后在myUseState中要做的第一步就是将接收到的state放入到_state中(注意这里和之前一样,第一次执行放入的是初始state,从第二次开始变成_state中对应的state):

      image.png

    3. 第三步我们在myUseState中创建一个能够修改_state中对应数据的函数setState并将其返回出来:

      image.png

    4. 之后需要考虑,setState执行的时候会重新渲染组件,所以在这一步中需要重置index:

      image.png

    5. 另外,为了保证每个_state中的state的顺序是一致的,所以在myUseState中将state放入到_state之后,将index + 1,这样我们就修复了之前多个state冲突的问题了:

      image.png

    6. 测试结果和代码总览:


      image.png
import React, { useState } from 'react'
import ReactDOM from 'react-dom'

const _state = []
let index = 0

const myUseState = initialValue => {
    const currentIndex = index
    _state[currentIndex] = _state[currentIndex] === undefined ? initialValue : _state[currentIndex]
    index = index + 1

    const setState = newValue => {
        _state[currentIndex] = newValue
        index = 0
        ReactDOM.render(<App />, document.getElementById('app'))
    }
    return [_state[currentIndex], setState]
}

const App = () => {
    const [n, setN] = myUseState(0)
    const [m, setM] = myUseState(0)

    const clickN = () => {
        setN(n + 1)
    }

    const clickM = () => {
        setM(m + 1)
    }

    return (
        <div>
            <div>{n}</div>
            <button onClick={clickN}>+</button>
            <div>{m}</div>
            <button onClick={clickM}>+</button>
        </div>
    )
}

ReactDOM.render(<App />, document.getElementById('app'))
  • useState的一些其他知识
    1. useState不能在条件语句后使用的原因: 原因根据上面自己实现的myUseState中就能看出来了,如果放在条件语句后使用,那么就有可能打破_state存放state的顺序导致state错乱。
    2. 看下面代码,虽然用了两次setN,但实际上点击按钮+后实际上数字只会加一:
      image.png

      image.png

      我们可以将clickN中的代码改成如下,使其变成每次都能+2:
      image.png

      image.png

useEffect和useLayoutEffect的使用及其异同

  • useEffect
    useEffect接收两个参数,第一个参数是函数,用于当组件内的state产生变化之后执行,而第二个参数(非必传)是一个数组,接收依赖的state,比如下面的例子,当n变化的时候将会打印出n的数值:

    image.png

    另外useEffetc接收的函数参数可以返回一个函数,这个函数将在该组件注销时执行,类似于class组件的componentWillUnmount
    例如下面的组件,在组件挂载后会设置一个定时器,每一秒钟打印一个1出来,当该组件被注销后,这个定时也会被注销:
    image.png

    另外还需要注意,usweEffect接收的函数是在组件渲染完毕之后才执行的。

  • useLayoutEffect
    useLayoutEffect用的非常少,这是一个有点像vue的v-cloak的功能,比如下面的代码,当组件挂载之后,把div里面的文字从value: 0改成value: 1000:

    image.png

    我们看到的效果确实也是这样的:
    image.png

    但是当你刷新多几次的时候,仔细观察就会发现,每次加载进来页面都会看到value: 0闪烁一下然后变成value: 1000,这是因为useEffect接收的函数是在组件被渲染之后才会执行的。
    这时候要解决这个问题,就需要将useEffect改成useLayoutEffect了,就不会存在这个闪烁的问题,而是直接显示value: 1000
    image.png

    原因在于,useLayoutEffect接收到的函数参数在组件渲染之前就会被执行,也就是说useEffectuseLayoutEffect功能其实是类似的,但是执行的时机不同,我们可以从下面的执行顺序看出来:
    image.png

    打印的顺序确实是1 2 3 4:
    image.png

  • 注意:
    虽然说useLayoutEffect能够在useEffect之前就执行,但是在不改变网页Dom文字样式的情况下,还是推荐使用useEffect的,在需要改变网页Dom文字样式的情况下再使用useLayoutEffect

useReducer以及useContext

  • useReducer
    useReducer的使用和redux的使用有些类似,useReducer接收两个参数,第一个是reducer(和redux中的一模一样),第二个参数是初始state,之后他会返回一个数组,数组第一项是state,第二项是改变state的函数dispatch,比如下面的例子:

    image.png

    测试结果:
    image.png

  • useContext
    useContext需要和createContext结合起来使用,实际上他们所要解决的问题和redux、mobx是类似的,都是夸组件间的数据传递,比如下面的例子,存在App组件,一个父亲组件,一个儿子组件,我们就通过创建一个Context,并用这个Context将App组件包裹起来,将App组件内的state传入到Context,使得父亲组件和儿子组件都能够通过useContext拿到App组件的state:

    image.png

  • useReducer和useContext结合搭建状态管理系统
    使用useContext可以在任意被对应Context包裹的组件中拿到传入的数据,将其和useReducer结合起来,
    就可以创建一个组件的状态管理系统,如何搭建可以参考我的这篇文章从零搭建项目(5) --- 前端: 搭建路由和状态管理

React.memo、useMemo和useCallback

这三个Api通常都在优化组件的时候使用,并且他们使用的都是记忆化函数的原理,关于记忆化函数可以参考我之前写的这篇文章: 再谈js中的函数

  • React.memo
    memo的功能其实之前class组件的pureComponent差不多,但是这个memo是用在函数式组件上的。
    首先我们来看下面的例子,Child组件引用了App组件的状态m,状态n和Child组件并无关系:

    image.png

    但实际上我点击按钮并执行setN的时候,Child组件也被更新了:
    image.png

    原因是Child组件被App组件所包裹,而执行setN的时候,App组件被重新渲染了,那么在其之中的Child组件自然也就被重新渲染了。
    所以这时候我们就需要用到memo来优化一下,使得我在执行setN的时候,Child组件不会跟着一起被渲染。
    memo的使用也非常简单,直接用它包裹需要被优化的组件即可,在本例中就是Child组件,所以代码可以修改为如下:
    image.png

    这时候我们执行setN的时候就不会使得Child组件跟着重新渲染了,只有执行setM的时候Child才会重新渲染:
    image.png

  • useCallback
    在上面使用memo的例子中,存在一个问题,当Child接收的props中存在函数的时候,之前使用memo做的优化就无效了,比如下面的代码:

    image.png

    结果:
    image.png

    原因和之前一样,由于App组件的重新渲染,所以const test = () => {}这段代码也被重新执行了,而test是一个函数,函数是引用类型,所以传入到Child中的test也和之前的test函数不一样,导致Child组件重新渲染。
    这时候我们就可以使用useCallback对其进行优化了。
    useCallback接收两个参数,首参是一个函数,在本例子中就是test函数,第二个参数是一个数组,这个数组接收的是改变这个函数引用的依赖,比如下面例子,m的值被改变的时候,test函数的引用才会被改变,Child组件才会被重新渲染:
    image.png

    优化结果:
    image.png

  • useMemo类似于vue中computed的功能,他接收两个参数,第一个参数是一个函数并通过计算得出一个state,第二个参数是计算这个state所需要的依赖,比如下面的例子,Child组件接收多一个props: num,这个num是n与m相加得出的:
    image.png

    结果:
    image.png

useRef和forwardRef

  • useRef
    useRef接收一个参数作为初始值,返回一个可变的 ref 对象,这个 ref 对象含有.current属性,该属性可以在整个组件色生命周期内不变。
    通常useRef被用作获取某个Dom,比如下面的例子:

    image.png

    image.png

  • forwardRef
    forwardRef这个函数用的场景相对较少,它主要用于在父组件获取子组件的Dom作为自己的ref的时候使用,比如下面的例子:

    image.png

    但实际上这样做是有问题的,会报错,并且buttonRef也没有获取到:
    image.png

    这时候我们就可以使用forwardRef对Button组件进行包裹,forwardRef会为Button组件注入一个新的参数ref:
    image.png

    这时候父组件就可以获取得到这个子组件的Dom了:
    image.png

推荐阅读更多精彩内容