React Hook实战

语言: CN / TW / HK

一、 Hook 简介

1.1 Hook历史

在React Hook出现之前的版本中,组件主要分为两种:函数式组件和类组件。其中,函数式组件通常只考虑负责UI的渲染,没有自身的状态也没有业务逻辑代码,是一个纯函数。而类组件则不同,类组件有自己的内部状态,界面的显示结果通常由props 和 state 决定,因此它也不再那么纯洁。函数式组件,类组件有如下一些缺点:

  • 状态逻辑难以复用。在类组件中,为了重用某些状态逻辑,社区提出了render props 或者 hoc 等方案,但是这些方案对组件的侵入性太强,并且组件嵌套还容易造成嵌套地狱的问题。
  • 滥用组件状态。大多数开发者在编写组件时,不管这个组件有木有内部状态,会不会执行生命周期函数,都会将组件编写成类组件,这造成不必要的性能开销。
  • 额外的任务处理。使用类组件开发应用时,需要开发者额外去关注 this 、事件监听器的添加和移除等等问题。

在函数式组件大行其道的当前,类组件正在逐渐被淘汰。不过,函数式组件也并非毫无缺点,在之前的写法中,想要管理函数式组件状态共享就是比较麻烦的问题。例如,下面这个函数组件就是一个纯函数,它的输出只由参数props决定,不受其他任何因素影响。

function App(props) {
  const {name, age } = props.info
  return (
      <div style={{ height: '100%' }}>
        <h1>Hello,i am ({name}),and i am ({age}) old</h1>
      </div>
  )
}

在上面的函数式组件中,一旦我们需要给组件加状态,那就只能将组件重写为类组件,因为函数组件没有实例,没有生命周期。所以我们说在Hook之前的函数组件和类组件最大的区别其实就是状态的有无。

1.2 Hook 概览

为了解决函数式组件状态的问题,React 在16.8版本新增了Hook特性,可以让开发者在不编写 类(class) 的情况下使用 state 以及其他的 React 特性。并且,如果你使用React Native进行移动应用开发,那么React Native 从 0.59 版本开始支持 Hook。

并且,使用Hook后,我们可以抽取状态逻辑,使组件变得可测试、可重用,而开发者可以在不改变组件层次结构的情况下,去重用状态逻辑,更好的实现状态和逻辑分离的目的。下面是使用State Hook的例子。

import React, { useState } from "react";

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

  return (
    <div>
      <p>you clicked {count} times</p>
      <button type="button" onClick={() => setCount(count + 1)}>
        click me
      </button>
    </div>
  );
};

在上面的示例红,useState 就是一个 Hook ,即通过在函数组件里调用它来给组件添加一些内部 State,React 会在重复渲染时保留这个 State。useState 会返回一对值:当前状态和一个让你更新它的函数,你可以在事件处理函数中或其他一些地方调用这个函数。它类似 class 组件的 this.setState,但是它不会把新的 state 和旧的 state 进行合并。(我们会在使用 State Hook 里展示一个对比 useState 和 this.state 的例子)。

二、Hook 基本概念

Hook为函数式组件提供了状态,它支持在函数组件中进行数据获取、订阅事件解绑事件等等,学习React Hook之前,我们我们先理解以下一些基础概念。

2.1 useState

useState让函数组件具有了状态的能力。例如,前面用到的计数器示例就用到了useState。

function App () {
    const [count, setCount ] = useState(0)
    return (
      <div>
        点击次数: { count } 
        <button onClick={() => { setCount(count + 1)}}>点我</button>
      </div>
      )
  }

可以发现,useState使用上非常简单,第一个值是我们的 state, 第二个值是一个函数,用来修改该 state的值。useState支持指定 state 的默认值,比如 useState(0), useState({ a: 1 }),除此之外,useState还支持我们传入一个通过逻辑计算出默认值,比如。

function App (props) {
    const [ count, setCount ] = useState(() => {
      return props.count || 0
    })
    return (
      ... 
      )
  }

2.2 useEffect

Effect Hook 可以让你处理函数组件中的副作用。在React中,数据获取、设置订阅、手动的更改 DOM都可以称为副作用,可以将副作用分为两种,一种是需要清理的,另外一种是不需要清理的。比如网络请求、DOM 更改、日志这些副作用都不要清理。而比如定时器,事件监听则是需要处理的,而useEffect让开发者可以处理这些副作用。

下面是使用useEffect更改document.title标题的示例,代码如下。

import React, { useState,useEffect } from "react";

function App () {
    const [ count, setCount ] = useState(0)

    useEffect(() => {
        document.title = count
    })

    return (
        <div>
            当前页面ID: { count }
            <button onClick={() => { setCount(count + 1 )}}>点我</button>
        </div>
    )
}

export default App;

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

在类组件中,我们绑定事件、解绑事件、设定定时器、查找 Dom都需要通过 componentDidMount、componentDidUpdate、componentWillUnmount 生命周期来实现,而 useEffect的作用就相当于这三个生命周期函数,只不过需要通过传参来决定是否调用它。useEffect 会返回一个回调函数,作用于清除上一次副作用遗留下来的状态,如果该 useEffect 只调用一次,该回调函数相当于 componentWillUnmount 生命周期。

例如有下面一个useEffect综合的例子,代码如下。

import React, { useState,useEffect } from "react";

function App () {
    const [ count, setCount ] = useState(0)
    const [ width, setWidth ] = useState(document.body.clientWidth)

    const onChange = () => {
        setWidth(document.body.clientWidth)
    }

    useEffect(() => {
        //相当于 componentDidMount
        window.addEventListener('resize', onChange, false)
        return () => {
            //相当于componentWillUnmount
            window.removeEventListener('resize', onChange, false)
        }
    }, [])

    useEffect(() => {
        //相当于componentDidUpdate
        document.title = count;
    })

    useEffect(() => {
        console.log(`count change: count is ${count}`)
    }, [ count ])

    return (
        <div>
            页面名称: { count }
            页面宽度: { width }
            <button onClick={() => { setCount(count + 1)}}>点我</button>
        </div>
    )
}

export default App;

在上面例子中,我们需要处理两种副作用,即既要处理title,还要监听屏幕宽度的改变,按照 类组件的写法我们需要在生命周期中处理这些逻辑,不过在Hooks中,我们只需要使用 useEffect 就能解决这些问题。

前面说过,useEffect就是用来处理副作用的,而清除上一次留下的状态就是它的作用之一。由于useEffect是每次render之后就会被调用,此时title的改变就相当于 componentDidUpdate,但我们不希望事件监听每次 render 之后进行一次绑定和解绑,此时就用到了useEffect 函数的第二个参数。

那什么时候会用到useEffect 的第二个参数呢?主要有以下场景:

  • 组件每次执行render之后 useEffect 都会调用,此时相当于执行类组件的componentDidMount 和 componentDidUpdate生命周期。
  • 传入一个空数组[], 此时useEffect只会调用一次,相当于执行类组件的componentDidMount 和 componentWillUnmount生命周期。
  • 传入一个数组,其中包括变量,只有这些变量变动时,useEffect 才会执行。

2.3 useMemo

在传统的函数组件中,当在一个父组件中调用一个子组件的时候,由于父组件的state发生改变会导致父组件更新,而子组件虽然没有发生改变但是也会进行更新,而useMemo就是函数组件为了防止这种不必要的更新而采取的手段,其作用类似于类组件的 PureComponent。

那useMemo 是如何使用的呢,看下面的一个例子。

function App () {
  const [ count, setCount ] = useState(0)
  const add = useMemo(() => {
    return count + 1
  }, [count])
  return (
    <div>
      点击次数: { count }
      <br/>
      次数加一: { add }
      <button onClick={() => { setCount(count + 1)}}>点我</button>
    </div>
    )
}

需要注意的是,useMemo 会在渲染的时候执行,而不是渲染之后执行,这一点和 useEffect 有区别,所以 useMemo不建议方法中有副作用相关的逻辑。

2.4 useCallback

useCallback是useMemo 的语法糖,基本上能用useCallback实现的都可以使用useMemo,不过useCallback也有自己的使用场景。比如,在React 中我们经常会面临子组件渲染优化的问题,尤其在向子组件传递函数props时,每次的渲染 都会创建新函数,导致子组件不必要的渲染。而useCallback使用的是缓存的函数,这样把这个缓存函数作为props传递给子组件时就起到了减少不必要渲染的作用。

import React, { useState, useCallback, useEffect } from 'react';
function Parent() {
    const [count, setCount] = useState(1);
    const [val, setVal] = useState('');

    const callback = useCallback(() => {
        return count;
    }, [count]);
    return <div>
        <h4>父组件:{count}</h4>
        <Child callback={callback}/>
        <button onClick={() => setCount(count + 1)}>点我+1</button>
    </div>;
}

function Child({ callback }) {
    const [count, setCount] = useState(() => callback());
    useEffect(() => {
        setCount(callback());
    }, [callback]);
    return <div>
        子组件:{count}
    </div>
}

export default Parent;

需要说明的是,React.memo和 React.useCallback一定记得配对使用,缺了一个都可能导致性能不升反“降”,毕竟无意义的浅比较也会消耗一些性能。

2.5 useRef

在React中,我们使用Ref来获取组件的实例或者DOM元素,我们可以使用两种方式来创建 Ref:createRef和useRef,如下所示。

import React, { useState, useRef } from 'react'

function App(){
    const [count, setCount] = useState(0)
    const counterEl = useRef(null)

    const increment = () => {
        setCount(count + 1)
        console.log(counterEl)
    }

    return (
        <>
            Count: <span ref={counterEl}>{count}</span>
            <button onClick={increment}>点我+</button>
        </>
    )
}

2.6 useReducer

useReducer的作用类似redux中的功能,相较于useState,useReducer适合一些逻辑较复杂且包含多个子值的情况。reducer接受两个参数,第一个参数是一个reducer,第二个参数是初始 state,返回值为最新的state和dispatch函数。

按照官方的说法,useReducer适合用于复杂的state操作逻辑,嵌套的state的对象的场景。下面是官方给出的示例。

import React, { useReducer } from 'react';

function Reducers () {
    const initialState={count:0}
    const [count,dispatch] = useReducer((state,avtion) => {
        switch(avtion.type) {
            case 'add':
                return state+1;
            case 'minus':
                return state-1
            default:
                return state
        }
    },0)
    return (
        <div>
            <div>{count}</div>
            <button onClick={() => {dispatch({type: 'add'})}}>加</button>
            <button onClick={() => {dispatch({type: 'minus'})}}>减</button>
        </div>
    )
}
export default Reducers

2.7 useImperativeHandle

useImperativeHandle 可以让开发者在使用 ref 时自定义暴露给父组件的实例值。其意思就是,子组件可以选择性的暴露一些方法给父组件,然后隐藏一些私有方法和属性,官方建议,useImperativeHandle最好与 forwardRef 一起使用。

import React, { useRef, forwardRef, useImperativeHandle } from 'react'

const App = forwardRef((props,ref) => {

    const inputRef = useRef()

    useImperativeHandle(ref,()=>({
        focus : () =>{
            inputRef.current.focus()
        }
    }),[inputRef])
    return <input type="text" ref={inputRef}/>
})

export default function Father() {
    const inputRef = useRef()
   
    return (
        <div>
            <App ref={inputRef}/>
            <button onClick={e=>inputRef.current.focus()}>获取焦点</button>
        </div>
    )
}

在示例中,我们通过 useImperativeHandle 将子组件的实例属性输出到父组件,而子组件内部通过 ref 更改 current 对象后组件不会重新渲染,需要改变 useState 设置的状态才能更改。

除了上面介绍的几种Hook API之外,React Hook常见的API还包括useLayoutEffect、useDebugValue。

自定义 Hook

使用Hook技术,React函数组件的this指向、生命周期逻辑冗余的问题都已得到解决,不过React开发中另一个比较常见的问题,逻辑代码复用仍然没有得到解决。如果要解决这个问题,需要通过自定义Hook。

所谓的自定义Hook,其实就是指函数名以use开头并调用其他Hook的函数,自定义Hook的每个状态都是完全独立的。例如,下面是使用自定义Hook封装axios实现网络请求的示例,代码如下。

import axios from 'axios'
import { useEffect, useState} from 'react';

const useAxios = (url, dependencies) => {

    const [isLoading, setIsLoading] = useState(false);
    const [response, setResponse] = useState(null);
    const [error, setError] = useState(null);

    useEffect(() => {
        setIsLoading(true);
        axios.get(url).then((res) => {
            setIsLoading(false);
            setResponse(res);
        }).catch((err) => {
            setIsLoading(false);
            setError(err);
        });
    }, dependencies);
    return [isLoading, response, error];
}

export default useAxios;

在上面的代码中,我们使用React已有的API实现自定义Hook的功能。而具体使用时,自定义Hook的使用方法和React官方提供的Hook API使用上类似,如下所示。

function App() {
    let url = 'http://api.douban.com/v2/movie/in_theaters';
    const [isLoading, response, error] = useAxios(url, []);

    return (
        <div>
            {isLoading ? <div>loading...</div> :
                (error ? <div> There is an error happened </div> : <div> Success, {response} </div>)}
        </div>
    )
}

export default App;

可以发现,相比于函数属性和高阶组件等方式,自定义Hook则更加的简洁易读,不仅于此,自定义Hook也不会引起之组件嵌套地狱问题。

虽然React的Hooks有着诸多的优势。不过,在使用Hooks的过程中,需要注意以下两点:

  • 不要在循环、条件或嵌套函数中使用Hook,并且只能在React函数的顶层使用Hook。之所以要这么做,是因为React需要利用调用顺序来正确更新相应的状态,以及调用相应的生命周期函数函数。一旦在循环或条件分支语句中调用Hook,就容易导致调用顺序的不一致性,从而产生难以预料到的后果。
  • 只能在React函数式组件或自定义Hook中使用Hook。

同时,为了避免在开发中造成一些低级的错误,可以安装一个eslint插件,命令如下。

yarn add eslint-plugin-react-hooks --dev

然后,在eslint的配置文件中添加如下一些配置。

{
  "plugins": [
    // ...
    "react-hooks"
  ],
  "rules": {
    // ...
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}
分享到: