[React Hooks]状态与副作用

707 阅读8分钟

前言

React Hooks在平时写的项目当中得到了充分的使用,对于项目来说,跳过传统的类组件直接过渡到函数组件,在项目开发过程中发现在使用时会出现使用不当的情况,因为对官方的几个钩子函数做一个较为全面的总结。

函数式组件出现的原因

为什么会出现函数式组件,因为传统的类组件确实有不少缺点:

  • 类组件中的this指向有点绕
  • 通过选项去组织代码,在组件比较大的时候会比较痛苦,因为类组件天生分离,不符合内聚性原则
  • 组件复用不方便,尤其是mixin,很容易带来数据来源指向不清楚的问题

函数式组件居然"有状态了"

我们知道在过去,函数式组件被称为"傻瓜组件"。因为它并不具有自身的状态,通常被用来做一些渲染视图的工作,即UI = render(props)。这是一个纯粹输入输出模型,无任何副作用。但React Hooks的出现,让函数式组件拥有自身的状态成为了可能。

函数式组件在运行过程中会被调用很多次,假如我们将状态保存在函数体里面,毫无疑问是不可行的。因为函数是一种"用完即销毁"的东西。

这正是Hooks所做的事情:将一个函数组件的状态保存在函数外面。准确来说,是这个函数组件对应的Hooks链表,当函数式组件需要用到该状态的时候,通过Hooks这一钩子将状态从函数体外部"钩进来"。

函数式组件其实也有“生命周期”

函数式组件的生命周期可以分为以下三部分:

初次渲染(first-render)---> 重渲染(re-render)---> 销毁(destroy

当我们第一次使用函数式组件的时候,会触发初次渲染(first-render);若其props改变,就会调用该render函数,触发重渲染(re-render)。

每一次的渲染,都是独立的。这正是函数式组件的美妙之处。

那么react如何决定要不要调用render函数来更新UI视图呢?这取决于data有没有更新。从整个组件树来看,data指的是整个组件的state;从具体到某个功能组件来看,data也可以被认为是props和自身state的结合体。

render的执行取决于data变化,而data中的state数据是保存在链表中的。

链表的特性是啥?就是每个元素都有一个next指针指向下一个元素,一环扣一环关联起来。所以为什么hooks不能用在条件判断/循环/嵌套中,因为这些都不能保证每次渲染时读取hooks链表的顺序是完全一致的。尤其对于状态读取来说,读取顺序和初次渲染链表记录的顺序不一致,会直接导致一些useState钩子读取到错误的状态值。

useState,状态保存之处

用法:

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

原理:

首先,useState会生成一个状态和修改状态的函数,这个状态会保存在函数式组件外面,每次重渲染时,这一次渲染都会去外面把这个状态钩回来,读取,成常量并写进该次渲染中。

通过调用修改状态的函数,会触发重渲染,到这里我们总结:props的改变和setState的调用,都会触发re-render

由于每次渲染都是独立的,所以每次渲染都会读到一个独立的状态值,这个状态值,就是通过钩子钩到的state并读取到的常量。

这就是所谓的capture value特性,每次的渲染都是独立的,每次渲染的状态其实都只是常量罢了。

深入本质

让我们深入一下本质,看看useStatere-render到底如何关联起来:

  1. 函数时组件初次渲染,一个个的useState依次执行,生成hooks链表,里面记录了每个state的初始值和应对的setter函数
  2. 这个链表会挂在这个函数式组件的外面,可以被useState或相应setter访问
  3. 当某个时刻调用了setSetter,将会直接改变这个hooks链表
  4. hooks链表其实就是这个函数式组件的状态表,它的改变等效于状态改变,会引起函数式组件重渲染
  5. 这个函数式组件重渲染,执行到useState时,因为初次执行已经挂载过一个hooks链表了,这个时候就会直接读取链表的相应值

这也就是为什么叫useState,而不是createState

useRef,DOM访问与外部状态保存

useRef有啥用

useRef主要有两个作用:

  • 用来访问DOM;
  • 用来保存变量到当前函数式组件外部。

访问DOM

我们先来看看前者怎么用吧:

const inputRef = useRef(null);


const handleClick = () => {
    inputRef.current?.focus();
}

return (
    <input ref={inputRef} />
    <button onClick={handleClick}>点击</button>
)

这样就可以方便地访问DOM节点。

保存可变值

前面我们提到,useState可以方便地保存状态值,但是由于函数式组件的capture value特征,使得我们并不能以一种比较方便的形式获取到更改后的状态值。

const [num, setNum] = useState(0);

const increaseNum = () => {
    setNum(prev => prev + 1);
    console.log(num); //打印的仍然是旧值,因为num在这一帧被常量化了
}

useRef将会插件一个ref对象,并把这个ref对象保存在函数式组件外部,这样的好处在于:

  1. 独立于capture value之外存储,不用担心获得过时变量的问题;
  2. 可以同步修改状态。

我们试验如下:

const numRef = useRef(0);

const increaseNum = () => {
    numRef.current += 1;
    console.log(numRef.current); //能获取最新的值
}

但是要注意⚠️:由于引用没变,上述操作并不会引起函数式组件的重渲染,这是一个很容易引起错误的地方!

useEffect,生命周期与观察者

用法及建议

useEffect的模型十分之简洁,如下:

useEffect(effectFn, deps);

useEffect可以模拟旧时代的三个生命周期:componentDidMountshouldComponentUpdatecomponentWillUnmont,相当于三个生命周期合并为一个api。

所谓shouldComponentUpdate,其实就是去除deps依赖数组,如此一来这个副作用的effectFn会在首次渲染之后和每次重渲染之后执行,相当于模拟了shouldComponentUpdate这一生命周期,如下:

useEffect(() => {
    /// xxx
});

而所谓componentDidMount,则是传入一个空数组作为依赖,因为当有deps数组时,里面effectFn是否执行取决于deps数组内的数据是否变化,空数组内无数据,所以对比自然也就无变化,使用如下:

useEffect(() => {
    // xxx
}, []);

componentWillUnmount,则是在effectFn中返回一个清除函数,如下:

useEffect(() => {
    // 执行副作用
    // ...
    return () => {
       // 清除上面的副作用
       // ...
    }
}, []);

此处我们应该始终遵循一个原则:那就是不要对deps依赖撒谎,否则会引发一系列bug。当然编辑器的linter也不会允许我们这样做,这一点非常关键。

原理

effectFn就是当依赖变化时执行的副作用函数,这里的副作用,并不是一个贬义词,而是一个中性词。

函数内部与外部发生的任何交互都算副作用,比如打印个日志、开启一个定时器,发一个请求,读取全局变量等等。

好,现在这个effectFn可以返回一个清理函数cleanUp,用于清除这个副作用。典型的清理函数,如:clearIntervalclearTimeout,如:

useEffect(() => {
    const timer = setTimeouto(() => console.log("over"),1000);
    return () => clearTimeout(timer);
});

useEffect其实是每次渲染完成后都会执行,但是effectFn是否执行,就要看依赖有没有变化了。执行useEffect的时候,会拿这次渲染的依赖跟上次渲染的对应依赖对比,如果没变化,就不执行effectFb。如果有变化,才执行effectFn

如果连依赖都没有,那react就认为每次都有变化,每次运行useEffect必运行effectFn

useEffect有典型的三大特点:

  • 会在每次渲染完成后才执行,不会阻塞渲染,从而提高性能
  • 在每次运行effectFn之前,要把前一次运行effectFn遗留的cleanUp函数执行掉(如果有的话)
  • 在组件销毁时,会把最后一次运行effectFn遗留的cleanUp函数执行掉。

deps数组里面的各个依赖与上次的依赖是否相同,需要通过Object.is来比较,比如:

Object.is(22, 22); // true
Object.is([], []); // false

这样就会有一个隐患,当deps数组里面的子元素为引用类型的时候,每次对比都会是false,从而执行effectFn。因为Object.is对比引用类型的时候,比较的是两个指针是否指向堆内存中的同一个地址。

useEffect的执行机制,是在初次渲染时,执行到useEffect就将内部的effectFn放到两个地方:一个是hooks链表中,另外一个则是EffectList队列中。在渲染完成后,会依次执行EffectList里面的effectFn集合。

所以,说白了,要不要re-render,完全取决于链表里面的东西有没有变化。

细节

不同于vue里面有async mounted,在useEffect里面的effectFn,应该始终坚持一个原则:要么不返回,要么返回一个cleanUp清除函数。像下面这样写是不行的:

// 错误的用法❌
useEffect(async () => {
    const reponse = await fetch("...");
    // ...
});

另外我们很容易发现:我们并不需要把useState返回的第二个Setter函数作为useEffect的依赖,实际上,React内部已经对Setter函数做了Memoization处理,因此每次渲染拿到的Setter函数都是完全一样的,不需要把这个Setter函数放到deps数组里面。