这篇文档将通过一个 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 重写吗?需要清楚以下几点:
- hook 不能在 Class 中调用。但是在组件树中,我们可以同时使用 class 组件或者 function 组件。
- 有一些不常用的生命周期函数目前没有对应的 hook,比如
getSnapshotBeforeUpdate
,getDerivedStateFromError
或者componentDidCatch
。 - 某些第三方库可能跟 Hooks 不兼容。
- 重写组件代价高,可以在新代码中尝试 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')
);
3.2 使用 useEffect 添加副作用
介绍
前面有说到,React 经常提到的副作用(Side Effect)指的是数据获取,事件监听,手动修改DOM 等操作,因为这些操作可能会影响其他组件,且不能在正在渲染的过程中进行。副作用的操作通常是写在生命周期方法 componentDidMount
,componentDidUpdate
或者 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>
)
}
对于示例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>
)
}
效果:
可以明显地看出:
- 抽离前后代码的行为没有改变,只是变得可以复用了而已。
- 自定义 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. 参考阅读
- Hooks at a Glance:https://reactjs.org/docs/hooks-overview.html
- Hooks API Reference:https://reactjs.org/docs/hooks-reference.html
- Hooks FAQ:https://reactjs.org/docs/hooks-faq.html
- Dan Abramov 写的一些demo:https://codesandbox.io/u/gaearon/sandboxes
- Dan Abramov 在 React Conf 2018 上介绍 Hook 的演讲视频:https://youtu.be/dpw9EHDh2bM