二、使用状态挂钩

现在,您已经了解了 React 的原理并对挂钩进行了介绍,我们将深入了解状态挂钩。我们将首先通过自己重新实现来学习 State 挂钩如何在内部工作。接下来,我们将了解挂钩的一些局限性,以及它们存在的原因。然后,我们将了解可能的替代挂钩 API 及其相关问题。最后,我们将学习如何解决由于挂钩的局限性而导致的常见问题。在本章末尾,我们将知道如何使用状态挂钩在 React 中实现有状态的功能组件。

本章将介绍以下主题:

  • useState挂钩重新实现为一个简单的函数,该函数访问全局状态
  • 将我们的重新实现与真实的 React 挂钩进行比较,并了解其中的差异
  • 了解可能的替代挂钩 API 及其权衡
  • 解决因挂钩的局限性而导致的常见问题
  • 用条件挂钩解决问题

技术要求

应该已经安装了 Node.js 的最新版本(v11.12.0 或更高版本)。Node.js 的npm包管理器也需要安装。

本章的代码可以在 GitHub 存储库中找到:https://github.com/PacktPublishing/Learn-React-Hooks/tree/master/Chapter02

请查看以下视频以查看代码的运行情况:

http://bit.ly/2Mm9yoC

Please note that it is highly recommended that you write the code on your own. Do not simply run the code examples that have been previously provided. It is important that you write the code yourself so that you learn and understand it properly. However, if you run into any issues, you can always refer to the code example.

现在,让我们从这一章开始。

重新实现 useState 函数

为了更好地理解挂钩的内部工作原理,我们将从头开始重新实现useState挂钩。然而,我们不打算将它实现为一个实际的 React 挂钩,而是作为一个简单的 JavaScript 函数来实现,只是为了了解挂钩实际上在做什么。

Please note that this reimplementation is not exactly how React Hooks work internally. The actual implementation is similar, and thus, it has similar constraints. However, the real implementation is much more complicated than what we will be implementing here.

我们现在将开始重新实现状态挂钩:

  1. 首先,我们从chapter1_2复制代码,我们将用自己的实现替换当前的useState挂钩。
  2. 打开src/App.js并通过移除以下管线移除吊钩进口:
import React, { useState } from 'react'

将其替换为以下代码行:

import React from 'react'
import ReactDOM from 'react-dom'

We are going to need ReactDOM in order to force rerendering of the component in our reimplementation of the useState Hook. If we used actual React Hooks, this would be dealt with internally.

  1. 现在,我们定义自己的useState函数。我们已经知道,useState函数将initialState作为参数:
function useState (initialState) {
  1. 然后,我们定义一个值,在其中存储状态。首先,该值将设置为initialState,作为参数传递给函数:
    let value = initialState
  1. 接下来,我们定义setState函数,在这里我们将把值设置为不同的值,并强制我们的MyName组件重新排序:
    function setState (nextValue) {
        value = nextValue
        ReactDOM.render(<MyName />, document.getElementById('root'))
    }
  1. 最后,我们将valuesetState函数作为数组返回:
    return [ value, setState ]
}

我们之所以使用数组而不是对象,是因为我们通常希望重命名valuesetState变量。使用数组可以通过分解结构轻松重命名变量:

const [ name, setName ] = useState('')

正如我们所看到的,挂钩是处理副作用的简单 JavaScript 函数,例如设置有状态值。

我们的挂钩函数使用闭包来存储当前值。闭包是一个存在并存储变量的环境。在我们的例子中,函数提供闭包,value变量存储在该闭包中。setState函数也在同一个闭包中定义,这就是为什么我们可以访问该函数中的value变量。在useState函数之外,我们不能直接访问value变量,除非我们从函数返回它。

简单挂钩实现的问题

如果我们现在运行挂钩实现,我们会注意到当我们的组件重新加载时,状态被重置,因此我们不能在字段中输入任何文本。这是由于每次呈现组件时都会重新初始化value变量,这是因为每次呈现组件时都会调用useState

在接下来的部分中,我们将使用一个全局变量来解决这个问题,然后将简单值转换为一个数组,从而允许我们定义多个挂钩。

使用全局变量

正如我们所了解的,该值存储在由useState函数定义的闭包中。每次组件重新启动时,闭包都会重新初始化,这意味着我们的值将被重置。要解决这个问题,我们需要将值存储在函数外部的全局变量中。这样,value变量将位于函数外部的闭包中,这意味着当函数再次被调用时,闭包将不会被重新初始化。

我们可以定义一个全局变量,如下所示:

  1. 首先,我们在useState函数定义的上方增加以下一行(粗体):
let value

function useState (initialState) {
  1. 然后,我们用以下代码替换函数中的第一行:
       if (typeof value === 'undefined') value = initialState

现在,我们的useState函数使用全局value变量,而不是在其闭包中定义value变量,因此当再次调用该函数时,它不会被重新初始化。

定义多个挂钩

我们的挂钩功能有效!然而,如果我们想添加另一个挂钩,我们将遇到另一个问题:所有挂钩都写入同一个全局value变量!

让我们仔细看看这个问题,通过添加第二个挂钩到我们的组件。

向组件添加多个挂钩

假设我们要为用户的姓氏创建第二个字段,如下所示:

  1. 我们首先在函数的开头创建一个新的挂钩,在当前挂钩之后:
    const [ name, setName ] = useState('')
 const [ lastName, setLastName ] = useState('')
  1. 然后,我们定义另一个handleChange函数:
    function handleLastNameChange (evt) {
        setLastName(evt.target.value)
    }
  1. 接下来,我们将lastName变量放在名字后面:
            <h1>My name is: {name} {lastName}</h1>
  1. 最后,我们添加另一个input字段:
            <input type="text" value={lastName} onChange={handleLastNameChange}
   />

当我们尝试这个方法时,我们会注意到我们重新实现的 Hook 函数对两个状态使用相同的值,所以我们总是同时更改两个字段。

实现多挂钩

为了实现多个挂钩,我们应该有一个挂钩值数组,而不是一个全局变量。

我们现在将把value变量重构为一个values数组,这样我们就可以定义多个挂钩:

  1. 删除以下代码行:
let value

将其替换为以下代码段:

let values = []
let currentHook = 0
  1. 然后,编辑useState函数的第一行,我们现在初始化values数组的currentHook索引处的值:
    if (typeof values[currentHook] === 'undefined') values[currentHook] = initialState
  1. 我们还需要更新 setter 函数,以便只更新相应的状态值。在这里,我们需要将currentHook值存储在一个单独的hookIndex变量中,因为currentHook值稍后会更改。这确保在useState函数的闭包内创建currentHook变量的副本。否则,useState函数将从外部闭包访问currentHook变量,每次调用useState都会修改该变量:
    let hookIndex = currentHook
    function setState (nextValue) {
        values[hookIndex] = nextValue
        ReactDOM.render(<MyName />, document.getElementById('root'))
    }
  1. 编辑useState函数的最后一行,如下所示:
        return [ values[currentHook++], setState ]

使用values[currentHook++]currentHook的当前值作为索引传递给values数组,然后将currentHook增加 1。这意味着从该功能返回后,currentHook将增加。

If we wanted to first increment a value and then use it, we could use the arr[++indexToBeIncremented] syntax, which first increments, and then passes the result to the array.

  1. 当我们开始渲染组件时,我们仍然需要重置currentHook计数器。在零部件定义的正后方添加以下行(粗体):
function Name () {
    currentHook = 0

最后,我们简单地重新实现了useState挂钩!以下屏幕截图突出显示了这一点:

Our custom Hook reimplementation works

如我们所见,使用全局数组存储挂钩值解决了定义多个挂钩时遇到的问题。

示例代码

简单挂钩重新实现的示例代码可以在Chapter02/chapter2_1文件夹中找到。

只需运行npm install安装所有依赖项并启动npm start应用,然后在浏览器中访问http://localhost:3000(如果它没有自动打开)。

我们能定义条件挂钩吗?

如果我们想添加一个复选框来切换名字字段的使用,该怎么办?

让我们通过实现这样一个复选框来了解:

  1. 首先,我们添加一个新的挂钩以存储复选框的状态:
    const [ enableFirstName, setEnableFirstName ] = useState(false)
  1. 然后,我们定义一个处理函数:
    function handleEnableChange (evt) {
        setEnableFirstName(!enableFirstName)
    }
  1. 接下来,我们呈现一个复选框:
            <input type="checkbox" value={enableFirstName} onChange={handleEnableChange} />
  1. 如果禁用了名字,我们不想显示它。编辑以下现有行以添加对enableFirstName变量的检查:
            <h1>My name is: {enableFirstName ? name : ''} {lastName}</h1>
  1. 我们是否可以将挂钩定义放入一个if条件或三元表达式中,就像下面的代码片段一样?
    const [ name, setName ] = enableFirstName
        ? useState('')
        : [ '', () => {} ]
  1. react-scripts的最新版本实际上在定义条件挂钩时抛出了一个错误,因此我们需要通过运行以下命令来降级本例中的库:
> npm install --save react-scripts@^2.1.8

在这里,我们要么使用挂钩,要么如果名字被禁用,我们返回初始状态和一个空的 setter 函数,这样编辑输入字段就不起作用了。

如果我们现在尝试这段代码,我们会注意到编辑姓氏仍然有效,但编辑名字不起作用,这正是我们想要的。正如我们在下面的屏幕截图中所看到的,现在仅编辑姓氏有效:

State of the app before checking the checkbox

单击复选框时,会发生一些奇怪的情况:

  • 选中该复选框
  • “名字”输入字段已启用
  • 姓字段的值现在是名字段的值

我们可以在以下屏幕截图中看到单击复选框的结果:

State of the app after checking the checkbox

我们可以看到姓氏状态现在在 first name 字段中。由于挂钩的顺序很重要,所以这些值被交换了。从我们的实现中我们知道,我们使用currentHook索引来知道每个挂钩的状态存储在哪里。然而,当我们在两个现有的挂钩之间插入一个额外的挂钩时,顺序就会混乱。

选中复选框前,values数组如下:

  • [false, '']
  • 挂钩顺序:enableFirstNamelastName

然后,我们在lastName字段中输入了一些文本:

  • [false, 'Hook']
  • 挂钩顺序:enableFirstNamelastName

接下来,我们切换了复选框,它激活了我们的新挂钩:

  • [true, 'Hook', '']
  • 挂钩顺序:enableFirstNamenamelastName

我们可以看到,在两个现有挂钩之间插入一个新挂钩会使name挂钩从下一个挂钩(lastName中窃取状态,因为它现在具有与lastName挂钩以前相同的索引。现在,lastName挂钩没有值,这导致它设置初始值(空字符串)。因此,切换复选框会将lastName字段的值放入name字段。

示例代码

我们的简单挂钩重新实现的条件挂钩问题的示例代码可以在Chapter02/chapter2_2文件夹中找到。

只需运行npm install安装所有依赖项,并启动npm start应用,然后在浏览器中访问http://localhost:3000(如果它没有自动打开)。

将我们的重新实现与真实挂钩进行比较

我们的简单挂钩实现已经让我们了解挂钩如何在内部工作。然而,在现实中,挂钩并不使用全局变量。相反,它们将状态存储在 React 组件中。它们也在内部处理挂钩计数器,因此我们不需要手动重置函数组件中的计数。此外,当状态改变时,真正的挂钩会自动触发组件的重新加载。然而,要做到这一点,需要从 React 函数组件调用挂钩。不能在 React 外部或 React 类组件内部调用 React 挂钩。

通过重新实现useState挂钩,我们学到了一些东西:

  • 挂钩只是访问 React 特性的函数
  • 挂钩处理在重渲染器中持续存在的副作用
  • 定义的顺序很重要

最后一点特别重要,因为这意味着我们不能有条件地定义挂钩。我们应该始终在函数组件的开头有所有的挂钩定义,并且永远不要将它们嵌套在if或其他构造中。

在这里,我们还学到了以下内容:

  • React 挂钩需要在 React 函数组件内部调用
  • 不能有条件地或在循环中定义 React 挂钩

我们现在将研究允许条件挂钩的替代挂钩 API。

替代挂钩 API

有时,有条件地或在循环中定义挂钩会很好,但是为什么 React 团队决定实现这样的挂钩呢?有哪些替代方案?让我们看看其中的一些。

命名挂钩

我们可以给每个挂钩一个名称,然后将挂钩存储在对象中,而不是数组中。但是,这并不是一个好的 API,我们还必须始终考虑为挂钩提供唯一的名称:

// NOTE: Not the actual React Hook API
const [ name, setName ] = useState('nameHook', '')

此外,如果将条件设置为false,或者从循环中删除一项,会发生什么情况?我们能清除胡克州吗?如果不清除挂钩状态,可能会导致内存泄漏。

即使我们解决了所有这些问题,仍然存在名称冲突的问题。例如,如果我们创建一个使用useState挂钩的自定义挂钩,并将其命名为nameHook,那么我们就不能再在组件中调用任何其他挂钩nameHook,否则我们将导致名称冲突。库中的挂钩名称也是如此,因此我们需要确保避免与库中定义的挂钩发生名称冲突!

挂钩工厂

或者,我们也可以创建一个挂钩工厂函数,在内部使用Symbol,以便为每个挂钩指定一个唯一的键名:

function createUseState () {
    const keyName = Symbol()

    return function useState () {
        // ... use unique key name to handle hook state ...
    }
}

然后,我们可以使用工厂功能,如下所示:

// NOTE: Not the actual React Hook API
const useNameState = createUseState()

function MyName () {
    const [ name, setName ] = useNameState('')
    // ...
}

然而,这意味着我们需要实例化每个挂钩两次:一次在组件外部,一次在函数组件内部。这为错误创造了更多的空间。例如,如果我们创建两个挂钩并复制和粘贴样板代码,那么我们可能会因为工厂函数而在挂钩的名称上出错,或者在组件内部使用挂钩时出错。

这种方法也使得创建自定义挂钩变得更加困难,这迫使我们编写包装函数。此外,调试这些包装函数比调试简单函数更困难。

其他选择

有许多针对 React 挂钩的备选 API,但它们都面临着类似的问题:要么使 API 更难使用、更难调试,要么引入名称冲突的可能性。

最后,React 团队决定最简单的 API 是通过计算挂钩的调用顺序来跟踪挂钩。这种方法有其自身的缺点,例如不能有条件地或在循环中调用挂钩。然而,这种方法使我们很容易创建自定义挂钩,而且使用和调试都很简单。我们也不需要担心命名挂钩、名称冲突或编写包装函数。挂钩的最后一种方法让我们像使用任何其他函数一样使用挂钩!

用挂钩解决常见问题

正如我们发现的,用官方 API 实现挂钩也有其自身的权衡和局限性。我们现在将学习如何克服这些常见问题,这些问题源于 React 挂钩的局限性。

我们将研究可用于克服这两个问题的解决方案:

  • 求解条件挂钩
  • 求解循环中的挂钩

求解条件挂钩

那么,我们如何实现条件挂钩呢?我们可以随时定义挂钩并在需要时使用它,而不是将挂钩设置为有条件的。如果这不是一个选项,我们需要分割我们的组件,这通常是更好的!

总是定义挂钩

对于简单的情况,例如我们前面的名字和姓氏示例,我们可以始终定义挂钩,如下所示:

const [ name, setName ] = useState('')

对于简单的情况,总是定义挂钩通常是一个很好的解决方案。

拆分组件

解决条件挂钩的另一种方法是将一个组件拆分为多个组件,然后有条件地渲染这些组件。例如,假设我们希望在用户登录后从数据库获取用户信息。

我们无法执行以下操作,因为使用if条件可能会更改挂钩的顺序:

function UserInfo ({ username }) {
    if (username) {
        const info = useFetchUserInfo(username)
        return <div>{info}</div>
    }
    return <div>Not logged in</div>
}

相反,我们必须为用户登录时创建一个单独的组件,如下所示:

function LoggedInUserInfo ({ username }) {
    const info = useFetchUserInfo(username)
    return <div>{info}</div>
}

function UserInfo ({ username }) {
    if (username) {
        return <LoggedInUserInfo username={username} />
    }
    return <div>Not logged in</div>
}

对于非登录状态和登录状态使用两个单独的组件是有意义的,因为我们希望坚持每个组件有一个功能的原则。因此,如果我们坚持最佳实践,通常情况下,没有条件挂钩并不是一个很大的限制。

求解循环中的挂钩

至于循环中的挂钩,我们可以使用包含数组的单个状态挂钩,也可以拆分组件。例如,假设我们想要显示所有在线用户。

使用数组

我们可以简单地使用包含所有users的数组,如下所示:

function OnlineUsers ({ users }) {
    const [ userInfos, setUserInfos ] = useState([])
    // ... fetch & keep userInfos up to date ...
    return (
        <div>
            {users.map(username => {
                const user = userInfos.find(u => u.username === username)
                return <UserInfo {...user} />
            })}
        </div>
    )
}

然而,这可能并不总是有意义的。例如,我们可能不想通过OnlineUsers组件更新user状态,因为我们必须从数组中选择正确的user状态,然后修改数组。这可能行得通,但相当乏味。

拆分组件

更好的解决方案是在UserInfo组件中使用挂钩。这样,我们可以使每个用户的状态保持最新,而不必处理阵列逻辑:

function OnlineUsers ({ users }) {
    return (
        <div>
            {users.map(username => <UserInfo username={username} />)}
        </div>
    )
}

function UserInfo ({ username }) {
    const info = useFetchUserInfo(username)
    // ... keep user info up to date ...
    return <div>{info}</div>
}

正如我们所看到的,为每个功能使用一个组件可以保持代码的简单和简洁,同时也避免了 React 挂钩的限制。

用条件挂钩解决问题

现在我们已经了解了条件挂钩的不同替代方案,我们将解决之前在小示例项目中遇到的问题。解决这个问题的最简单方法是始终定义挂钩,而不是有条件地定义它。在这样一个简单的项目中,总是定义挂钩最有意义。

编辑src/App.js并移除以下条件挂钩:

    const [ name, setName ] = enableFirstName
        ? useState('')
        : [ '', () => {} ]

将其更换为普通挂钩,例如:

    const [ name, setName ] = useState('')

现在,我们的示例运行良好!在更复杂的情况下,总是定义挂钩可能是不可行的。在这种情况下,我们需要创建一个新组件,在那里定义挂钩,然后有条件地呈现该组件。

示例代码

条件挂钩问题的简单解决方案示例代码可在Chapter02/chapter2_3文件夹中找到。

只需运行npm install安装所有依赖项,并启动npm start应用,然后在浏览器中访问http://localhost:3000(如果它没有自动打开)。

总结

在本章中,我们首先通过使用全局状态和闭包来重新实现useState函数。然后我们了解到,为了实现多个挂钩,我们需要使用状态数组。然而,通过使用状态数组,我们被迫在函数调用之间保持挂钩的顺序一致。这种限制使得条件挂钩和循环中的挂钩成为不可能。然后,我们了解了 Hook API 的可能替代方案、它们之间的权衡以及选择最终 API 的原因。最后,我们学习了如何解决由挂钩的局限性引起的常见问题。我们现在对挂钩的内部工作原理和局限性有了坚实的理解。此外,我们还深入了解了状态挂钩。

在下一章中,我们将使用状态挂钩创建博客应用,并学习如何组合多个挂钩。

问题

为了总结本章所学内容,请尝试回答以下问题:

  1. 我们在开发自己的useState挂钩的重新实现时遇到了什么问题?我们是如何解决这些问题的?
  2. 为什么在挂钩的 React 实现中不可能使用条件挂钩?
  3. 什么是挂钩,它们处理什么?
  4. 使用挂钩时,我们需要注意什么?
  5. 挂钩的替代 API 思想的常见问题是什么?
  6. 我们如何实现条件挂钩?
  7. 我们如何在循环中实现挂钩?

进一步阅读

如果您有兴趣了解更多关于我们在本章学到的概念,请阅读以下阅读材料: