学习React钩子函数,以useEffect为例

前言

最近一段时间中接手了同事写的一些 React 代码,其中很多地方采用了 React hooks 钩子和函数式编程的思想来书写组件。刚开始理解起来或许有些困难,后面随着开发的深入,发现这些新的 API 和新思想,还挺方便数据状态和业务逻辑的理解、管理、维护和扩展。

尽管 React 16中的 hooks相对于 React 17已经是很久远的新鲜事物,奈何基础薄弱,接下来还是想从头开始梳理一下React hook 、函数式编程和函数式组件相关的知识。

一、React 的两套 API

React 有两套 API:类(class)API 和基于函数的钩子(hooks)API。任何一个组件,都可以通过刚刚所说的两种方式来书写。

类(class)的写法:

class Welcome extends React.Component {
    render() {
        return <h1>Hello,{this.props.name}</h1>
    }
}

钩子(hook)的写法,就是函数:

function Welcome(props) {
    return <h1>Hello,{props.name}</h1>
}

这两种写法,作用完全一样。但官方更推荐是用钩子(函数),而不是类。因为钩子更简洁,代码量少,用起来比较“轻”,而类比较“重”。而且,钩子是函数,更符合 React 函数式的本质。

例如类组件1与函数组件2的代码量对比。对于复杂的组件就差的更多了。但是对于初学者,钩子的灵活性较大,不太容易理解,很容易写出混乱不堪、无法维护的组件,不像类组件那样有很多强制的语法约束和规范。

// 类组件1
import React,{Component} from 'react';
class ClassComponent extends Component {
    state = {number:0}
    componentDidMount() {
        this.setState({number:10})
    }
    render() {
        const {number} = this.state
        return (
            <div>
                <p>Class Component</p>
                <p>State is {number}.</p>
            </div>
        )
    }
}
export default ClassComponent
// 函数组件2
import React from 'react'
const FunctionComponent = ({number:10}) => {
    return (
        <div>
               <p>Class Component</p>
            <p>State is {number}.</p>
        </div>
    )
}
export default FunctionComponent

二、类和函数的差异

类组件和函数组件拥有不同的写法,也代表着不同的编程方法论。

类(class)是数据与逻辑的封装。组件的状态和操作方法是封装在一起的。如果选择了类的写法,就应该把相关的数据和操作,都放在同一个 class 中。

而对于函数来说,只应该做一件事,就是返回一个值。如果你有多个操作,每个操作都应该写成一个单独的函数。而且,数据的状态应该与操作方法分离。根据这种理念,React 的函数组件只应该做一件事,即返回组件的 HTML 代码,而没有其他功能。

function Welcome(props) {
    return <h1>Hello,{props.name}</h1>
}

这里的函数只做一件事,就是根据输入的参数,返回组件的 HTML 代码。这种只进行单纯的数据计算(换算)的函数,在函数式编程里面成为“纯函数”(pure function)。

三、副效应是什么?

纯函数只能进行计算,而对于不涉及计算的操作,比如生成日志、存储数据、改变应用状态等等,应该写在哪里呢?

函数式编程将那些跟数据计算无关的操作,都称为“负效应”(side effect)。如果函数内部直接包含产生副效应的操作,那么这个函数就不再是纯函数了,称之为不纯的函数。

纯函数内部只有通过间接的手段,即通过其他函数调用,才能包含副效应。

四、钩子(hook)的作用

钩子(hook)就是 React 函数组件的副效应解决方案,用来为函数组件引入副效应。在函数组件的主体部分只应该用来返回组件的 HTML 代码,所有其他的操作即副效应都必须通过钩子引入。

由于副效应非常多,所以钩子有许多种。React 为许多常见的操作(副效应),都提供了专用的钩子。

  • useState:保存状态
  • useContext:保存上下文
  • useRef:保存引用

上面的钩子,都是引入某种特定的副效应,而 useEffect() 是通用的副效应钩子。找不到对应的钩子时,就可以用它。

五、useEffect() 的用法

useEffect() 本身是一个函数,由 React 框架提供,在函数组件内部调用即可。举例来说,如果希望组件加载以后,网页标题(document.title)会随之改变。那么改变标题这个操作,就是副效应,必须通过 useEffect() 来实现。

import React,{useEffect} from 'react'
function Welcome(props) {
    useEffect(() => {
        document.title = "加载完成"
    })
    return <h1>Hello,{props.name}</h1>
}

上面的例子中,useEffect() 的参数是一个函数,它就是所要完成的副效应(改变网页标题)。组件加载以后,React 就会执行这个函数。

useEffect() 的作用就是指定一个副效应函数,组件每渲染一次,该函数就自动执行一次。组件首次在网页 DOM 加载后,副效应函数也会执行。

六、useEffect() 的第二个参数

当不希望 useEffect() 每次都渲染执行,这时可以使用它的第二个参数,使用一个数组指定副效应函数的依赖项,只有依赖项发生了变化,才会重新渲染。

function Welcome(props) {
    useEffect(() => {
        document.title = `Hello,${props.name}`
    },[props.name])
    return <h1>Hello,{props.name}</h1>
}

上面的例子中,useEffect() 的第二个参数是一个数组,制定了第一个参数即副效应函数的依赖项(props.name)。只有该变量发生变化时,副效应函数才会执行

如果第二个参数是空数组,就表明副效应函数没有任何依赖项。因此,副效应函数这时只会在组件加载进入 DOM 后执行一次,后面组件重新渲染,就不会再执行了。原因是:副效应不依赖任何变量,无论变量如何变化,副效应函数执行的结果都不会发生改变,所以运行一次就够了。

七、useEffect() 的用途

只要是副效应,都可以使用 useEffect() 引入,它常见的用途主要有以下几种。

  • 获取数据(data fetching)
  • 事件监听或订阅(setting up a subscription)
  • 改变 DOM(changing the DOM)
  • 输出日志(logging)

下面是从远程服务器获取数据的例子。

import React, { useState, useEffect } from 'react';
import axios from 'axios';
function App() {
    const [data,setData] = useState({hits: []});
    useEffect(() => {
        const fetchData = asyn () => {
            const result = await axios (url);
            setData(result.data);
        }
        fetchData();
    },[]);
    return (
        <ul>
            {
                data.hits.map(item => (
                    <li key={item.objectID}>
                        <a href={item.url}>{item.title}</a>
                    </li>
                ))
            }
        </ul>
    );
}
export default App;

上面的例子中,useState() 用来生成一个状态变量(data),保存获取的数据; useEffect() 的副效应函数内部有一个 async 函数,用来从服务器异步获取数据。拿到数据后,再用 setData() 触发组件的重新渲染。

由于获取数据只需要执行一次,所以上面 useEffect() 的第二个参数为一个空数组。

八、useEffect() 的返回值

副效应是随着组件的加载而发生的,那么组件卸载时,可能需要清理这些副效应。 useEffect() 允许返回一个函数,在组件卸载时,执行该函数,清理副效应。如果不需要清理副效应,useEffect() 就不用返回任何值。

useEffect(() => {
    const subscription = props.source.subscribe();
    return () => {
        subscription.unsubscribe();
    }
},[props.source])

上面的例子中,useEffect() 在组件加载时订阅了一个事件,并且返回一个清理函数,在组件卸载时取消订阅。

实际使用过程中,由于副效应函数是每次渲染都会执行,所以清理函数不仅会在组件卸载时执行一次,每次副效应函数重新执行之前,也会执行一次,用来清理上一次渲染的副效应。

九、useEffect() 的注意点

使用 useEffect() 时,需要注意如果有多个副效应,应该调用多个 useEffect() ,而不应该合并写在一起。例如下面第一种,即为错误的写法。

function App() {
    const [varA,setVarA] = useState(0);
    const [varB,setVarB] = useState(0);

    useEffect(() => {
     const timeoutA = setTimeout(() => setVarA(varA + 1),1000);
     const timeoutB = setTimeout(() => setVarA(varB + 2),2000);
     return () => {
         clearTimeout(timeoutA);
         clearTimeout(timeoutB);
     }   
    },[varA,varB]);

    return <span>{varA}, {varB}</span>
}

上面的例子是错误的写法,副效应函数里面有两个定时器,但它们并没有关系,其实是两个不相关的副效应,不应该写在一起。正确的做法是将它们分开写成两个 useEffect() 。

function App() {
    const [varA,setVarA] = useState(0);
    const [varB,setVarB] = useState(0);

    useEffect(() => {
     const timeoutA = setTimeout(() => setVarA(varA + 1),1000);
     return () => {
         clearTimeout(timeoutA);
     }   
    },[varA]);

    useEffect(() => {
     const timeoutB = setTimeout(() => setVarA(varB + 2),2000);
     return () => {
         clearTimeout(timeoutB);
     }   
    },[varB]);

    return <span>{varA}, {varB}</span>
}