React 学习笔记(2)—— Hook

140 阅读10分钟

Hook 简介

Hook 是 React 16.8 新增特性。它可以在不编写 class 的情况下使用 state 以及其它 React 特性。

import React, { useState } from 'react';

function Example(props) {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)} />
    </div>
  );
}

兼容性

Hook 是:完全可选的100% 向后兼容的现在可用的

React 目前没有计划移除 class。Hook 也不会影响对 React 概念的理解。Hook 为已知的 React 概念提供了更加直接的 API: props、state、context、refs 以及生命周期,Hook 还提供了一种更加强大的方式来组合他们。

动机

Hook 的提出主要为了解决下面这些经常遇到的问题:

  1. 难以在组件间复用状态逻辑

    使用 Hook 可以从组件中提取状态逻辑,使得这些逻辑可以单独测试并复用。Hook 使得无需修改组件结构即可复用状态逻辑。这使得组件间或社区内共享 Hook 变得更便捷。

  2. 复杂组件难以理解

    复杂组件内部存在大量的状态逻辑和副作用。每个生命周期常常包含一些不相关的逻辑。相互关联且需要对照的代码被拆分了,而完全不相关的代码却在同一个方法中组合在一起。这样很容易出 bug,并且导致逻辑不一致。

    很多人将 React 和状态管理库结合起来使用以解决这一问题,但是这又会引入一些抽象概念,需要在不同文件之间来回切换,使得复用变得更加困难。

    为了解决这一问题,Hook 将组件中互相关联的部分拆分为更小的函数,而不是强制按照生命周期划分。还可以使用 reducer 来管理组建内部状态,使其变得更加可预测。

  3. 难以理解的 class

    除了代码复用和代码管理之外,class 也是学习 React 的一大屏障,这里面的问题包括:this 的工作方式、事件处理器的绑定。

    组件预编译可能会带来巨大的潜力,但是 class 组件会无意中鼓励开发者使用一些让优化措施无效的方案。class 也为目前的工具带来了一些问题,如:class 不能很好地压缩,并且会使热重载出现不稳定的情况。

    为解决这些问题,Hook 使得在非 class 的情况下可以使用更多的 React 特性。概念上,React 组件更像函数,而 Hook 拥抱函数,同时没有牺牲 React 的精神原则。

渐进策略

Hook 和现有代码可以同时工作,可以渐进式地使用他们。

React 继续为 class 组件提供支持。

建议避免任何大规模重写,尤其是现有的复杂的 class 组件。最好在新组件中尝试使用 Hook。

Hook 概览

什么是 Hook?

Hook 是一些可以在函数组件里“钩入” React State 及生命周期等特性的函数。

Hook 不能在 class 组件中使用,Hook 在 class 内部不起作用,使用 Hook 可以取代 class

React 内置了一些 Hook,如:useState。React 也允许创建自定义 Hook 来复用不同组件间的状态逻辑。

State Hook

import React, { useState } from 'react';

function Example(props) {
  const [age, setAge] = useState(0);
  const [fruit, setFruit] = useState('apple');
  const [todos, setTodos] = useState([{text: 'Learn Hooks'}]);
  // ...
}

useState 就是一个 Hook,在函数组件里调用它可以为组件添加一些内部状态。React 会在重复渲染时保留这个 state。

useState 的唯一参数就是初始 state,它不必是对象,这个初始参数只在第一次渲染时被用到;useState 返回一对值:当前状态和一个相应的状态更新函数,可以在事件处理函数中或是其它地方调用这个函数。与 class 组件中 this.setState 不同的是,它不会把新、旧 state 进行合并。

一个组件中可以多次使用 State Hook,数组解构语法使得调用该 Hook 时可以为 state 命名。React 假设当你多次调用 useState 时,你能够保证每次渲染时它们的调用顺序不变。

Effect Hook

有时可能需要在 React 组件中获取数据、订阅、修改 DOM 等操作,这些操作统称为“副作用”,简称“作用”。

useEffect 是一个 Effect Hook,让函数组件可以操作副作用,它和 class 组件中的 componentDidMountcomponentDidUpdatecomponentWillUnmount 具有相同的用途。调用 useEffect 后,默认情况下,React 会在每次 DOM 更新后调用副作用函数——包括第一次。副作用函数可以访问组件的 props 和 state。

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

function Example(props) {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    document.title = `click ${count} times`;
  });

  return (
    <div>
      <p>{count}</p>
      <button onclick={() => setCount(count + 1)}>Click please!</button>
    </div>
  );
}

副作用函数可以通过返回一个函数来指定如何清除副作用。组件重新渲染时,React 会调用该返回值函数,后续再重新执行副作用函数。

useEffect 可以在一个组件中多次使用,通过使用 Hook,可以把组件内相关的副作用组织在一起。

Hook 使用规则

使用 Hook 必须遵守以下两个规则:

  1. 只能在函数最外层调用 Hook,不要在循环、条件判断或子函数中调用。

  2. 只能在 React 组件或自定义 Hook 中调用 Hook,不要在其它 JavaScript 函数中调用。

相关的 linter 插件可以自动执行这些规则。

自定义 Hook

自定义 Hook 可以在不增加组件的前提下,在组件间重用状态逻辑。

import { useState, useEffect } from 'react';

function useCount(original_count) {
  const [count, setCount] = useState(original_count);

  useEffect(() => {
    document.title = `click ${count} times`;
    return () => {
      console.log('clear effect');
    };
  });
  
  return count;
}

Hook 是一种复用状态逻辑的方式,它不复用 state 本身。事实上,每次调用 Hook 都有一个完全独立的 state,因此可以在单个组件中多次调用同一个自定义 Hook。

自定义 Hook 更像是一种约定而非特性。如果函数以“use”开头并调用了其它 Hook,就称为自定义 Hook。useSomething 的命名约定可以让 linter 插件在使用了 Hook 的代码中找到 bug。

其它 Hook

useContext 让使用者不使用组件嵌套就可以订阅 React 的 Context。

useReducer 可以让使用者通过 reducer 来管理组件本地的复杂 state。

State Hook

声明 State 变量

import React, { useState } from 'react';

function Example(props) {
  const [count, setCount] = useState(0);
}
  1. 调用 useState 方法

    定义了一个“state 变量”,即使退出了函数,该变量也会被 React 保留。useState 方法可以调用多次以使用多个状态。

  2. useState 方法的参数

    useState 方法唯一的参数就是初始 state 值,该初始值可以是数字、字符串等任意数据类型,而不必是对象。

  3. useState 方法的返回值

    当前 state 和一个更新相应 state 的函数,该函数总是替换整个变量来更新 state,而不是合并。

React 会记住 state 当前值,在重复渲染时提供最新的值给该函数,这也是 Hook 不命名为 createSomething 的原因。

单个 state 还是多个 state?

State Hook 更新对象类型的状态是直接替代而非合并,通过自定义 Hook 可以实现合并方式的状态更新。但是,React 建议把状态拆分成多个 state 变量,每个变量包含的不同值会同时发生改变。这样拆分的另一个好处是,方便把相关的逻辑抽取为自定义 Hook。所有 state 都放到一个 useState 调用中,和每个字段对应一个 useState 调用,两种方式都能实现需求;但使用者往往需要在这两个极端情况之间找到一个平衡点,然后把相关的状态组合到几个独立的 state 变量中,增加组件的可读性。

Hook 调用和组件之间如何联系?

React 追踪当前渲染中的组件。而 Hook 规范约定了只有在 React 组件中才能调用 Hook。

每个组件内部都有一个“记忆单元格”列表,它是用来存储数据的对象。当使用 useState() 调用一个 Hook 时,它会读取当前的单元格,或者在首次渲染时将其初始化,然后把指针移动到下一个,这使得多个 useState() 调用会得到各自独立的本地状态。

Effect Hook

Effect Hook 使函数组件可以执行副作用。数据获取、设置订阅、手动更改 React 组件中的 DOM 都属于副作用。

React 组件中的副作用分两种:需要清除的、不需要清除的。

无需清除的 effect

有时,使用者只想在 React 更新 DOM 之后额外运行一些代码,如手动变更 DOM、记录日志等。由于执行完这些操作之后就可以忽略它们,因此这些操作都是无需清除的。

class 组件中执行这些副作用,需要在两个生命周期函数 componentDidMountcomponentDidUpdate 中编写重复的代码。

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

function Example(props) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `Click ${count} times`;
  });

  return (
    <div>
      <h1>{count}</h1>
      <button onclick={() => setCount(count + 1)}>Click</button>
    </div>
  );
}

useEffect 接收一个副作用函数,React 会保存该函数,并且,默认情况下,每次 DOM 更新后都会执行该副作用。

useEffect 在组件内部调用,使得副作用函数可以直接访问组件的 stateprops

组件每次渲染时,useEffect 都接收一个全新的函数,使得 effect 可以获取最新的 state。这也让 effect 更像是渲染结果的一部分,即每个 effect “属于”一次特定的渲染。

componentDidMountcomponentDidUpdate 不同的是,useEffect 调度的 effect 不会阻塞浏览器更新屏幕。

需要清除的 effect

有些副作用,如:订阅外部数据源,必须要清除。清除工作非常重要,可以防止内存泄露。

对于这些副作用,class 组件必须同时在 componentDidMountcomponentWillUnmount 中做相应的处理,这使得一个副作用被拆到了不同的生命周期。

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

function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);
  
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading ...';
  }
  return isOnline ? 'Online' : 'Offline';
}

React 会在组件卸载时执行清除操作。每次渲染 React 都会执行 effect,因此,React 允许 effect 返回值是一个清除函数,用于清除上一个 effect 所产生的影响。

使用多个 Effect Hook 实现关注点分离

class 组件中一个副作用可能要拆分到多个生命周期中,而同一个生命周期中可能还要执行多个副作用。

使用多个 useEffect 即可实现副作用分离,Hook 允许按照代码用途进行分离,React 将按照 effect 声明的顺序依次调用组件中的每个 effect。

默认情况下,每次重新渲染时,React 都会清除上一个 effect,然后再执行当前 effect。这让注册 effect 和清除 effect 之间的数据保持一致,同时 effect 也可以得到最新的 state 和 props。

Effect 性能优化

某些情况下,每次渲染都执行清除或执行 effect 会导致性能问题。useEffect 提供了第二个数组参数作为依赖列表,来解决这一问题。

useEffect(() => {
  document.title = `click ${count} times`;
}, [count]);

只要数组内的值没有发生变化,React 就会跳过这个 effect。使用这种优化方式,需要确保数组中包含了所有外部作用域中会随着时间变化并且在 effect 中使用过的变量。如果要执行且仅执行一次 effect,可以传入一个空数组作为第二个参数。

在依赖列表中省略函数

function Example({ someProp }) {
  function doSomething() {
    console.log(someProp);
  }
  
  useEffect(() => {
    doSomething();
  }, []); // 不安全,doSomething 中使用了 someProp
}

依赖列表必须包含回调中参与 React 数据流的所有值。这里面包括 props、state,以及由它们衍生出来的其它值。如果调用了 effect 之外的函数,则需要把该函数加入到依赖列表中,如果省略了函数可能会产生 bug。通常在 effect 内部声明它所需要的函数,这样可以容易看出该 effect 依赖于组件作用域中的哪些值,从而不必将函数加入到依赖列表中。这也允许通过 effect 内部的局部变量来处理无序的响应。

useEffect(() => {
  let ignore = false;

  async function fetchProduct() {
    const { data } = await findData(productId);
    if (!ignore) setProduct(data);
  }

  return () => {
    ignore = true;
  }
}, [productId]);

如果无法将函数移动到 effect 内部,可以采用其它解决方案:

  1. 将函数移动到组件之外,让该函数不依赖于任何 props、state。

  2. 对于纯计算的并且可以在渲染时调用的函数,可以在 effect 之外调用它,让 effect 依赖于它的返回值。

  3. 把函数加入 effect 依赖,但是把它的定义包裹在 useCallback Hook 中,确保它不随渲染变化,而只随自身依赖改变。

Hook 规则

使用 Hook 时必须遵守以下规则:

  1. 只在最顶层使用 Hook

    不要在循环、条件、嵌套函数中调用 Hook,确保总是在 React 函数内的最顶层以及任何 return 之前调用 Hook。确保 Hook 在每次渲染中都按照同样的顺序被调用

  2. 只在 React 函数中使用 Hook

    不要在普通的函数中调用 Hook,只在 React 函数组件或自定义 Hook 中调用 Hook。确保组件的状态逻辑在代码中清晰可见。

上述两条规则可以使用名为 eslint-plugin-react-hooks 的 ESLint 插件强制执行。

之所以约定这些规则,是因为React 依靠 Hook 的调用顺序来确定 state、effect 等应用侧变量与 useStateuseEffect 等框架侧 Hook 之间的对应关系。只要 Hook 的调用顺序在多次渲染间保持一致,React 就能将两者正确地关联起来。因此,如果需要依条件执行一个 effect,可以将判断放到 Hook 内部,而不能将 Hook 放到条件分支中。

自定义 Hook

自定义 Hook 可以在组件之间共享状态逻辑,实现和 render props/高阶组件相同的功能。

自定义 Hook 是一个函数,其名称以“use”开头,函数内部可以调用其他 Hook。

import { useEffect, useState } from "react";

export function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);
  
  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }

  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

自定义 Hook 是一种自然遵循 Hook 设计的约定,而不是 React 的特性。

自定义 Hook 解决了以前在 React 组件中无法灵活共享逻辑的问题。

尽量避免过早地增加抽象逻辑。但 React 仍建议通过自定义 Hook 寻找可能,已达到简化代码逻辑,解决组件杂乱无章的目的。