理解 Effect Hooks 副作用

2,799 阅读8分钟

什么是副作用?

我们首先从字面意思了解一下什么是副作用

看了很多,一直对副作用的理解不是很深入,直到看到了一篇文章的解释

副作用,是相对于主要作用的,并不只是作用

因为"药物的副作用",一直以来都没有很好的理解编程中副作用一词,想当然的把它理解为一些负面的作用,其实不然。

副作用操作是相对于操作state而言的。每次因为state的改变,都会有一次对应副作用函数的执行时机,如果state 多次改变,那么就有多次对应副作用的执行时机。

执行流程:

useState(状态改变) -> rende(渲染) -> useEffect(执行副作用)

React Hooks中对副作用真实的解释是:

  • 副作用(side effect)指的是函数内部与外部互动,产生运算以外的其他结果。

看到这个解释还是很迷茫,什么是函数内部与外部互动,产生运算意外的其他结果?

我们还是通过一个例子来看一下。

import {useEffect, useState} from "react"

const UserEffectDemo = () => {
    const [count, setCount] = useState(0);
    
    //修改了浏览器的标题,这就是一个典型的副作用
    useEffect(()=> {
      document.title = `You clicked ${count} times`;
    })
    
    const onClick0 = () => {
      setCount(count + 1)
    }
    
   return (
      <div>
        <div>{count}</div>
        <button onClick={() => onClick0()}>典型副作用实例,修改浏览器的标题</button>
      </div>
   )
}

以上示例的功能是:点击按钮,通过setCount(count + 1) 让count的值每次 +1,然后我们使用了 useEffect() 在DOM更新后(useEffect是在DOM更新后才执行,后边会说到)修改页面标题。这个修改页面标题的功能就是这个函数的副作用。

执行流程:

点击操作 -> 改变count的值 -> rende(渲染) -> useEffect(执行副作用修改标题)

用上述代码解释一下就是:函数内部onClick0()内部执行更改count的操作)与外部互动use Effect()内部做了修改外部DOM标题的操作),产生了运算之外的结果。

以下几种方式都认为是副作用

  1. 引用外部变量
  2. 调用外部函数
  3. 修改DOM
  4. 修改全局变量
  5. 计时器
  6. 存储相关
  7. 网络请求

注意点: 在类组件中使用副作用,通常是在 componentDidMount 和 componentDidUpdate 中处理副作用。在 componentDidUpdate 执行之后才会构建真实DOM树。

接着上边修改浏览器标题的代码,如果要在类组件中使用副作用,伪代码如下:

componentDidMount() {
  document.title = `You clicked ${this.state.count} times`;
}
componentDidUpdate() {
  document.title = `You clicked ${this.state.count} times`;
}

根据上述对比,我们可以得出:

  1. 函数组件中的 useEffect 可以视为 componentDidMountcomponentDidUpdate 的组合。
  2. componentDidUpdate 在执行后才会构建真实DOM树useEffect的执行是在构建完真实DOM树之后

副作用的执行是异步的

我们先来看一段代码

import {useEffect} from "react"

const UserEffectDemo = () => {

  console.log("render1")

  useEffect(() => {
    console.log("useEffect");
  })

  console.log("render2")
  return (
    <div>
    </div>
  )
}
export default UserEffectDemo

输出结果如下

image.png

可以看到,useEffect是最后输出的,

结论:useEffect异步的闭包函数,在render渲染完成之后才会执行useEffect

副作用如何清除

先前,我们理解了怎样定义不需要任何清除的副作用。但是,有时候我们需要在执行副作用函数的同时执行清除操作,以免造成内存泄漏。

类组件中的示例

在类组件中我们通过组件的 生命周期 函数 componentWillUnmount 来执行移除副作用。

我们用伪代码看一下:

componentDidMount() {
  this.timerID = setInterval(
    () => this.tick(),
    1000
  );
}

componentWillUnmount() {
  clearInterval(this.timerID);
}

上述代码可以看出:我们在 componentDidMount 中通过setInterval()创建了计时器,在 componentWillUnmount 中通过 clearInterval(this.timerID) 取消了倒计时操作。

函数组件中的示例

在函数组件中通过 useEffect 中的 return 来移除副作用。

我们还是用一段代码来了解一下:


import {useEffect, useRef, useState} from "react"

const UserEffectDemo = () => {

  const [count2, setCount2] = useState(0);
  
  const timer1 = useRef<number>();

  useEffect(() => {
    timer1.current = window.setInterval(() => {
        setCount2(count2 + 1)
    }, 2000)
    console.log("useEffect");

    return () => {

      console.log("clear useEffect");

      clearInterval(timer1.current);
    }
  })

  const onClick2 = () => {
    setCount2(count2 + 1)
  }

  const onClick3 = () => {
    clearInterval(timer1.current)
  }

  return (
    <div>
      <div>{count2}</div>
      <button onClick={() => onClick2()}>UseEffect计时器并清除副作用</button>
      <button onClick={() => onClick3()}>停止计时器</button>
    </div>
  )
}

export default UserEffectDemo

如上代码,我们在副作用函数中启动了一个每隔两秒执行一次的计时器,在 return 函数中通过执行clearInterval(timer1.current); 来停止计时器操作。

useEffect 中的 return 就是清理函数,清理函数是在除首次render构建之后的每一次运行副作用函数(每次执行 useEffect 之前会先执行)

执行流程:

1次:render(渲染) -> useEffect

第2次:state(状态变化)-> render(渲染)-> 清理函数 -> useEffect
...
第n次:state(状态变化)-> render(渲染)-> 清理函数 -> useEffect

通过上述执行流程可以看出来,在首次渲染的时候只会执行副作用函数,在之后的状态变化render(渲染)之后会执行清理函数。也就是说在副作用函数已经创建的情况下在重新创建副作用函数前会先执行清除操作,清除掉之前的操作后才会继续执行创建动作。

结论:清理函数在首次执行副作用函数前并不会执行,而在之后的副作用函数执行前会先执行 清理函数

注意点:每一次执行useEffect(副作用函数),都是新的不同的函数,也就是说 useEffect 每次运行都会重新创建。

另外我们也可以试试,当组件移除的时候会不会也执行清理函数

我们定义一个字子组件,通过父组件的按钮来操作字组件的显示隐藏,代码如下

import {useEffect, useRef, useState} from "react"

const Child = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    //真实DOM构建以后才会执行
    console.log("useEffect 执行");
    return () => {
      console.log("clear useEffect");
    }
  })

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

  return (
    <div>
      <div>{count}</div>
      <button onClick={() => onClick0()}>ADD</button>
    </div>
  )
}

const UserEffectDemo1 = () => {
  const [visible, setVisible] = useState(true)
  return (
    <div>
      {
        //visible为true才渲染
        visible && <Child />
      }
      <button onClick={() => setVisible(!visible)}>显示/隐藏</button>
    </div>
  )
}
export default UserEffectDemo1

运行上边的代码可知,当点击显示/隐藏按钮,在子组件销毁的情况下,清理函数中的log日志clear useEffect也会执行,

结论:清理函数在组件销毁的时候也会执行。

重复循环渲染

先来看一段文章函数组件中的示例中的一段代码:

import {useEffect, useRef, useState} from "react"

const UserEffectDemo2 = () => {

  const [count2, setCount2] = useState(0);

  const timer1 = useRef<number>();

  useEffect(() => {
    console.log("开始计时器");

    timer1.current = window.setInterval(() => {

    console.log("进入计时器");
    
    setCount2(count2 + 1)
    
  }, 2000)

    return () => {

      console.log("清除计时器------------------");

      clearInterval(timer1.current);
    }
  })
  return (
    <div>
      <div>{count2}</div>
    </div>
  )
}

export default UserEffectDemo2

输出结果如下图:

image.png

通过输出我们看到,输出中count每+1一次,都会执行开启计时器进入计时器,并且每次都会销毁,这种情况肯定是不合理的,那这时候我们只想有一个计时器,不用每次创建新的计时器。

也就是对应的 类组件 中的执行流程,我们希望只在 初次渲染componentDidMount) 创建计时器,不在更新 (componentDidUpdate) 的时候每次都创建新的计时器。要实现这一点,我们可以向 useEffect 函数中传递第二个参数,它是该 effect 的依赖数组参数。

1. effect 依赖数组参数

我们试着修改一下上述代码,给 useEffect 增加空数组,看打印结果

import {useEffect, useRef, useState} from "react"

const UserEffectDemo2 = () => {

  const [count2, setCount2] = useState(0);

  const timer1 = useRef<number>();

  useEffect(() => {
    console.log("开始计时器");

    timer1.current = window.setInterval(() => {

    console.log("进入计时器");

    setCount2(count2 + 1)

  }, 2000)

    return () => {

      console.log("清除计时器------------------");

      clearInterval(timer1.current);
    }
  }, [])
  return (
    <div>
      <div>{count2}</div>
    </div>
  )
}

export default UserEffectDemo2

输出结果如下图:

image.png

通过上述输出可以看到,开始计时器执行了一次,之后每次计时器只执行进入计时器,只有在清除计时器之后才会重新开始计时器

不知大家注意到没,上面的日志中,我执行了三次进入计时器,但是 count 打印出来的值却是 1,准确来说是第一次进入计时器之前 count 的值是0,之后再进入会一直是 1,这又是为什么呢?

我们分析一下上述代码,可以看到首次执行时 count 的值是 0,也就说在render(渲染)完成之后初次进入计时器时count + 1 = 1, 第二次render(渲染)的时候setCount 中的count还是 初始化useState 中的 count0,拿的不是最新的count,也就是说setCount(count + 1) 中的 count 永远都是 0。这时候要怎么改呢?我们可以使用依赖外部的state来实现和使用箭头函数式更新形式实现。

2. 依赖频繁改变

有时候我们已经设置了依赖,但是发现还是会无限重复。有可能是你的依赖就是频繁变化的,即在改变状态的方法中用到了状态,比如:

...
  useEffect(() => {
    console.log("开始计时器");

    timer1.current = window.setInterval(() => {

    console.log("进入计时器");

    setCount2(count2 + 1)

  }, 2000)

    return () => {

      console.log("清除计时器------------------");

      clearInterval(timer1.current);
    }
  }, [count2])
...

输出结果如下图:

image.png

通过输出我们看到,输出中count每+1一次,都会执行开启计时器进入计时器重新渲染,并且每次都会销毁,要解决这个问题,我们可以使用 setState箭头函数式 更新形式实现。它允许我们制定 state 该 如何 改变而不用引用当前 state:

...
  useEffect(() => {
    console.log("开始计时器");

    timer1.current = window.setInterval(() => {

    console.log("进入计时器");

    setCount2(count2 => count2 + 1)  //在这里不依赖于外部的 `count` 变量

  }, 2000)

    return () => {

      console.log("清除计时器------------------");

      clearInterval(timer1.current);
    }
  }, []) //我们的 effect 不适用组件作用域中的任何变量
...

输出结果如下图:

image.png

结论:使用setState箭头函数式 更新 state 不会引起组件 effect 重新执行

总结

1: useEffect异步的闭包函数,在render渲染完成之后才会执行useEffect

2: useEffect操作是相对于操作state而言的。每次因为state的改变,都会有一次对应副作用函数的执行时机,如果state 多次改变,那么就有多次对应副作用的执行时机。

3: 函数组件中的 useEffect 可以视为 componentDidMount 和 componentDidUpdate 的组合。

4: componentDidUpdate 在执行后才会构建真实DOM树useEffect的执行是在构建完真实DOM树之后

5: 清理函数在首次执行副作用函数前并不会执行,而在之后的副作用函数执行前会先执行 清理函数

6: 依赖参数([])值的改变会导致useEffect函数重新执行。

7: 使用setState箭头函数式 更新 state 不会引起组件 effect 重新执行.

参考

【零基础学习hooks】react_hooks从入门到精通

useEffect 完整指南

解决useEffect重复调用问题