React Hooks 简介

React Hooks16.8 版本中正式加入的特性,配合函数组件使用,在没有 Hooks 之前,函数组件使用场景非常有限,只适合编写纯展示性的 UI 组件,其余复杂的场景不得不使用类组件,而 Hooks 的主要作用是在函数组件中使用原本所不具备的 React 特性。

React Hooks 产生的动机

在业务开发中,数据主要存在两种形式,业务数据和 UI 数据,我们需要将这两种数据区分开,而有时数据又在组件之间存在共用关系,情况稍微复杂,参数传递的方式就无法满足需求,于是就会有状态管理进入到项目中(ReduxMobx 等),会增加开发者的学习成本和项目的维护成本。

使用 React 的开发者都知道,React 主张组件化,就是把业务页面拆分成多个组件进行组合、嵌套、渲染,为了保证项目质量,开发者会花费大量精力在项目的模块化、状态数据最小化以及功能解耦上,而一部分组件会因为数据状态的共享耦合在一起,这时需要使用高阶组件(HOC)、属性渲染(Render props)、渲染回调(Prop callback)等更高级的 React 特性去解耦,但是会增加代码的复杂程度、降低代码的可读性,在渲染时也会增加 DOM 的层级。

上面这些实际问题促成了 React Hooks 的诞生,而在有 Hooks 后官方也越来越推荐使用函数组件。

推荐使用函数组件主要原因总结如下:

  • 为了状态相关逻辑的提取和复用;
  • 解决复杂组件代码变得难以理解的问题;
  • 解决类组件带给开发者一些容易混淆的点,比如 this 指向问题;
  • 由于 JS 解释器在解释 class 关键字时的性能问题,使用函数组件代替。

React 没有重大变化,完全兼容类组件,可以让开发者不必完全重写现有代码,而是在后续开发中逐步尝试使用 Hooks

React Hooks 分类

React 官方主要给 Hooks 分为两大类:

  • 基础 Hooks APIuseStateuseEffectuseContext
  • 其他 Hooks APIuseReduceruseCallbackuseImperativeHandleuseMemouseRefuseLayoutEffectuseDebugValue

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 HooksuseState 实现的计数器和类组件实现的功能完全相同,从 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 HooksuseState 让函数组件具备了管理组件状态的能力,所以不需要单独实现 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 替代类组件的生命周期 componentDidMountcomponentDidUpdatecomponentWillUnmount

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...登录退出 按钮用来空登录状态的显示(OnlineOffline),增加用户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

useReduceruseState 的替代方案,用来处理复杂的 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 进行过项目开发应该都是用过 ReduxMobx 之类的状态管理工具,但其实他们并不是专门针对 React 所设计的,里面都蕴含了一些关于状态管理的编程思想和自己独立的逻辑,也可以在其他框架技术栈中使用,只是和 React 搭配使用时更舒适,而 React hooks 中提供了官方自身的状态管理解决方案,避免依赖第三方库,所以 Facebook 挖来了 Redux 的作者开发了 React hooks 中状态管理相关的 API

useContext

想了解 useContext 首先要了解 context API,即 React.createContext 方法,执行后返回一个对象,其中包含两个属性分别为 ProviderConsumer,都为组件,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,并将它们通过 contextProvider 组件提供给子组件 Com,子组件中调用 useContext 并传入这个创建的 context 对象,返回了父组件所提供的状态数据,并在子组件中点击的方式来更改,此时父、子组件中渲染的 count 都发生了变化。

上面说 React hooks 中提供了自己的状态管理解决方案,也就是说可以替代 Redux 的工作,实现整个项目的状态管理以及相关状态逻辑的复用,下面就使用 useContextuseReducer 来实现一个简单的状态管理逻辑。

/* 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),创建了用来提供 statedispatch 的公共组件 ContextProvider,该组件内部通过创建上下文的 Provider 组件给该组件中间包裹的所有子组件 children 通过 value 提供 statedispatch(通过 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-reduxProvider 组件提供 store 的模式相似,这样被 ContextProvider 组件包裹的子组件就可以使用 reducer.js 中所 useReducer 所提供的 statedispatchCounter 子组件代码如下。

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 就可以解构出 statedispatch,并通过 action 实现三种不同的对状态的 state 的操作。

useCallback

useCallbackReact 针对函数组件的优化考虑所设计的 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 更新 statecount 值,来完成重渲染,内部的 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,并通过点击事件改变 ab 的状态,初次渲染时控制台打印 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 是一个渲染非常耗时的 “昂贵” 组件,并在两个不同的容器组件 Com1Com2 中对比,ExpensiveCom 参数是在父组件 Com1Com2 中创建的函数,前者直接创建,后者使用 useCallback 创建,在 App 组件中渲染 Com1Com2,分别传入状态 p1p2,并在 Com1Com2 内部的函数中进行打印,而创建的函数作为 “昂贵” 组件的参数,并作为内部点击的执行函数。

当在页面点击 p1 + 1p2 + 1 时,都会导致 App 的状态变化,也就是 App 的重渲染,而作为 App 的子组件, Com1Com2,也会跟着重新渲染,点击 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 组件提供状态 ab 作为参数提供给 Parent 组件,App 中可以通过 改变 a改变 b 按钮更新状态 ab,当 ab 发生变化时导致 Parent 组件重新渲染,内部的 child1child2 分别是通过 useMemo 和直接创建的组件,其中分别渲染 Com 组件,默认情况下 child1child2 都会渲染,点击 改变 achild1child2 重新渲染,因为 child1 的依赖 a 发生变化,点击 改变 b,发现只有 child2 重新渲染,而再此点击 改变 a,由于都重新渲染导致 child1child2 渲染的值同步了。

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,并通过传递给 FancyButtonref 直接操作内部的 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 属性上,其实父、子组件引用的是同一个 refcurrent 对象,官方不建议使用这样的 ref 透传,而使用 useImperativeHandle 后,可以让父、子组件分别有自己的 ref,通过 React.forwardRef 将父组件的 ref 透传过来,通过 useImperativeHandle 方法来自定义开放给父组件的 current

useImperativeHandle 的第一个参数是定义 current 对象的 ref,第二个参数是一个函数,返回值是一个对象,即这个 refcurrent 对象,这样可以像上面的案例一样,通过自定义父组件的 ref 来使用子组件 ref 的某些方法,进而将子组件的 ref 保护起来,符合开放封闭原则。

useImperativeHandleReact.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 才会重新将子组件的实例属性输出到父组件 refcurrent 属性上,如果为空数组,则不会重新输出。

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 组件中同时使用了 useLayoutEffectuseEffect,在页面初次渲染时可以看到控制台打印顺序为 Com 渲染useLayoutEffect 执行...useEffect 执行...

当点击 App 组件按钮更新状态导致 Com 重新渲染,打印顺序为 Com 渲染useLayoutEffect 销毁...useLayoutEffect 执行...useEffect 销毁...useEffect 执行...

在刚接触 React Hooks 时,说到执行时机我们一般会和类组件的生命周期去类比,下面是一个 useLayoutEffectuseEffect 与类组件生命周期配合使用的例子。

/* 对比 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);

上面例子中 useLayoutEffectuseEffect 依然在 Com 组件中使用,App 组件为类组件,Com 作为 App 的子组件,在首次渲染时控制台的打印顺序为 App 渲染Com 渲染useLayoutEffect 执行...App componentDidMountuseEffect 执行...

而点击按钮更改状态触发重渲染时,打印顺序为 App 渲染Com 渲染useLayoutEffect 销毁...useLayoutEffect 执行...App componentDidUpdateuseEffect 销毁...useEffect 执行...

useLayoutEffect 的执行时机要早于 useEffectuseLayoutEffect 的执行在类组件生命周期前,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,在内部使用 useDebugValuecount 的状态进行了调试,在开发工具中显示如下图。


useDebugValue 调试效果图
useDebugValue 调试效果图


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 地址