React Hooks 简介
React Hooks
是16.8
版本中正式加入的特性,配合函数组件使用,在没有Hooks
之前,函数组件使用场景非常有限,只适合编写纯展示性的UI
组件,其余复杂的场景不得不使用类组件,而Hooks
的主要作用是在函数组件中使用原本所不具备的React
特性。
React Hooks 产生的动机
在业务开发中,数据主要存在两种形式,业务数据和 UI
数据,我们需要将这两种数据区分开,而有时数据又在组件之间存在共用关系,情况稍微复杂,参数传递的方式就无法满足需求,于是就会有状态管理进入到项目中(Redux
、Mobx
等),会增加开发者的学习成本和项目的维护成本。
使用 React
的开发者都知道,React
主张组件化,就是把业务页面拆分成多个组件进行组合、嵌套、渲染,为了保证项目质量,开发者会花费大量精力在项目的模块化、状态数据最小化以及功能解耦上,而一部分组件会因为数据状态的共享耦合在一起,这时需要使用高阶组件(HOC
)、属性渲染(Render props
)、渲染回调(Prop callback
)等更高级的 React
特性去解耦,但是会增加代码的复杂程度、降低代码的可读性,在渲染时也会增加 DOM
的层级。
上面这些实际问题促成了 React Hooks
的诞生,而在有 Hooks
后官方也越来越推荐使用函数组件。
推荐使用函数组件主要原因总结如下:
- 为了状态相关逻辑的提取和复用;
- 解决复杂组件代码变得难以理解的问题;
- 解决类组件带给开发者一些容易混淆的点,比如
this
指向问题;- 由于
JS
解释器在解释class
关键字时的性能问题,使用函数组件代替。
React
没有重大变化,完全兼容类组件,可以让开发者不必完全重写现有代码,而是在后续开发中逐步尝试使用Hooks
。
React Hooks 分类
React
官方主要给Hooks
分为两大类:
- 基础
Hooks API
:useState
、useEffect
、useContext
;- 其他
Hooks API
:useReducer
、useCallback
、useImperativeHandle
、useMemo
、useRef
、useLayoutEffect
、useDebugValue
。
React Hooks 使用规则
为了保证 Hooks
在使用时不会出现不可预测的问题,官方制定了一定要遵循的两条使用规则(强制遵守),在此提前声明。
- 只在函数组件内部最顶层调用
Hook
,不要在循环、条件判断或者嵌套函数中调用;- 只能在函数组件中调用
Hook
(自定义Hook
中可以调用Hook
),不要在其他JavaScript
函数中调用。
React Hooks API
useState
useState
方法用于在函数组件内部实现组件的状态管理,可以起到类组件中 state
一样的作用。
/* 类组件实现的计数器 */
import React, { Components } from 'react';
import ReactDOM from 'react-dom';
class Counter extends Components {
constructor() {
super();
this.state = { count: 0 };
}
handleClick = () => {
this.setState({ count: this.state.count + 1 })
}
render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={this.handleClick}>
Click!
</button>
</div>
)
}
}
ReactDOM.render(<Counter />, root);
上面是一个类组件实现的计数器,当前计数器的值在类组件的 state
中进行管理。
/* Hooks 实现的计数器 */
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
function Counter() {
const [ count, setCount ] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click!
</button>
</div>
)
}
ReactDOM.render(<Counter />, root);
使用 React Hooks
的 useState
实现的计数器和类组件实现的功能完全相同,从 useState
实现的代码可以看出 useState
是一个函数,传入的参数是状态的初始值,返回值是一个数组,数组的第一项是当前状态的值,数组的第二项是改变状态值的方法。
/* 实现每次加 2 的计数器 */
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
function Counter() {
const [ count, setCount ] = useState(0);
const countAction = (preCount, n) => preCount + n;
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(countAction(count, 2))}>
Click!
</button>
</div>
)
}
ReactDOM.render(<Counter />, root);
上面计数器功能的实现逻辑比较简单,下面来看一个类组件实现的稍微复杂的案例,然后再通过 useState
进行重构。
/* 类组件实现的模态切换功能 */
import React, { Component, Fragment } from 'react';
import ReactDOM from 'react-dom';
import { Button, Modal } from 'antd';
import 'antd/dist/antd.css';
// Toggle 组件专门提供切换状态和切换方法
class Toggle extends Component {
constructor(props) {
super(props);
// 初始化 on 的值
this.state.on = this.props.initial;
}
state = { on: false }
toggle = () => {
this.setState({ on: !this.state.on });
}
render() {
return this.props.children(this.state.on, this.toggle);
}
}
function App() {
return (
<Toggle initial={false}>
{
(on, toggle) => (
<Fragment>
<Button type="primary" onClick={toggle}>
Open Model
</Button>
<Modal visible={on} onCancel={toggle} />
</Fragment>
)
}
</Toggle>
)
}
ReactDOM.render(<App />, root);
上面代码中的类组件 Toggle
主要的作用就是管理模态框显示的状态和对状态的控制,选择使用 children
并传入一个函数,目的是函数可以在类组件内部通过 children
属性调用,并将 Toggle
内部的状态和方法作为参数传入,进而将功能提供给函数组件,这种高级的用法叫做 “渲染回调”,可以成功的将组件进行解耦,但是这样的方式缺点也显而易见,就是代码的逻辑抽象,可读性差,下面来使用 useState
进行重构。
/* useState 重构切换模态的功能 */
import React, { useState, Fragment } from 'react';
import ReactDOM from 'react-dom';
import { Button, Modal } from 'antd';
import 'antd/dist/antd.css';
function App() {
const [ on, setOn ] = useState(false);
return (
<Fragment>
<Button type="primary" onClick={() => setOn(true)}>
Open Model
</Button>
<Modal visible={on} onCancel={() => setOn(false)}/>
</Fragment>
)
}
ReactDOM.render(<App />, root);
因为
React Hooks
的useState
让函数组件具备了管理组件状态的能力,所以不需要单独实现Toggle
组件,代码变得更精简、清晰,更函数式编程,更新的粒度更细。
useState
解构出的用来更改状态的函数传入的参数支持函数类型,传入函数的参数为上一次的状态值,也就是说当更新状态的新值依赖于上一次的值时,会通过这样的方式解决。
/* 当 useState 更新的状态依赖于上一次的值 */
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
function Counter() {
const [ count, setCount ] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(prev => prev + 1)}>
Click!
</button>
</div>
)
}
ReactDOM.render(<Counter />, root);
useEffect
正如 useEffect
钩子的命名一样,是在函数组件中专门用来处理副作用的,这个副作用是指某些操作使用了函数组件作用域外的变量,而且这个操作的结果会影响函数组件外部的环境。
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
function App() {
const [ count, setCount ] = useState(0);
// 每次渲染后执行
useEffect(() => {
document.title = `You clicked ${count} times`;
});
// 初次渲染后执行
useEffect(() => {
console.log('Execute once');
}, []);
// 当 count 更改时才执行
useEffect(() => {
console.log('count changed');
}, [ count ]);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click!
</button>
<button onClick={() => setCount(count)}>
Click no change!
</button>
</div>
)
}
ReactDOM.render(<App />, root);
上面案例是通过 useState
一节中的计数器案例改编,我们增加了每次点击计数器将计数器状态同步到页面标题上的功能,并使用 useEffect
实现,useEffect
函数的参数为回调函数,并在每次页面渲染之后执行(包含首次渲染和更新渲染)。
可以使用
useEffect
替代类组件的生命周期componentDidMount
、componentDidUpdate
和componentWillUnmount
。
useEffect
还支持传入第二个参数,类型为数组,数组的值为被监听的状态(被 useState
监听),此时 useEffect
内部会做一次比较,数组中变量的值没发生变化时,传入对应 useEffect
的回调不会执行,当传入 useEffect
的数组为空时,则传入的回调只在函数组件首次渲染时执行一次,作用相当于类组件的生命周期 componentDidMount
。
import React, { useState, useEffect, Component, Fragment } from 'react';
import ReactDOM from 'react-dom';
// 使用 Hooks 的函数组件
function HooksCom() {
const [ count, setCount ] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times (hooks)`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click!
</button>
</div>
)
}
// 类组件
class ClassCom extends Component {
constructor(props) {
super(props);
this.state = { count: 1 };
}
componentDidMount() {
document.title = `You clicked ${this.state.count} times (class)`;
}
componentDidUpdate() {
document.title = `You clicked ${this.state.count} times (class)`;
}
render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={() => this.setState({
count: this.state.count + 1
})}>
Click!
</button>
</div>
)
}
}
function App() {
return (
<Fragment>
<HooksCom />
<ClassCom />
</Fragment>
)
}
ReactDOM.render(<App />, root);
通过上面案例,对于使用了
componentDidMount
生命周期的类组件和使用了useEffect
的函数组件对于页面标题更改的对比,useEffect
的执行会晚于componentDidMount
。
import React, { useState, useEffect, Fragment } from 'react';
import ReactDOM from 'react-dom';
const ChatAPI = {
handle: null,
isOnline: false,
login() {
this.isOnline = true;
this.handle && this.handle({ isOnline: true });
},
logout() {
this.isOnline = false;
this.handle && this.handle({ isOnline: false });
},
subscribeToFriendStatus(id, handle) {
console.log(`订阅好友:${id}`);
this.handle = handle;
},
unsubscribeToFriendStatus(id, handle) {
console.log(`清理好友:${id}`);
this.handle = null;
}
};
// 用于渲染好友在线状态的函数组件
function FriendStatus(props) {
// 控制好友在线的变量和方法
const [ isOnline, setIsOnline ] = useState(null);
// 设置好友状态的函数
const handleStatusChange = (status) => setIsOnline(status.isOnline);
useEffect(() => {
// 订阅好友状态
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
// 清除好友状态
ChatAPI.unsubscribeToFriendStatus(props.friend.id);
}
}, [ props.friend.id ]);
if (isOnline === null) {
return 'Loading...'
}
return (
<div>
<span>计数器:{props.friend.count}</span>
<br/>
<span>
登录状态:
{
isOnline ? 'Online' : 'Offline'
}
</span>
</div>
)
}
function App() {
const [ show, setShow ] = useState(true);
const [ count, setCount ] = useState(0);
const [ userId, setUserId ] = useState(1);
return (
<div>
<span>用户ID:{userId}</span>
<br/>
{
show && <FriendStatus friend={{ id: userId, name: 'Hello' }}/>
}
<button onClick={() => setShow(!show)}>显示/关闭</button>
<button onClick={() => setUserId(userId + 1)}>增加用户ID</button>
<button onClick={() => setCount(count + 1)}>增加计数器</button>
<button onClick={ChatAPI.login.bind(ChatAPI)}>登录</button>
<button onClick={ChatAPI.logout.bind(ChatAPI)}>退出</button>
</div>
)
}
ReactDOM.render(<App />, root);
上面是一个覆盖比较全的 useEffect
案例,用来实现组件 FriendStatus
内对用户的订阅和取消订阅,其中 App
组件中的 show
状态用来控制 FriendStatus
组件是否渲染,显示/关闭
按钮用来控制 show
的值,FriendStatus
默认登录状态显示 Loading...
,登录
和 退出
按钮用来空登录状态的显示(Online
或 Offline
),增加用户ID
和 增加计数器
按钮分别用来更改当前用户 ID
和计数器的值,计数器的 count
属性和 setUserId
通过 Props
的方式传递给 FriendStatus
,我们将使用到的方法统一都放在 ChatAPI
对象上。
默认渲染 FriendStatus
在控制台发现 useEffect
执行了,并订阅了当前传入的用户,而点击 显示/关闭
按钮时发现取消订阅了用户,这说明组件卸载之前执行了 useEffect
回调内部返回的函数,点击增加计数器按钮,FriendStatus
组件发生了重新渲染,而 useEffect
内部并没有再次对用户进行订阅,原因是指定了 useEffect
的第二个参数,并将用户的 ID
作为元素存入数组内,也就是用户 ID
不发生变化的时候就不会重新执行这个 useEffect
去订阅用户,当点击 增加用户ID
按钮时,控制台首先取消订阅了上一个用户,又订阅了新的用户,这说明 FriendStatus
组件重新渲染时,如果需要执行 useEffect
,则会优先执行回调内返回的取消订阅的函数。
如果在
useEffect
方法传入的回调中返回一个函数,这个函数会在组件卸载之前执行,或重新渲染需时要执行对应的useEffect
时优先执行。
/* 频繁更新未被监听的变量不变 */
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
function Counter() {
const [ count, setCount ] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
ReactDOM.render(<Counter />, root);
上面的案例是一个状态频繁变化的组件,但是我们给 useEffect
传入的第二个参数为空数组,这就会产生一个 Bug
,由于 useEffect
默认只执行一次,并没有执行清除定时器的返回函数,所以导致取到的依然是初始的状态值,还记得上面一节 useState
中提到使用上一次的状态去更新状态,这里我们可以通过这种方式修复这个 Bug
。
/* 解决频繁更新未被监听变量不变的问题 */
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
function Counter() {
const [ count, setCount ] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(prev => prev + 1), 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
ReactDOM.render(<Counter />, root);
所以说在有些时候,对于
useEffect
第二个参数传入[ ]
的行为不是绝对安全的,并且不建议这样使用。
useReducer
useReducer
是 useState
的替代方案,用来处理复杂的 state
更新,看到这个名字大家可能会想到 Redux
中的 reducer
,其实 useReducer
就是 React Hooks
中用来替代 Redux
解决问题的,让我们从此不需要 Redux
。
import React, { useReducer, Fragment } from 'react';
import ReactDOM from 'react-dom';
// 初始 state
const initalCountState = { count: 0 };
// reducer 函数
function reducer(state, action) {
switch (action.type) {
case 'reset':
return { count: action.payload };
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
return state;
}
}
// 默认初始 state 函数
function init(initalCountState) {
return { count: initalCountState.count + 1 };
}
function Counter({ initalCount }) {
const [ state, dispatch ] = useReducer(reducer, initalCountState, init);
return (
<Fragment>
count: { state.count }
<button onClick={() => dispatch({
type: 'reset',
payload: initalCount
})}>
Reset
</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
</Fragment>
)
}
function App() {
return <Counter initalCount={0} />
}
ReactDOM.render(<App />, root);
useReducer
有三个参数:
- 第一个参数为
reducer
函数(根据action
的类型匹配新的state
值);- 第二个参数为监听状态对象
state
的初始值;- 第三个参数是一个函数,参数为初始的
state
,作用是输出一个新的state
替换初始的state
,只在最初执行一次。
useReducer
的返回值为数组:
- 数组第一项是监听的
state
对象;- 数组第二项是用来触发
state
更新的函数,参数为action
。
/* useReducer 不传第三个参数 */
import React, { useReducer, Fragment } from 'react';
import ReactDOM from 'react-dom';
function reducer(state, action) {
switch (action.type) {
case 'reset':
return { count: action.payload };
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
return state;
}
}
function Counter({ initalCount }) {
const [ state, dispatch ] = useReducer(reducer, initalCount);
return (
<Fragment>
count: { state.count }
<button onClick={() => dispatch({
type: 'reset',
payload: initalCount.count
})}>
Reset
</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
</Fragment>
)
}
function App() {
return <Counter initalCount={{ count: 0 }} />
}
ReactDOM.render(<App />, root);
这个案例跟之前的稍有差别,去掉了 useReducer
的第三个参数,并将 Counter
组件的入参 initalCount
作为了初始 state
。
使用
React
进行过项目开发应该都是用过Redux
和Mobx
之类的状态管理工具,但其实他们并不是专门针对React
所设计的,里面都蕴含了一些关于状态管理的编程思想和自己独立的逻辑,也可以在其他框架技术栈中使用,只是和React
搭配使用时更舒适,而React hooks
中提供了官方自身的状态管理解决方案,避免依赖第三方库,所以Redux
的作者开发了React hooks
中状态管理相关的API
。
useContext
想了解 useContext
首先要了解 context API
,即 React.createContext
方法,执行后返回一个对象,其中包含两个属性分别为 Provider
和 Consumer
,都为组件,Provider
用于包裹提供状态的容器组件,Consumer
用于包裹消费这个状态的组件,更详细的用法不在这里过多赘述,可以查看 React 官方文档。
import React, { useState, useContext } from 'react';
import ReactDOM from 'react-dom';
const myContext = React.createContext();
// 子组件
function Com() {
const { count, setCount } = useContext(myContext);
return (
<div>
子组件:{count}
<br />
<button onClick={() => setCount(count + 1)}>count + 1</button>
</div>
)
}
// 父组件
function App() {
const [ count, setCount ] = useState(0);
return (
<myContext.Provider value={{ count, setCount }}>
父组件:{count}
<br />
<Com />
</myContext.Provider>
)
}
ReactDOM.render(<App />, root);
上面是 useContext
的一个简单用法,我们创建了 context
,在父组件 App
中创建了 count
和更改 count
的函数 setCount
,并将它们通过 context
的 Provider
组件提供给子组件 Com
,子组件中调用 useContext
并传入这个创建的 context
对象,返回了父组件所提供的状态数据,并在子组件中点击的方式来更改,此时父、子组件中渲染的 count
都发生了变化。
上面说 React hooks
中提供了自己的状态管理解决方案,也就是说可以替代 Redux
的工作,实现整个项目的状态管理以及相关状态逻辑的复用,下面就使用 useContext
和 useReducer
来实现一个简单的状态管理逻辑。
/* reducer.js */
import React, { useReducer } from 'react';
// 初始状态(默认值)
const initalState = { count: 0 };
// 导出共用的上下文
export const myContext = React.createContext();
// 导出 reducer 函数
export function reducer(state, action) {
switch (action.type) {
case 'reset':
return initalState;
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
return state;
}
}
// 导出提供状态的函数组件
export const ContextProvider = props => {
const [ state, dispatch ] = useReducer(reducer, initalState);
return (
<myContext.Provider value={{ state, dispatch }}>
{props.children}
</myContext.Provider>
)
}
上面的 reducer.js
文件用来提供整个状态管理的核心逻辑,创建了初始的 state
,创建了共用的上下文对象,创建了 reducer
函数(通过 action
来匹配并返回新的 state
),创建了用来提供 state
和 dispatch
的公共组件 ContextProvider
,该组件内部通过创建上下文的 Provider
组件给该组件中间包裹的所有子组件 children
通过 value
提供 state
和 dispatch
(通过 useReducer
创建)。
/* App.js */
import React from 'react';
import ReactDOM from 'react-dom';
import { ContextProvider } from './reducer';
import Counter from './Counter';
function App() {
return (
<div>
<ContextProvider>
<Counter />
</ContextProvider>
</div>
)
}
ReactDOM.render(<App />, root);
App
组件,是提供状态的容器(一般使用根组件),根据 reducer.js
的用法,只需要引入 ContextProvider
组件包裹需要使用状态的子组件,与 react-redux
的 Provider
组件提供 store
的模式相似,这样被 ContextProvider
组件包裹的子组件就可以使用 reducer.js
中所 useReducer
所提供的 state
和 dispatch
,Counter
子组件代码如下。
import React, { useContext } from 'react';
import { myContext } from './reducer';
function Counter() {
const { state, dispatch } = useContext(myContext);
return (
<div>
Counter count: {state.count}
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
</div>
)
}
export default Counter;
在使用 reducer.js
中实现的状态管理逻辑的组件中,只需要引入 reducer.js
提供的 context
,并使用 useContext
就可以解构出 state
和 dispatch
,并通过 action
实现三种不同的对状态的 state
的操作。
useCallback
useCallback
是 React
针对函数组件的优化考虑所设计的 Hook API
,在函数被渲染时,React
底层是通过调用的方式去创建的,如果函数组件作用域中创建了实现某些功能的函数,则底层每次调用函数组件时,这些函数都会被重新创建,也就是指向新的引用,有了 React Hooks
以后,函数组件中需要的函数可以通过 useCallback
创建。
import React, { useCallback, Component } from 'react';
import ReactDOM from 'react-dom';
// 用来存储 useCallback 返回的函数
let fn = null;
// 使用 useCallback 的函数组件
function TestUseCallback({ nums, name }) {
const memoizedCallback = useCallback(() => {
console.log(nums, 'Hello world!');
}, [ nums ]);
console.log('callback 是否相同:', Object.is(fn, memoizedCallback));
console.log('nums > ', nums, 'name > ', name);
fn = memoizedCallback;
return (
<div>
<button onClick={memoizedCallback}>TestUseCallback</button>
</div>
)
}
// 用来触发重新 render 的类组件
class App extends Component {
state = {
nums: [1, 2, 3],
count: 0,
name: 'hello'
};
componentDidMount() {
setInterval(() => {
this.setState((state) => ({ count: state.count + 1 }));
}, 3000);
}
handleChangeNum = () => this.setState({ nums: [4, 5, 6], name: 'world' });
render() {
const { nums, name } = this.state;
return (
<div className="App">
<h2>Start editing to see some magic happen!</h2>
<button onClick={this.handleChangeNum}>修改传入的 nums 值</button>
<TestUseCallback nums={nums} name={name} />
</div>
)
}
}
ReactDOM.render(<App />, root);
在上面的案例中渲染的组件 App
是一个类组件,该组件在挂载后会创建一个定时器,每 3s
更新 state
的 count
值,来完成重渲染,内部的 TestUseCallback
组件也会跟着重渲染,在内部检测 useCallback
创建的函数是否每次都会创建新的,同时打印父组件传递的参数,在通过父组件的点击事件更改的依赖的时候,观察 useCallback
是否会新创建返回的值。
执行
useCallback
方法传入的的参数为回调函数和依赖列表(数组),返回值为传入的函数,React
已经将传入的函数注入,只要依赖列表中的依赖没有发生变化,就不会创建新的函数返回,这样就大大减小了每次都在内存中创建新的引用来存储新函数的开销,也同时减少了GC
的压力。
/* 组件中不同方式事件处理函数的区别 */
class Com1 extends Component {
handleClick() {
console.log('click happened');
}
render() {
return <button onClick={() => this.handleClick()}>Click me</button>
}
}
class Com2 extends Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
console.log('click happened');
}
render() {
return <button onClick={this.handleClick}>Click me</button>
}
}
function Com3() {
const handleClick = () => {
console.log('click happened');
}
return <button onClick={handleClick}>Click me</button>
}
function Com4() {
const memoizedHandleClick = useCallback(() => {
console.log('click happened');
}, []);
return <button onClick={memoizedHandleClick}>Click me</button>
}
Com1
:类组件,直接使用箭头函数,每次重新渲染都产生新的函数;Com2
:类组件,在constructor
中使用bind
绑定this
,每次重新渲染都使用同一个函数;Com3
:函数组件,直接创建函数,每次重新渲染都产生新的函数;Com4
:函数组件,使用useCallback
创建函数,每次重新渲染都使用同一个函数;
import React, { useState, useCallback } from 'react';
import ReactDOM from 'react-dom';
const Child = React.memo(({ a, memo }) => {
return (
<div>
{console.log('Child 渲染')}
<span>a: {a}</span>
<button onClick={memo}>Click in child</button>
</div>
)
});
const App = props => {
const [ a, setA ] = useState(0);
const [ b, setB ] = useState(0);
// 直接创建的函数
// const handleClick = () => console.log('click');
// 使用 useCallback 创建的函数
const handleClick = useCallback(() => console.log('click'), []);
return (
<div>
{console.log('App 渲染')}
<Child a={a} memo={handleClick} />
<button onClick={() => setA(a + 1)}>改变 a</button>
<button onClick={() => setB(b + 1)}>改变 b</button>
<button onClick={handleClick}>Click</button>
</div>
)
}
ReactDOM.render(<App />, root);
上面案例中使用了 React.memo
的函数组件优化方法来进一步验证了 useCallback
的作用,方法 React.memo
的参数为一个函数组件,会返回一个高阶组件,作用是当传入的函数组件内部的 props
不发生变化时,则不会重新渲染。
父组件 App
中使用 useCallback
创建的函数和使用 useState
创建的状态 a
作为参数传递给子组件 Child
,并通过点击事件改变 a
和 b
的状态,初次渲染时控制台打印 App 渲染
和 Child 渲染
,当点击 改变 a
时,父、子组件同时渲染,是因为子组件 props
中的 a
发生变化,当点击 改变 b
时,父组件重新渲染,但是子组件并没有,说明 useCallback
并没有产生新的函数传递给子组件,当使用 App
组件注释中直接创建的函数时,则点击 改变 b
,子组件也会重新渲染,是因为父组件重渲染创建了新的函数,导致子组件的参数发生变化。
import React, { useState, useCallback } from 'react';
import ReactDOM from 'react-dom';
// 存储两个组件的函数
let fun1 = null;
let fun2 = null;
// 使用 React.memo 改写的一个渲染耗时的组件(假设很耗时)
const ExpensiveCom = React.memo(({ onClick }) => {
const date = new Date();
return (
<h1 onClick={onClick}>
{console.log('昂贵组件渲染了!')}
{date.getSeconds()}
我是一个昂贵的组件!渲染耗时!
</h1>
)
});
function Com1({ p1 }) {
const fn = () => console.log('fn', p1);
console.log('Com1', Object.is(fun1, fn));
fun1 = fn;
return (
<ExpensiveCom onClick={fn} />
)
}
function Com2({ p2 }) {
const fn = useCallback(() => console.log('fn', p2), [ p2 ]);
console.log('Com2', Object.is(fun2, fn));
fun2 = fn;
return (
<ExpensiveCom onClick={fn} />
)
}
function App() {
const [ p1, setP1 ] = useState(0);
const [ p2, setP2 ] = useState(0);
return (
<div>
<h2>每次点击 fn 都是新的</h2>
<Com1 p1={p1} />
<button onClick={() => setP1({ p1: p1 + 1 })}>p1 + 1</button>
<br/>
<h2>不用重复生成 fn</h2>
<Com2 p2={p2} />
<button onClick={() => setP2({ p2: p2 + 1 })}>p2 + 1</button>
</div>
)
}
ReactDOM.render(<App />, root);
上面例子中假设 ExpensiveCom
是一个渲染非常耗时的 “昂贵” 组件,并在两个不同的容器组件 Com1
和 Com2
中对比,ExpensiveCom
参数是在父组件 Com1
和 Com2
中创建的函数,前者直接创建,后者使用 useCallback
创建,在 App
组件中渲染 Com1
和 Com2
,分别传入状态 p1
和 p2
,并在 Com1
和 Com2
内部的函数中进行打印,而创建的函数作为 “昂贵” 组件的参数,并作为内部点击的执行函数。
当在页面点击 p1 + 1
或 p2 + 1
时,都会导致 App
的状态变化,也就是 App
的重渲染,而作为 App
的子组件, Com1
和 Com2
,也会跟着重新渲染,点击 p1 + 1
,从控制台打印结果看,只有 Com1
内部的 “昂贵” 组件重新渲染,而 Com2
中并没有,是因为 useCallback
中依赖的 p2
没有改变,没有生成新的函数,当点击 p2 + 1
时,Com2
内部的 “昂贵” 组件重新渲染,同时 Com1
内部的 “昂贵” 组件也重新渲染,由此可以看出 Com2
的性能是要优于 Com1
的。
在大型的项目中,可能在内层组件中存在非常耗时耗性能的 “昂贵” 组件,如果因为在外层组件中一个函数的更新导致的所有组件重新渲染,显然性能代价是非常大的,所以合理的使用
useCallback
对函数组件进行优化是非常有必要的。
useMemo
useMemo
是一种优化手段,接收两个参数,第一个参数是一个函数,第二个参数是依赖列表,返回值是第一个参数传入函数执行后的返回结果,在函数组件渲染时,其中的 useMemo
只有在依赖列表中的依赖发生变化,才会重新计算函数的结果。
import React, { useState, useMemo } from 'react';
import ReactDOM from 'react-dom';
// 存储 useMemo 的返回结果
let ch = null;
// 用于观察是否重渲染的组件
const Com = ({ val }) => {
console.log('Com 重新渲染了');
return <h2>{val}</h2>
}
// 父组件
function Parent({ a, b }) {
const child1 = useMemo(() => (
<div>
{console.log('child1 重新计算')}
<Com val={b} />
</div>
), [ a ]);
console.log('child1 是否和之前相等', child1 === ch);
ch = child1;
const child2 = (
<div>
{console.log('child2 重新计算')}
<Com val={b} />
</div>
)
return (
<div>
{child1}
{child2}
</div>
)
}
// 提供状态的容器组件
const App = props => {
const [ a, setA ] = useState(0);
const [ b, setB ] = useState(0);
return (
<div>
<Parent a={a} b={b} />
<button onClick={() => setA(a + 1)}>改变 a</button>
<button onClick={() => setB(b + 1)}>改变 b</button>
</div>
)
}
ReactDOM.render(<App />, root);
上面例子中 App
组件提供状态 a
和 b
作为参数提供给 Parent
组件,App
中可以通过 改变 a
和 改变 b
按钮更新状态 a
和 b
,当 a
和 b
发生变化时导致 Parent
组件重新渲染,内部的 child1
和 child2
分别是通过 useMemo
和直接创建的组件,其中分别渲染 Com
组件,默认情况下 child1
和 child2
都会渲染,点击 改变 a
,child1
和 child2
重新渲染,因为 child1
的依赖 a
发生变化,点击 改变 b
,发现只有 child2
重新渲染,而再此点击 改变 a
,由于都重新渲染导致 child1
和 child2
渲染的值同步了。
useMemo
不仅仅可以优化耗时的复杂计算程序,同时可以优化渲染耗时且页面不要求更新的复杂组件,但有一点需要注意,就是不要在传入useMemo
的函数中执行与渲染无关的操作,如副作用,这类的操作属于useEffect
的范畴,而不是useMemo
。
useRef
在 React
组件中有一个区分方式,受控组件和非受控组件,大多场景应用于表单元素,受控组件就是通过 onChange
事件和 state
实现双向绑定,这里不过多赘述,非受控组件是通过元素的 ref
属性获取 Dom
的引用,进而对表单进行操作,在 React 16.3
以后推荐使用 React.createRef
方法创建。
类组件中使用 ref
通常是将引用关联到类组件的实例属性上,方便操作,而 useRef
就是为了在函数组件中实现这个功能而存在的。
import React, { useState, useRef, Fragment } from 'react';
import ReactDOM from 'react-dom';
function TextInputWithFocusButton() {
const inputEl = useRef();
const onButtonClick = () => {
inputEl.current.focus();
}
return (
<Fragment>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>获取焦点</button>
</Fragment>
)
}
const App = props => {
const [ count, setCount ] = useState(0);
return (
<div>
{count}
<TextInputWithFocusButton />
<button onClick={() => setCount(count + 1)}>count + 1</button>
</div>
)
}
ReactDOM.render(<App />, root);
在函数组件中同样可以
React.createRef
来实现,但是函数组件的每一次重新渲染都会导致ref
对象的重新创建,浪费内存和性能,useRef
的参数为创建ref
对象current
属性的初始值,ref
对象创建后会作为函数组件的实例属性,除非组件卸载,否则不会重新创建。
useImperativeHandle
在介绍 useImperativeHandle
之前一定要清楚 React
关于 ref
转发(也叫透传)的知识点,是使用 React.forwardRef
方法实现的,该方法返回一个组件,参数为函数(Prop callback
,并不是函数组件),函数的第一个参数为父组件传递的 props
,第二给参数为父组件传递的 ref
,其目的就是希望可以在封装组件时,外层组件可以通过 ref
直接控制内层组件或元素的行为。
/* 一个关于 ref 转发的例子 */
import React, { useCallback, useRef } from 'react';
import ReactDOM from 'react-dom';
// 实现 ref 的转发
const FancyButton = React.forwardRef((props, ref) => (
<div>
<input ref={ref} type="text" />
<button>{props.children}</button>
</div>
));
// 父组件中使用子组件的 ref
function App() {
const ref = useRef();
const handleClick = useCallback(() => ref.current.focus(), [ ref ]);
return (
<div>
<FancyButton ref={ref}>Click Me</FancyButton>
<button onClick={handleClick}>获取焦点</button>
</div>
)
}
ReactDOM.render(<App />, root);
上面例子中创建了一个 FancyButton
组件,内部渲染了一个 button
元素,我们希望在父元素 App
中渲染 FancyButton
,并通过传递给 FancyButton
的 ref
直接操作内部的 button
。
/* 一个官方的 useImperativeHandle 例子 */
import React, { useRef, useImperativeHandle } from 'react';
import ReactDOM from 'react-dom';
const FancyInput = React.forwardRef((props, ref) => {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
}
}));
return <input ref={inputRef} type="text" />
});
const App = (props) => {
const fancyInputRef = useRef();
return (
<div>
<FancyInput ref={fancyInputRef} />
<button onClick={() => fancyInputRef.current.focus()}>
父组件调用子组件的 focus
</button>
</div>
)
}
ReactDOM.render(<App />, root);
上面这个例子中与直接转发 ref
不同,直接转发 ref
是将 React.forwardRef
中函数上的 ref
参数直接应用在了返回元素的 ref
属性上,其实父、子组件引用的是同一个 ref
的 current
对象,官方不建议使用这样的 ref
透传,而使用 useImperativeHandle
后,可以让父、子组件分别有自己的 ref
,通过 React.forwardRef
将父组件的 ref
透传过来,通过 useImperativeHandle
方法来自定义开放给父组件的 current
。
useImperativeHandle
的第一个参数是定义 current
对象的 ref
,第二个参数是一个函数,返回值是一个对象,即这个 ref
的 current
对象,这样可以像上面的案例一样,通过自定义父组件的 ref
来使用子组件 ref
的某些方法,进而将子组件的 ref
保护起来,符合开放封闭原则。
useImperativeHandle
和React.forwardRef
是需要配合使用的,这也是为什么在开头要介绍ref
的转发。
import React, { useState, useRef, useImperativeHandle, useCallback } from 'react';
import ReactDOM from 'react-dom';
const FancyInput = React.forwardRef((props, ref) => {
const [ fresh, setFresh ] = useState(false)
const attRef = useRef(0);
useImperativeHandle(ref, () => ({
attRef,
fresh
}), [ fresh ]);
const handleClick = useCallback(() => {
attRef.current++;
}, []);
return (
<div>
{attRef.current}
<button onClick={handleClick}>Fancy</button>
<button onClick={() => setFresh(!fresh)}>刷新</button>
</div>
)
});
const App = props => {
const fancyInputRef = useRef();
return (
<div>
<FancyInput ref={fancyInputRef} />
<button onClick={() => console.log(fancyInputRef.current)}>
父组件访问子组件的实例属性
</button>
</div>
)
}
ReactDOM.render(<App />, root);
上面的案例相对于官方的例子意图更明显一些,通过 useImperativeHandle
将子组件的实例属性输出到父组件,而子组件内部通过 ref
更改 current
对象后,组件不会重新渲染,需要改变 useState
设置的状态才能更改。
useImperativeHandle
方法还支持传入第三个参数,即依赖列表,当监听的依赖发生变化时,useImperativeHandle
才会重新将子组件的实例属性输出到父组件ref
的current
属性上,如果为空数组,则不会重新输出。
useLayoutEffect
useLayoutEffect
的使用方法和 useEffect
相同,唯一的区别就是执行时机不一样。
/* 对比 useLayoutEffect 与 useEffect 的执行时机 */
import React, { useState, useEffect, useLayoutEffect } from 'react';
import ReactDOM from 'react-dom';
function Com() {
useEffect(() => {
console.log('useEffect 执行...');
return () => {
console.log('useEffect 销毁...');
}
});
useLayoutEffect(() => {
console.log('useLayoutEffect 执行...');
return () => {
console.log('useLayoutEffect 销毁...');
}
});
return (
<div>
{console.log('Com 渲染')}
<h2>Com1</h2>
</div>
)
}
const App = props => {
const [ count, setCount ] = useState(0)
return (
<div>
<Com />
{count}
<button onClick={() => setCount(count + 1)}>count + 1</button>
</div>
)
}
ReactDOM.render(<App />, root);
上面的例子中在 Com
组件中同时使用了 useLayoutEffect
和 useEffect
,在页面初次渲染时可以看到控制台打印顺序为 Com 渲染
→ useLayoutEffect 执行...
→ useEffect 执行...
。
当点击 App
组件按钮更新状态导致 Com
重新渲染,打印顺序为 Com 渲染
→ useLayoutEffect 销毁...
→ useLayoutEffect 执行...
→ useEffect 销毁...
→ useEffect 执行...
。
在刚接触 React Hooks
时,说到执行时机我们一般会和类组件的生命周期去类比,下面是一个 useLayoutEffect
、useEffect
与类组件生命周期配合使用的例子。
/* 对比 useLayoutEffect、useEffect 与类组件生命周期的执行时机 */
import React, { useEffect, useLayoutEffect, Component } from 'react';
import ReactDOM from 'react-dom';
// 使用 useLayoutEffect 和 useEffect 的函数组件
function Com() {
useEffect(() => {
console.log('useEffect 执行...');
return () => {
console.log('useEffect 销毁...');
}
});
useLayoutEffect(() => {
console.log('useLayoutEffect 执行...');
return () => {
console.log('useLayoutEffect 销毁...');
}
});
return (
<div>
{console.log('Com 渲染')}
<h2>Com1</h2>
</div>
)
}
// 使用生命周期的类组件
class App extends Component {
state = { count: 0 }
setCount = () => {
this.setState({ count: this.state.count + 1 });
}
componentDidMount() {
console.log('App componentDidMount');
}
componentDidUpdate() {
console.log('App componentDidUpdate');
}
render() {
return (
<div>
{this.state.count}
<Com />
{console.log('App 渲染')}
<button onClick={this.setCount}>count + 1</button>
</div>
)
}
}
ReactDOM.render(<App />, root);
上面例子中 useLayoutEffect
和 useEffect
依然在 Com
组件中使用,App
组件为类组件,Com
作为 App
的子组件,在首次渲染时控制台的打印顺序为 App 渲染
→ Com 渲染
→ useLayoutEffect 执行...
→ App componentDidMount
→ useEffect 执行...
。
而点击按钮更改状态触发重渲染时,打印顺序为 App 渲染
→ Com 渲染
→ useLayoutEffect 销毁...
→ useLayoutEffect 执行...
→ App componentDidUpdate
→ useEffect 销毁...
→ useEffect 执行...
。
useLayoutEffect
的执行时机要早于useEffect
,useLayoutEffect
的执行在类组件生命周期前,useEffect
的执行在类组件生命周期后,官方的建议是要求我们尽量使用useEffect
,以避免阻塞视觉更新,如果是将代码从类组件重构为React Hooks
,并且使用useEffect
出现问题,再考虑使用useLayoutEffect
,服务端渲染时使用useLayoutEffect
会触发警告。
useDebugValue
useDebugValue
用于在 React
开发者工具(如果已安装,在浏览器控制台 React
选项查看)中显示 自定义 Hook 的标签。
import React, { useState, useDebugValue } from 'react';
import ReactDOM from 'react-dom';
// 自定义 Hook
function useMyCount(num) {
const [ count, setCount ] = useState(0);
// 调试自定义 Hook,显示在 devtools 上
useDebugValue(count > num ? '溢出' : '不足');
const myCount = () => {
setCount(count + 2);
}
return [ count, myCount ];
}
function App() {
const [ count, setCount ] = useMyCount(10);
return (
<div>
{count}
<button onClick={() => setCount()}>setCount</button>
</div>
)
}
ReactDOM.render(<App />, root);
上面例子中创建了 useMyCount
自定义 Hook
,在内部使用 useDebugValue
对 count
的状态进行了调试,在开发工具中显示如下图。
useDebugValue
还支持第二个参数,类型为函数,函数的默认参数为 debug
的状态,作用是对 debug
的值进行格式化,官方叫做 “延迟格式化”。
/* 延迟格式化 */
import React, { useState, useDebugValue } from 'react';
import ReactDOM from 'react-dom';
// 自定义 Hook
function useMyCount(num) {
const [ count, setCount ] = useState(0);
// 延迟格式化
useDebugValue(count > num ? '溢出' : '不足', (status) => {
return status === '溢出' ? 1 : 0;
});
const myCount = () => {
setCount(count + 2);
}
return [ count, myCount ];
}
function App() {
const [ count, setCount ] = useMyCount(10);
return (
<div>
{count}
<button onClick={() => setCount()}>setCount</button>
</div>
)
}
ReactDOM.render(<App />, root);
上面的例子只是做了小小的改动,增加了一个格式化函数作为 useDebugValue
的第二个参数,当状态为 不足
时显示 0
,为 溢出
时显示 1
。
提示:我们不推荐你向每个自定义
Hook
使用useDebugValue
,只有自定义Hook
被复用时才最有意义。
自定义 Hook
在开篇介绍 React Hooks
产生的动机时,提到了在类组件中使用 “高阶组件”(HOC
)和 “渲染回调”(Prop callback
)的方式对状态逻辑进行复用和解耦会导致渲染嵌套的层级增多以及代码可读性差的问题,在 React 16.8
以后可以通过自定义 Hook
来解决这些问题。
/* 一个没有解决问题的例子 */
import React, { useState, useEffect, Fragment } from 'react';
import ReactDOM from 'react-dom';
// 计数器 1
function Counter1() {
const [ count, setCount ] = useState(0);
useEffect(() => {
console.log('开启一个新的定时器')
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => {
console.log('销毁老的定时器')
clearInterval(timer);
}
});
return <p>{count}</p>
}
// 计数器 2
function Counter2() {
const [ count, setCount ] = useState(0);
useEffect(() => {
console.log('开启一个新的定时器')
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => {
console.log('销毁老的定时器')
clearInterval(timer);
}
});
return <p>{count}</p>
}
function App() {
return (
<Fragment>
<Counter1 />
<Counter2 />
</Fragment>
)
}
ReactDOM.render(<App />, root);
上面实现了两个计数器,都有自动增加状态的更新数组的功能,并且都是使用 React Hooks
实现的,很明显我们可以将更新状态的逻辑抽离出来,下面是通过自定义 Hook
改写的例子。
/* 使用自定义 Hook 对状态逻辑进行抽离 */
import React, { useState, useEffect, Fragment } from 'react';
import ReactDOM from 'react-dom';
// 自定义 Hook
function useNumber() {
const [ count, setCount ] = useState(0);
useEffect(() => {
console.log('开启一个新的定时器')
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => {
console.log('销毁老的定时器')
clearInterval(timer);
}
});
return count;
}
// 计数器 1
function Counter1() {
let number = useNumber();
return <p>{number}</p>
}
// 计数器 2
function Counter2() {
let number = useNumber();
return <p>{number}</p>
}
function App() {
return (
<Fragment>
<Counter1 />
<Counter2 />
</Fragment>
)
}
ReactDOM.render(<App />, root);
使用自定义 Hook
就很容易的实现了状态逻辑的复用和解耦,代码简单易读,也避免了 “高阶组件” 和 “渲染回调” 造成渲染层级增加的问题。
注意:官方建议在创建自定义
Hook
时,也采用use
开头的命名方式,以保持命名的默认约定,便于识别,非强制,所以项目中可以使用ESlint
进行检查和约束。
总结
React Hooks
出现后让我们对使用React
编程如释重负,好的技术就是应该尽量减小学习坡度和上手难度,越用越简单,编写大家都读得懂又直观的代码才是优秀的代码,上面就是在学习完React Hooks
后的一些总结,最后附上相关案例的 Guthub 地址。