React Hook 笔记

2,831 阅读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

获取最新的值

看下面的例子,对比 useStateuseRef 使用的不同:

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。也就是上一次的渲染不会和下一次有什么关联。下一次的渲染也不会影响上一次的数据。这个是 stateprops 的特点。

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 组件中可以避免子组件无用的重复渲染的问题了。但是在函数组件中,这些问题依然存在。

我们通常把自身没有状态的组件使用函数组件表达。函数组件没有shouldComponentUpdatePureComponent,那要怎么实现呢?对应的解决方案就是 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}/>
        </>
    )
}

最后

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