Web 前端开发 让前端飞

结合示例学习 React Hooks

2020-01-03  本文已影响0人  Evelynzzz

这篇文档将通过一个 demo,介绍 React 几个 Hook 的使用方法和场景。包括:

阅读此文档之前,建议先仔细阅读 React 官网文档中的 Hooks 部分。感兴趣的话,当然也强烈建议看看 Dan Abramov 在 React Conf 2018 上介绍 Hook 的演讲视频

1. 什么是 Hook


Hook 是 React 16.8 加入的新特性,它带来什么好处呢?解决什么问题呢?

其实 function 组件一直都有,但跟 class 组件相比,它最大的限制就是,它只有 props 而没有 state 管理,也没有生命周期方法。所以在 Hook 特性出来之前,创建 function 组件时,不得不考虑到,如果之后这个组件需要添加 state 怎么办呢?那不是还得改写成 class 组件吗?多麻烦啊,不如一开始就直接创建 class 组件。

Hook 出来之后就不用担心这个问题了:需要添加 state 的话,我们可以使用useState,需要添加生命周期相关的副作用时,我们可以使用 useEffect。这样就避免改写成 class 了。

2. 使用 Hook 的规则


React 提供了 linter 插件 来自动检查 Hook 相关的代码语法并修复错误,在初学 Hook 时有必要使用。

那么 Hook 能够完全替代 Class 吗?我们需要把 Class 用 Hook 重写吗?需要清楚以下几点:

  1. hook 不能在 Class 中调用。但是在组件树中,我们可以同时使用 class 组件或者 function 组件。
  2. 有一些不常用的生命周期函数目前没有对应的 hook,比如getSnapshotBeforeUpdategetDerivedStateFromError 或者componentDidCatch
  3. 某些第三方库可能跟 Hooks 不兼容。
  4. 重写组件代价高,可以在新代码中尝试 Hooks。

3. Demo


接下来通过一个 demo 尝试一下 Hook 这个新特性。

先创建一个function 组件——计时器 Timer,只显示一段文本。

import React from 'react'
import ReactDOM from 'react-dom'

function Timer(){
    return (
        <h2> 10s passed...</h2>
    )
}

ReactDOM.render(
    <Timer />,
    document.getElementById('main')
);

3.1 使用 useState 添加 state


介绍

const [state, setState] = useState(initialState);

useState Hook 会返回一个 state 值,以及用于更新这个state 的方法。

示例1

比如我要添加一个按钮使得可以重置计时器的时间:

import React, {useState} from 'react'

function Timer(props){
    // 获取time state,以及更新time 的方法
    // 并用 props 给 time 一个初始值
    const [time, setTime] = useState(props.from)

    // 重置计时器
    var resetTimer=()=>{
        setTime(0)
    }

    return (
        <div>
            <h2>{time} s passed...</h2>
            <button onClick={resetTimer}>Reset</button>
        </div>
    )
}
ReactDOM.render(
    <Timer from={30}/>,
    document.getElementById('main')
);
示例1

3.2 使用 useEffect 添加副作用


介绍

前面有说到,React 经常提到的副作用(Side Effect)指的是数据获取,事件监听,手动修改DOM 等操作,因为这些操作可能会影响其他组件,且不能在正在渲染的过程中进行。副作用的操作通常是写在生命周期方法 componentDidMountcomponentDidUpdate 或者 componentWillUnmount中。

在function 组件中,我们使用 useEffect Hook 来达到类似的作用。useEffect 的作用和上面说的三个生命周期方法相同,相当于把它们合并到了一个 API 中。

useEffect(
  () => {
    // do some thing here...
    // add side effects here
    // 副作用操作卸载这里
      
    return () => {
      // clean up here
      // 清除定时器,解除监听等操作写在这里
    };
  },
  [dependencies]
);

useEffect 默认会在每次渲染完成后被调用。它可以接受两个参数:

在接下来的例子中会详细说明 useEffect 的用法。

示例2

接着计时器的例子,接下来使用 useEffect,添加定时器,使 Timer 不断更新:

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

function Timer(props){
    const [time, setTime] = useState(props.from)

    var resetTimer=()=>{
        setTime(0)
    }

    // 每次渲染后,如果time 发生变化,就会触发
    useEffect(()=>{
        // 设置定时器,每秒把时间加一
        var interval = setInterval(()=>{
            setTime(time+1)
        },1000)

        // 返回一个清理方法
        return ()=>{
            // 清除定时器
            if(interval) clearInterval(interval)
        }
    },[time])   // 依赖 time

    return (
        <div>
            <h2>{time} s passed...</h2>
            <button onClick={resetTimer}>Reset</button>
        </div>
    )
}

需要注意的是,上面的 useEffect中,作为第一个参数,我传递了一个 function:

()=>{
    var interval = setInterval(()=>{
        setTime(time+1)
    },1000)

    // clean-up
    return ()=>{
        if(interval) clearInterval(interval)
    }
}

在这个方法中做两件事:设置定时器,让时间每秒更新;返回一个 clean-up 方法,清除定时器。作为第二个参数,我传递了[time],也就是说这个effect 是依赖 time 变化的,当 time 改变了,effect 才会被触发。

因为设置了定时器,time 每秒都会更新,那么这个effect 每秒会被触发一次。其结果就是,虽然时间确实每秒递增,但实际上每次触发这个effect,都会新建一个定时器,然后这个定时器被清除,然后再新建,再清除······这是因为:

The clean-up function runs before the component is removed from the UI to prevent memory leaks. Additionally, if a component renders multiple times (as they typically do), the previous effect is cleaned up before executing the next effect.

按照 Hook API 中说到的, clean-up 方法会在组件被移除时执行,以避免内存泄漏。

划重点:当一个组件多次渲染时,新的 effect 会把旧的 effect 清理掉。

因此每次 effect 触发时,会先清理掉前一个effect 创建的定时器,然后再创建一个新的定时器。这当然不是正常的定时器用法!我们只需要一个定时器,一直存在,只要不需要更新时间时,再清理掉它。

示例3

换句话说,本来我们是想在 componentDidMount中定义一个定时器,却定义在componentDidUpdate中了,导致定时器没必要的重复创建和清除。

怎么改成只创建一个定时器呢?按照 useEffect 的第二个参数的说明,如果我们传递一个空数组[]不就可以吗?但实际上,如果配置了上文中提到的 Linter 工具eslint-plugin-react-hooks,就会发现这里不能传递空数组,因为useEffect 中用到了会发生变化的 time,那么第二个参数就一定要加上time。

如果第二个参数就是传递的[],会发生什么呢?定时器确实只创建一个,但每次setTime(time+1) 中的time始终是初始值30,页面上始终是31 s passed...

注意,useEffect 第二个参数传递空数组 [] 时表示

要让这个 effect 不依赖 time,且定时器不重复创建,解决办法如下:

useEffect(()=>{
    var interval = setInterval(()=>{
        // setTime(time+1)
        // 不再依赖 time,且每次都能获取到最新的 time 值
        setTime(t=>t+1)
    },1000)

    return ()=>{
        if(interval) clearInterval(interval)
    }
},[])

关于这部分,可以阅读 React Hook FAQ 中的讲解。

3.3 使用 useCallback 和 useMemo


接下来,我要修改一下 Timer 的 props,把一个对象传递到 props.config:from 为起始时间,to 为结束时间。

<Timer config={{from:20,to:30}}/>

示例4

相应的修改如下,在 effect 中判断倒计时是否结束。注意这个effect 依赖了 props.config。


function Timer(props){
    const [time, setTime] = useState(props.config.from)

    //......
    useEffect(()=>{
        var interval = setInterval(()=>{
            // 获取整个 config 对象
            const config = props.config
            setTime(t=>{
                // 倒计时结束,清理计时器
                if(t+1 >= config.to){
                    clearInterval(interval)
                }
                return t+1
            })
        },1000)

        return ()=>{
            if(interval) clearInterval(interval)
        }
    },[props.config]) // 依赖 props.config
    // ......
}

示例5

还有一种写法,区别在于依赖的是 props.config.to

useEffect(()=>{
    var interval = setInterval(()=>{
        setTime(t=>{
            // 直接跟 props.config.to 比较
            if(t+1 >= props.config.to){
                clearInterval(interval)
            }
            return t+1
        })
    },1000)

    return ()=>{
        if(interval) clearInterval(interval)
    }
},[props.config.to]) // 依赖 props.config.to

这两种写法有什么区别吗?功能上没区别,都能实现从from到to的计时。但是有一个隐患!!

示例6

当我在另一个 function 组件中引入Timer组件时,这两种写法就有很大区别了。

比如我写一个简单的 function 组件,定义一个name state,提供一个输入框可以修改name,然后引入 Timer。

function Parent(){
    const [name, setName] = useState('Evelyn');

    var onChangeName = (e)=>{
        setName(e.target.value)
    }

    return (
        <div>
            <input value={name} onChange={onChangeName} />
            <br/>
            Hello, {name}!!!
            <Timer config={{from:20,to:30}}/>
        </div>
    )
}
示例6

对于示例4和5的两种写法,当计时器还么结束时,改变输入框的内容会发生什么呢?

示例4 中的 effect 依赖了 props.config,按道理 props.config 一直都是 {from:20,to:30},为什么父组件的 state 变化时,effect 会被再次触发呢?

这是因为,父组件每次重新重新渲染时,整个function 组件中的变量等都会被重新创建,包括 <Timer config={{from:20,to:30}}/> 中的对象参数,虽然内容没变,但是引用变了。

useEffect 是否再次触发,依据的是第二个参数数组中的变量的引用相等性(Reference equality)。

var a = {from:20,to:30};
var b = {from:20,to:30};
a===b; // false
var d = a.to;
var e = b.to;
d===e; // true

关于 Reference equality 和 useCallback、useMemo 的关系,感兴趣的话可以阅读这篇文章 useCallback vs useMemo

示例7

要解决示例4 的问题,我们可以使用useMemo。

useMemo返回一个记忆化(Memoized)的值,也就是保证了在两次渲染之间这个值的引用不发生变化。在这个例子中,useMemo 依赖空数组保证了在渲染之间 {from:20,to:30}这个对象只创建了一次,config的引用不发生变化。

function Parent(){
    const [name, setName] = useState('Evelyn');

    var onChangeName = (e)=>{
        setName(e.target.value)
    }

    // 使用useMemo 创建一个 memoized 对象
    const config = useMemo(() => ({from:20,to:30}),[]);

    return (
        <div>
            <input value={name} onChange={onChangeName} />
            <br/>
            Hello, {name}!!!
            <Timer config={config}/>
        </div>
    )
}

useCallback 跟 useMemo 类似,只是 useCallback 返回的是第一个参数定义的记忆化的 function。

useCallback(fn,deps) // 返回一个记忆化的方法 fn

等同于

useMemo(()=>fn,deps)

useCallback 和 useMemo 的效果类似于shouldComponentUpdate,避免不需要的render。更丰富的使用场景就不在此赘述了,可以自行探索。

3.4 自定义Hook

使用 Hook 的 都是 function 组件,那么如果我们想要在多个function组件中使用一部分相同的逻辑,该怎么做呢?我们可以把那部分的逻辑抽离出来,放到一个新的方法中,这也就是自定义 Hook 的过程。

示例 8

基于示例 3 的代码,抽离出来一个 Hook useTimer(from),返回[time,resetTimer],这样就能在别的 function 组件中复用 useTimer 里的逻辑了。

function Timer(props){
    // 调用自定义的 Hook
    const [time, resetTimer] = useTimer(props.from)

    return (
        <div>
            <h2>{time} s passed...</h2>
            <button onClick={resetTimer}>Reset</button>
        </div>
    )
}

// 抽离出来定时器的逻辑,定义一个新的Hook
function useTimer(from){
    const [time, setTime] = useState(from)

    const resetTimer = ()=>{
        setTime(0)
    }

    useEffect(()=>{
        var interval = setInterval(()=>{
            setTime(t=>t+1)
        },1000)

        return ()=>{
            if(interval) clearInterval(interval)
        }
    },[])

    return [time, resetTimer]
}

示例9

比如在示例 8 的基础上,我再定义一个定时器,但是是倒计时,并且记录回合数。这时候可以复用自定义的 useTimer Hook。

// 正计时器
function Timer(props){
    const [time, resetTimer] = useTimer(props.from)
    return (
        <div>
            <h1>Timer 1</h1>
            <h2>{time}s passed...</h2>
            <button onClick={resetTimer}>Reset Timer</button>
        </div>
    )
}

// 定义新的组件,倒计时,记录回合数,可重置
function Timer2(props){
    const [time, resetTimer] = useTimer(0)  // 调用自定义Hook useTimer
    const [round, setRound] = useState(0) // 回合数

    // 重置回合数和倒计时
    const resetRound = ()=>{
        setRound(0)
        resetTimer()
    }

    useEffect(()=>{
        // 如果倒计时结束,自动重新倒计时,并更新回合数
        if(time >= props.timePerRound){
            resetTimer()
            setRound(r=>r+1)
        }
    }, [time, props.timePerRound, resetTimer])

    return (
        <div>
            <h1>Timer 2</h1>
            <h2>Round {round}</h2>
            {/* 显示倒计时 */}
            <h2>{props.timePerRound - time}s before the next round...</h2>
            <button onClick={resetRound}>Reset Round</button>
        </div>
    )
}

function Timers(){
    return (
        <div>
            <Timer from={0} />
            <Timer2 timePerRound={10} />
        </div>
    )
}

效果:


示例9

可以明显地看出:

只要理解了 Hook API,自定义 Hook 还是很简单的。

Custom Hooks are a convention that naturally follows from the design of Hooks, rather than a React feature.

但不得不说,自定义 Hook 提供了共享代码逻辑的灵活性,这在以前的 React 组件中是不可能的。我们可以编写涵盖各种用例的自定义 Hook,比如表单处理,动画,事件订阅,计时器等等。

4. 参考阅读

上一篇 下一篇

猜你喜欢

热点阅读