结合示例学习 React Hooks

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

  • useState
  • useEffect
  • useMemo
  • useCallback
  • 自定义 hook

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

1. 什么是 Hook


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

  • 代码复用更简单:Hook 可以让我们复用状态逻辑, 而不需要改变组件层次结构,避免造成“Wrapper Hell”——组件层级结构复杂一层包一层。
  • 简化代码结构: 当组件有越来越多的状态和副作用(Side Effect,指数据获取,事件监听,手动修改DOM 等操作)时,代码也会变得更难理解。相互关联的代码不得不分散在不同的生命周期方法中,比如我们经常需要在componentDidMount中设置事件监听,然后在componentWillUnmount 中得清除它们。另外 componentDidMount 还会包含一些无关联的代码,比如数据请求,这导致互不关联的代码被放在同一个方法中。而Hook 可以让我们根据代码之间的联系,将一个组件拆分成小的 function,而不再是根据生命周期方法来拆分。
  • 开发更方便:对于刚开始学习使用 React 的开发者来说, class 组件容易让人迷惑,比如 this 的用法,bind 事件监听,什么时候用class 组件,什么时候用 function 组件等等。Hook 的出现让开发者更容易快速地使用 React 的特性,而不一定需要使用 class。

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

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

2. 使用 Hook 的规则


  • 只能在 function 组件或者自定义 Hook 中调用 Hook
  • 只能在代码顶层调用,不能在循环,条件,嵌套语句中调用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 默认会在每次渲染完成后被调用。它可以接受两个参数:

  • 第一个参数是一个function,相当于 componentDidMount 或 componentDidUpdate。这个function 还可以返回另一个用于清理的 function,后者的作用相当于 componentWillUnmount 。

  • 第二个参数是一个数组,只有当数组中的依赖发生变化时,useEffect 才会触发。如果这个传了一个空数组[],那就相当于 componentDidMount 和 componentWillUnmount 的结合,副作用操作只会触发一次。如果传了个不为空的数组[value],那就相当于 componentDidUpdate 和 componentWillUnmount 的结合,且只有当 value变化时,才会触发。

在接下来的例子中会详细说明 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 不依赖任何 props 和 state,因此不需要重新执行;
  • 在这个 effect 中的 props 和 state 会一直是初始值。(因为创建了闭包)

要让这个 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:每次 name 发生变化时,Timer 中的定时器会被清理然后重新创建,也就是说 effect 被触发了。
  • 示例5: Timer 中的定时器正常,effect 没有被重新触发。

示例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 的名称以 use 开头。
  • 两个组件Timer 和 Timer2 虽然都使用了useTimer Hook,但不共享state,自定义 Hook 中的 state 和 effect 都是完全隔离的。

只要理解了 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. 参考阅读

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,045评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,114评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,120评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,902评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,828评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,132评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,590评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,258评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,408评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,335评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,385评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,068评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,660评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,747评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,967评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,406评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,970评论 2 341

推荐阅读更多精彩内容

  • 你还在为该使用无状态组件(Function)还是有状态组件(Class)而烦恼吗?——拥有了hooks,你再也不需...
    米亚流年阅读 939评论 0 5
  • React是现在最流行的前端框架之一,它的轻量化,组件化,单向数据流等特性把前端引入了一个新的高度,现在它又引入的...
    老鼠AI大米_Java全栈阅读 5,773评论 0 26
  • 你还在为该使用无状态组件(Function)还是有状态组件(Class)而烦恼吗?——拥有了hooks,你再也不需...
    水落斜阳阅读 82,310评论 11 100
  • 目录 什么是 React Hooks? 为什么要创造 Hooks? Hooks API 一览 Hooks 使用规则...
    一个笑点低的妹纸阅读 1,062评论 0 2
  • 人们都说:知己难得,两三即可。朋友难求,合适就好。 但是你也总能遇到你们一群“疯子”,学历不高,但是待人不计较。二...
    汐麓生阅读 468评论 0 2