React Hook 笔记

3,218 阅读11分钟

本文用于记录自己学习和使用 React Hook 笔记。
Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。Hook 在 class 内部是不起作用的。但你可以使用它们来取代 class 。

Hook 可以做什么?

  • 在函数组件中使用 state (useState)
  • 将组件的状态逻辑进行抽离(自定义 hook)

初始化

使用 create-react-app 初始化支持 TypeScript 语法的工程。

npx create-react-app base-demo --typescript

useState

在 Hook 提出之前,函数组件(无状态组件)通常用于 UI 渲染,通过 props 传递的数据进行展示。如果组件想要有自己的状态 ( state ) 数据的话,只能通过 class 组件来实现了。useState 提供了函数组件中保留自己状态的方法。用法如下:

import React, {useState} from 'react';
const Counter: React.FC = ()=>{
    const [counter, setCounter] = useState(0);
    return <div>
        <span>{counter}</span><button onClick={()=>{setCounter(counter+1)}}>+1</button>
    </div>
}
export default Counter;

counter 是定义的变量,setCounter 用来修改 counter 的值的方法。

useEffect

Effect Hook 可以让你在函数组件中执行副作用操作。

如果你熟悉 React class 的生命周期函数,你可以把 useEffect Hook 看做 componentDidMount,componentDidUpdate 和 componentWillUnmount 这三个函数的组合。

无需清除的 effect

我们希望在组件加载和更新时执行同样的操作。从概念上说,我们希望它在每次 渲染 之后执行 —— 但 React 的 class 组件没有提供这样的方法。即使我们提取出一个方法,我们还是要在两个地方调用它。

import React, { useState, useEffect } from 'react';
const Counter: React.FC = ()=>{
    const [counter, setCounter] = useState(0);
    const [flag, setFlag] = useState(true);
    useEffect(()=>{
        document.title = `You clicked ${counter} times`;
        console.log("useEffect...")
    });
    return <div>
        <span>{counter}</span>
        <button onClick={()=>{setCounter(counter+1)}} style={{marginLeft: "20px"}}>+1</button>
        <button onClick={()=>{setFlag(!flag)}} style={{marginLeft: "20px"}}>{flag ? 'ON':'OFF'}</button>
    </div>
}
export default Counter;
  • useEffect 函数在每次 渲染 的时候都会执行,啥意思? 例子中,即使我们修改的是 flag 的值的时候,我们定义的 useEffect 函数也会被执行。这不是我们想要的,useEffect 函数中第二个参数可以用来控制执行的时机。

控制 useEffect 执行

useEffect 其实有两个参数:

  • 如果第二个参数默认不写的话,useEffect(()=>{});那么 useEffect 函数每次页面 渲染 都会执行。
  • 如果第二个参数是空数组 useEffect(()=>{}, []); 那么说明 useEffect 函数的执行跟其他变量无关,只会在第一次页面渲染时执行。
  • 如果 useEffect 函数的执行跟某个变量相关,变量变化的时候需要相应的一些操作,那么可以将该变量传入数组中。useEffect(()=>{}, [counter]);
  useEffect(()=>{
      document.title = `You clicked ${counter} times`;
      console.log("useEffect...")
  }, [counter]);

这样页面只有 counter 数据变化的时候才会执行 useEffect 函数。

与 componentDidMount 或 componentDidUpdate 不同,使用 useEffect 调度的 effect 不会阻塞浏览器更新屏幕,这让你的应用看起来响应更快。

需要清除的 effect

还有一些副作用是需要清除的。例如 document 上绑定的事件,setTimeout 函数等。这种情况下,清除工作是非常重要的,可以防止引起内存泄露!现在让我们来比较一下如何用 Class 和 Hook 来实现。以记录鼠标点击位置为例来说明,在 Class 组件中,我们是这么实现的:

import React from 'react';
class MouseTrigger extends React.Component{
    constructor(props){
        super(props);
        this.state = {
            position: {
                x: 0,
                y: 0
            }
        }
    }
    handleMouseMove = (e)=>{
        this.setState({
            position: {
                x: e.clientX,
                y: e.clientY
            }
        })
    }
    // 添加监听事件
    componentDidMount(){
        document.addEventListener("click", this.handleMouseMove);
    }
    // 取消监听事件
    componentWillUnmount(){
        document.removeEventListener("click", this.handleMouseMove);
    }
    render(){
        return (
            <div>
                <p>Mouse position is ({this.state.position.x}, {this.state.position.y})</p>
            </div>
        )
    }
}
export default MouseTrigger;

使用 useEffect 方式实现, useEffect 函数返回一个函数,用于清除 “副作用”。

import React, { useState, useEffect} from "react";
const MouseTrigger: React.FC = ()=>{
    const [position, setPosition] = useState({x: 0, y:0})
    const handleMouseMove = (e: MouseEvent)=>{
        setPosition({
            x: e.clientX,
            y: e.clientY
        })
    }
    useEffect(()=>{
        document.addEventListener("click", handleMouseMove);
        return ()=>{
            document.removeEventListener("click", handleMouseMove);
        }
    }, [])
    return <p>
        Mouse position is ({position.x}, {position.y})
    </p>
}
export default MouseTrigger;

useEffect(()=>{... return ()=>{}}, []) 中,第二个参数是空数组 [] ,使得 useEffect 函数只在第一次页面加载执行;返回一个函数,会在组件卸载的时候执行,清除掉添加的 “副作用”。

没有对比,就没有伤害。明显使用 useEffect 定义的函数组件逻辑更加清晰简洁,给人一种清清爽爽的感觉。

自定义 Hook

通过自定义 Hook,可以将组件逻辑提取到可重用的函数中。在自定义 Hook 提出之前,我们是怎么实现的呢?常用的方式是 2 种,HOC 和 render props。我们一起看一下用法的有哪些不一样呢

HOC

HOC 是 High Order Component 的缩写,意思是 “高阶组件” 。高阶组件本质是一个函数,接收一个组件作为参数,返回一个新的组件。
业务中,往往会有请求数据的需求。在数据请求时,展示 loadin 动画,请求完成显示对应的数据。这个逻辑就可以进行剥离,如下:

// FetchData.js
import React from 'react';
import axios from 'axios';
const FetchData = (Component, url)=>{
    class WithFetchData extends React.Component{
        constructor(props){
            super(props);
            this.state = {
                data: [],
                isLoading: true
            }
        }
        componentDidMount(){
            this.setState({
                isLoading: true
            });
            axios.get(url).then(res=>{
                if(res.status===200){
                    this.setState({
                        data: res.data,
                        isLoading: false
                    })
                }
            })
        }
        render(){
            const {isLoading, data} = this.state;
            return <>
                { isLoading ? <p>data is loading</p>: <Component data={data}/>}
            </>
        }
    }
    return WithFetchData;
}
export default FetchData;

FetchData 就是一个高阶组件,本质是一个函数,接收一个组件,然后返回一个新的组件。在需要的组件中使用:

// ShowData.js
import React from 'react';
import FetchData from './FetchData';
const ShowData = (props)=>{
    return <>
        {props.data.map((item, key)=><p key={key}>{item.name}, {item.price}</p>)}
    </>
}
export default FetchData(ShowData, "/api/mockData.json");

HOC 其实是在将组件逻辑提取到了父组件中,包裹在组件外面,这样内部组件就可以复用使用外部定义的数据和逻辑了。

render props

render props 是一种利用父子组件传递的技巧。通过 render 函数将要显示的组件传递给父组件中。

// RenderPropsDemo.js
import React from 'react';
import PropTypes from 'prop-types';
import axios from 'axios';
class RenderPropsDemo extends React.Component{
    constructor(props){
        super(props);
        this.state = {
            data: [],
            isLoading: true
        }
    }
    componentDidMount(){
        this.setState({
            isLoading: true
        });
        const {url} = this.props;
        axios.get(url).then(res=>{
            if(res.status===200){
                this.setState({
                    data: res.data,
                    isLoading: false
                })
            }
        })
    }
    render(){
        const {isLoading, data} = this.state;
        const {render} = this.props;
        return (
            <>
                { isLoading ? <p>data is loading</p> : <>{render(data)}</>}
            </>
        )
    }
}
RenderPropsDemo.prototypes = {
    render: PropTypes.func.isRequired
}
export default RenderPropsDemo;

使用:

// ShowData.js
import React from 'react';
import RenderPropsDemo from './RenderPropsDemo';

const ShowData = ()=>{
    const render = (data)=>{
        return data.map((item, key)=><p key={key}>{item.name}, {item.price}</p>)
    }
    return <>
        <RenderPropsDemo render={render} url="/api/mockData.json"/>
    </>
}
export default ShowData;

自定义 Hook

通过自定义 Hook,可以将组件逻辑提取到可重用的函数中。相比 HOC 和 render props 的方式,自定义 Hook 是纯净的函数模式,不牵扯父子组件。逻辑更加清晰。

//useFetchData.tsx
import { useState, useEffect } from 'react';
import axios from 'axios';
// 默认只在第一次发起请求
const useFetchData = (url: string, deps: any[] = [])=>{
    const [data, setData] = useState<any>([]);
    const [loading, setLoading] = useState(false);
    useEffect(()=>{
        setLoading(true);
        axios.get(url).then(res=>{
            if(res.status===200){
                setData(res.data);
                setLoading(false);
            }
        })
    }, deps);
    // 返回
    return [data, loading];
}

export default useFetchData;

使用:

//FetchData.tsx
import React from 'react';
import useFetchData from './useFetchData';
interface IShowDataResult{
    name: string;
    price: number;
}
type resultType = Array<IShowDataResult>;
const FetchData: React.FC = ()=>{
    // 直接执行 useFetchData 获取
    const [data, loading] = useFetchData("/api/mockData.json");
    return <>
        {
            loading ? <p>data is loading</p> : 
            <>
                {data.map((item:IShowDataResult, key: number)=><p key={key}>{item.name}</p>)}
            </>
        }
    </>
}

export default FetchData;

自定义 Hook 实现的效果和 Vue 3.0 中的 Composition API 思想是相通的。就是将组件中重复的逻辑剥离出来,以更简洁清晰的组织方式去管理代码。

自定义 Hook 使用注意点:

  • 自定义 Hook 必须以 “use” 开头。 这个约定非常重要。不遵循的话,由于无法判断某个函数是否包含对其内部 Hook 的调用,React 将无法自动检查你的 Hook 是否违反了 Hook 的规则。
  • 两个组件中使用相同的 Hook 不会共享 state。自定义 Hook 是一种重用状态逻辑的机制(例如设置为订阅并存储当前值),所以每次使用自定义 Hook 时,其中的所有 state 和副作用都是完全隔离的。

useRef

获取最新的值

看下面的例子,对比 useState 和 useRef 使用的不同:

import {useState, useRef, useEffect} from 'react';
const Counter = ()=>{
    const [counter, setCounter] = useState(0);
    const likeRef = useRef(0);
    const handleAlert = ()=>{
        setTimeout(()=>{
            alert(counter);
            alert(likeRef.current);
        }, 2000);
    }
    return (
        <div>
            <span>{counter}</span>
            <button onClick={()=>{setCounter(counter+1); likeRef.current++;}} style={{marginLeft: '20px'}}>+1</button>
            <button onClick={handleAlert} style={{marginLeft: '20px'}}>alert</button>
        </div>
    )
}
export default Counter;

当我们点击 alert 按钮的时候,可以发现 counter 的值并不是最新的,而 likeRef.current 却是最新的。

这是为什么呢?

这是因为我们修改状态的时候, React 每次会重新渲染组件,每一次渲染都会拿到独立的 counter 的值,并且重新渲染一个 handleAlert 函数。每一个 handleAlert 函数闭包中保存着渲染时的 counter。也就是上一次的渲染不会和下一次有什么关联。下一次的渲染也不会影响上一次的数据。这个是 state 和 props 的特点。

而 useRef 返回一个可变的 ref 对象。ref 在所有 render 都保持着唯一的引用,所以 ref 取值拿到的都是 最终 的状态,而不会隔离。

获取

useRef 可以用于获取 DOM,对DOM进行操作。 一个例子:页面加载时 input 输入框自动聚焦。

import {useState, useRef, useEffect} from 'react';
const Counter = ()=>{
    const inputRef = useRef(null);
    useEffect(()=>{
        if(inputRef && inputRef.current){
            inputRef.current.focus();
        }
    }, [])
    return (
        <div>
            <input type="text" ref={inputRef}/>
        </div>
    )
}
export default Counter;

useContext

在全局定义的state,在子组件中可以获取。当这些 state 修改的时候,会触发使用的子组件重新渲染。

  • React.createContext(themes.light); 初始化全局状态,默认值 themes.light
  • <ThemeContext.Provider value={themes.dark}> 赋值 themes.dark
  • useContext 子组件使用全局状态。
// App.js 中初始化全局状态
import './App.css';
import React, {useState} from 'react';
import Counter from "./Counter";

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
}

const ThemeContext = React.createContext(themes.light);
// 需要导出出去给子组件使用。
export {ThemeContext};

function App() {
  const [curTheme, setCurTheme] = useState(true);
  const changeTheme = ()=>{
    setCurTheme(!curTheme);
  }
  return (
    <ThemeContext.Provider value={curTheme ? themes.light: themes.dark}>
      <Counter/>
      <button onClick={changeTheme}>change</button>
    </ThemeContext.Provider>
  );
}
export default App;

在子组件中使用:

import {useState, useRef, useEffect, useContext} from 'react';
import { ThemeContext } from './App';
const Counter = ()=>{
     //部分代码省略
    const theme = useContext(ThemeContext);
    const themeStyle = {
        background: theme.background, 
        color: theme.foreground
    }
    return (
        <div>
            <input type="text" style={themeStyle}/>
        </div>
    )
}
export default Counter;

useMemo 和 useCallback

背景

一个问题引发的一系列优化故事,看下面的例子:

class Foo extends React.Component{
    render(){
        console.log("render...")
        return (
            <p>{this.props.count}</p>
        )
    }
}

class App extends React.Component{
    constructor(props){
        super(props);
        this.state = {
            count: 0,
            double: 1
        }
    }
    changeDouble = ()=>{
        this.setState({
            double: this.state.double * 2
        })
    }
    render(){
        return (
            <>
                <Foo count={this.state.count}/>
                <p>count: {this.state.count}, double: {this.state.double}</p>
                <button onClick={this.changeDouble}>Double</button>
            </>
        )
    }
}

正常我们想要的效果是子组件 Foo 应该只有在父组件 count 的值改变的时候才需要重新 render, 但实际中每次父组件一旦 render 自己,子组件也会被 render。当我们修改 double的值的时候,子组件也会重新 render。要怎么解决这个问题呢?有以下的方法:

  • 方案一: shouldComponentUpdate(nextProps, nextState) 生命周期函数中重新定义是否需要 update
  • 方案二: 使用 PureComponent 组件解决 方案一: shouldComponentUpdate(nextProps, nextState) 生命周期函数返回一个 boolean 值,默认是 true,意思是组件每次都会重新 render。我们可以在这个函数中根据将要修改的 nextProps 值和当前 props 的值进行对比,如果一样,也就是没有改变,则返回 false,组件不会被重新渲染。否则,返回 true,组件需要重新渲染。
class Foo extends React.Component{
    shouldComponentUpdate(nextProps, nextState){
        if(nextProps.count === this.props.count){
            return false;
        }
        return true;
    }
}

方案二: 使用 PureComponent 组件解决。

class Foo extends React.PureComponent{
    render(){
        console.log("render...")
        return (
            <p>{this.props.count}</p>
        )
    }
}

PureComponent 组件内部是在 shouldComponentUpdate 生命周期函数中对 nextProps 和 this.props 进行了浅层比较。需要注意的是因为是浅层比较,如果我们传给子组件的是回调函数,会有问题。看下面的例子:

class App extends React.Component{
    render(){
        return (
            <>
                <Foo count={this.state.count} cb={()=>{}}/>
                <button onClick={this.changeDouble}>Double</button>
            </>
        )
    }
}

每次父组件 APP 去修改 double 的值的时候,导致自身 render 函数被执行。render 函数执行的时候,cb={()=>{}} 会重新执行,所以每次都是不同的回调对象。这就导致子组件重复渲染。

解决方案:我们需要将回调函数作为一个 class 中的一个变量,可以避免这种问题。

class App extends React.Component{
    cb = ()=>{}
    render(){
        return (
            <>
                <Foo count={this.state.count} cb={this.cb}/>
            </>
        )
    }
}

至此,在 class 组件中可以避免子组件无用的重复渲染的问题了。但是在函数组件中,这些问题依然存在。

我们通常把自身没有状态的组件使用函数组件表达。函数组件没有shouldComponentUpdate 和 PureComponent,那要怎么实现呢?对应的解决方案就是 memo 了。

import React, {memo} from 'react';
const Foo = memo((props)=> {
    console.log("render...")
    return (
        <p>{props.count}</p>
    )
})

这就完事了吗?并没有。在 React Hook 使用过程中,我们的函数组件有了 state 的特性,那这个时候我们的父组件 App 不再只是 class 组件了,也可以是函数组件。如下:

const Foo = memo((props)=> {
    console.log("render...")
    return (
        <p>{props.count}</p>
    )
})
const App = ()=>{
    const [count, setCount] = useState(0);
    const [double, setDouble] = useState(1);
    const cb = ()=>{}
    return (
        <>
            <Foo count={count} cb={cb}/>
            <p>count: {count}, double: {double}</p>
            <button onClick={()=>{setDouble(double*2)}}>Double</button>
        </>
    )
}

可以看到即使我们把回调函数使用一个变量存储,每次子组件还是会重复渲染。这是因为 App 本身是函数组件,每次执行也是相互独立的。每次变量 cb 不会被保留。这个时候 useMemo 就要粉墨登场了。

useMemo

返回一个新的数据

useMemo 的语法同 useEffect 是一样的。但是 useMemo 的执行时机在渲染前执行,这点跟 useEffect 不一样。

const App = ()=>{
    const [count, setCount] = useState(0);
    const [double, setDouble] = useState(1);
    const sum = useMemo(()=>{
        return count + double;
    }, [count])

    return (
        <>
            <p>count: {count}, double: {double}, sum:{sum}</p>
            <button onClick={()=>{setDouble(double*2)}}>Double</button>
            <button onClick={()=>{setCount(count+1)}}>count++</button>
        </>
    )
}

当 count 和 double 任意发生变化的时候, sum 也会修改。有点 Vue 中 computed 的感觉。

返回一个函数

回到前面的问题,那就是如果父组件给子组件传递函数的时候,在函数组件中,怎么保证子组件不会重复渲染呢?那就是使用 useMemo 返回一个函数,useMemo(()=>{return ()=>{}}, []) 第二个参数传入空数组 [],表示只会在第一次触发执行,这样可以保证函数是同一个,子组件也不会重新渲染。

const App = ()=>{
    const [count, setCount] = useState(0);
    const [double, setDouble] = useState(1);
    const cb = useMemo(()=>{
        return ()=>{
           console.log("cb...")
        }
    }, [])
    return (
        <>
            <Foo count={count} cb={cb}/>
        </>
    )
}

那么, useCallback 怎么用呢? 那就是 useMemo 返回一个函数的时候,我们可以使用 useCallback 进行简写。如下

const App = ()=>{
    const [count, setCount] = useState(0);
    const [double, setDouble] = useState(1);
    const cb = useCallback(()=>{
        console.log("cb..")
    }, [])
    return (
        <>
            <Foo count={count} cb={cb}/>
        </>
    )
}

最后

如果有错误或者不严谨的地方,烦请给予指正,十分感谢。如果喜欢或者有所启发,欢迎点赞,对作者也是一种鼓励。