【译】自定义 React Hooks 及其使用时机

avatar

React hooks 是能让你在 React 函数组件中使用和交互状态的函数。React 自带一些内置 hooks,其中最常用的是 useStateuseRefuseEffect。前两者用于在渲染间存储数据,而后者用于在数据变化时执行副作用。

我们也可以利用内置 hooks 来构建我们自己的 hooks。这些通常被称为“自定义 hooks”,以区别于内置 hooks。根据我的经验,自定义 hooks 是 React 中最不常用的抽象之一。对于新手来说,了解如何构建自定义 hooks 或何时使用它们可能会有些困难。本文将重点解答这些问题。

内置 hooks

为了快速回顾一下 hooks,这里是一个示例 React 组件,它跟踪一个计数并在计数变化时记录到控制台:

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

  useEffect(() => {
    console.log(`Count changed to ${count}`);
  }, [count]);

  return (
    <div>
      The count is: {count}.
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

上面的组件使用 useState hook 存储 count 作为状态,并且通过使用 useEffect hook 在任何时候 count 变化时记录该值。

什么是自定义 hooks

在 React 文档中,关于构建自定义 hooks 的定义很简单:

自定义 Hook 是一个 JavaScript 函数,其名称以 "use" 开头,并且可能调用其他 Hooks。

就是这样!如果你在一个组件中有一些代码,觉得将其提取出来可能会有意义,无论是为了在其他地方重用还是为了保持组件的简洁性,你可以将其提取到一个函数中。如果该函数调用了其他 hooks,比如 useEffectuseState,或者可能是另一个自定义 hook,那么你的函数本身也是一个 hook,并且按照惯例,应该以 "use" 开头命名,以明确表示它是一个 hook。

如果 hooks 和普通函数如此相似,你可能会想知道我们为什么需要“hook”这个概念。我们需要这个概念的原因是因为 hooks 是特殊的。它们是函数,同时也具有由 React 在内部持久化的状态。因此,必须始终遵循 hooks 的规则,以确保 React 不会混淆。使用 "use..." 命名约定帮助我们识别哪些函数是 hooks,这样我们就可以确保遵循规则。

接下来是一些创建自定义 hook 可能有意义的情况的示例。

可重用的自定义 hooks

大多数 React 开发者都熟悉将可重用功能提取到组件或函数中,但有时不太习惯将代码提取到 hooks 中。如果我们有代码想要从组件中提取出来,如果满足以下条件,自定义 hook 可能是适当的提取方式:

  • 提取的代码没有 JSX 输出(如果有,则应创建一个组件)
  • 并且提取的代码包含对其他 hooks 的调用(如果没有,则创建一个常规函数)

让我们以一个简单的可重用 hook 为例。假设在我们的应用中,每次渲染新页面时我们都想要更改文档标题。我们可能在许多组件中都有这样的代码:

function HomePage {
  useEffect(() => {
    document.title = 'Home'
  }, [])

  return <div>Home Page...</div>
}

我们可以通过将标题逻辑提取到一个 hook 中来简化这段代码:

function useTitle(title) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}

现在,我们的主页看起来是这样的:

function HomePage {
  useTitle('Home')

  return <div>Home Page...</div>
}

这是一个简单的例子,在这种情况下,可能不创建 hook 也没问题,但随着功能变得更复杂或在更多地方使用,提取行为变得越来越有用。

或者,这里是另一个有用的可重用自定义 hook:

/**
类似于 useState,但它会跟踪先前的值并在数组中返回它。
使用方法:
const [value, previousValue, setValue] = useStateWithPrevious('initialValue')
*/
function useStateWithPrevious(initialValue) => {
  const reducer = (state, value) => ({
    value,
    previousValue: state.value,
  });

  const [{ value, previousValue }, setValue] = useReducer(reducer, {
    value: initialValue,
  });

  return [value, previousValue, setValue];
};

function App() {
  const [name, previousName, setName] = useStateWithPrevious('')
  // 现在我们始终可以访问先前的值
}

对于更多自定义 hook 的灵感,我建议查看 react-use

提取功能的非可重用自定义 hooks

就像组件一样,有时创建一个自定义 hook 即使它不能作为可重用部分,也可以作为一种提取功能的方式,以使父组件更易于理解。

在评估是否值得提取一个不可重用 hook 时,我使用与任何其他提取相同的标准:

  • 父代码通过抽象更易理解吗?
  • 抽象是否足够孤立以单独存在?

如果以上任何一个答案是否定的,那么这个 hook 看起来不是一个干净的抽象,而且将 hook 放回组件内联可能更容易理解代码。即使两个答案都是肯定的,仍然要问自己,这个抽象是否值得由这个新层创建的额外复杂性。

确定提取多少

并不总是清楚最好在哪里定义你的抽象。这个选择会影响你的新抽象应该是一个 hook 还是一个组件还是一个常规函数。

考虑这个编造的组件,在这里我们从全局存储(如 Redux 或 React 上下文)中获取用户,然后显示一个消息,比如“Hello, Dr. Jane García”。我们还假设我们的应用程序需要在几个不同的地方显示相同的消息,因此希望提取一些这样的功能以供重用。

下面是原始的没有提取的组件:

function MyComponent({ displayTitle }) {
  const user = useSelector((state) => state.user);

  const formattedName = _.compact([
    displayTitle ? user.title : null,
    user.firstName,
    user.middleName,
    user.lastName,
  ]).join(" ");

  return (
    <>
      <h1>Hello, {formattedName)}</h1>
      <p>Some text</p>
    </>
  );
}

我们可以做的最简单的提取是将格式化用户姓名的逻辑提取到一个单独的函数中:

function formatUserName(user, { displayTitle }) {
  return _.compact([
    displayTitle ? user.title : null,
    user.firstName,
    user.middleName,
    user.lastName,
  ]).join(" ");
}

function MyComponent({ displayTitle }) {
  const user = useSelector((state) => state.user);

  // extracted logic to a function
  const formattedName = formatUserName(user, { displayTitle });

  return (
    <>
      <h1>Hello, {formattedName}</h1>
      <p>Some text</p>
    </>
  );
}

另一个选择是同时提取 useSelector 调用,这意味着我们现在创建的是一个 hook 而不是一个常规函数(记住:如果你的函数调用了一个 hook,那么你的函数就是一个 hook):

// 提取了所有内容,包括全局存储的访问,因此这是一个 hook
function useFormattedUserName({ displayTitle }) {
  const user = useSelector((state) => state.user);
  const formattedName = _.compact([
    displayTitle ? user.title : null,
    user.firstName,
    user.middleName,
    user.lastName,
  ]).join(" ");

  return formattedName;
}

function MyComponent({ displayTitle }) {
  const formattedName = useFormattedUserName({ displayTitle });

  return (
    <>
      <h1>Hello, {formattedName}</h1>
      <p>Some text</p>
    </>
  );
}

第三个选择,甚至更加严格的方式是将其提取到一个组件中:

function UserGreeting({ displayTitle }) {
  const user = useSelector((state) => state.user);
  const formattedName = _.compact([
    displayTitle ? user.title : null,
    user.firstName,
    user.middleName,
    user.lastName,
  ]).join(" ");

  return <h1>Hello, {formattedName}</h1>;
}

function MyComponent({ displayTitle }) {
  return (
    <>
      <UserGreeting displayTitle={displayTitle} />
      <p>Some text</p>
    </>
  );
}

上面的例子有些牵强,但它们展示了在选择正确的抽象点时的一些微妙之处。构建抽象的方式总是多种多样的。

正如我们将在下一节中讨论的那样,通常最好创建较小的抽象,即使存在一些重复的代码风险。因此,在这种情况下,最好只创建 formatUserName 函数,并允许在各个位置显示问候语时稍微重复使用 useSelector 调用和 JSX 输出。

如果我们相反地创建了 <UserGreeting /> 组件,一个更大的抽象,其中包括显示/样式化,那么每当我们需要以不同的样式、称谓或 HTML 标记显示用户姓名时,我们都会被迫修改该抽象。这会在代码库中引入不希望的耦合,因为该组件发生变化时会影响不同部分的代码。

抽象的代价

正如我们在上一节中讨论的那样,抽象并不是免费的。它们有诸如创建耦合、引入额外层和间接性等缺点。有时,最好的解决方案是放弃抽象,接受一些重复。

随着时间的推移,需求会发生变化。当我们更新一个抽象时,我们必须考虑更新如何影响所有调用点。这会减慢我们的速度,也容易引入错误。

除了创建耦合之外,抽象还会在应用程序中添加额外的层和间接性,使代码更难理解。我们的大脑一次只能记住几层信息,因此随着复杂性的增加,我们对功能的理解就会减少。

React 文档建议如下,关于抽象:

尽量不要过早添加抽象。现在函数组件可以做更多的事情,你代码库中的平均函数组件可能会变得更长。这是正常的——不要觉得你必须立即将其拆分为 Hooks。但我们也鼓励你开始发现那些自定义 Hook 可以在简单接口后隐藏复杂逻辑的情况,或者帮助解开混乱组件的情况。

如果你不确定是否需要添加抽象,那么可能不需要!如果你不确定如何最好地塑造一个抽象,考虑一下等待需求明确。

结论

自定义 hooks 是一个有用的抽象。理解它们以及何时使用它们对构建可维护的 React 应用程序至关重要。虽然组件、hooks、函数和其他模式都在开发者工具箱中各有用处,但我们在创建抽象时也应谨慎,理解它们会创建耦合和额外的层和间接性。

原文地址