现在您已经学习了 React 的原理并介绍了 Hook,我们将深入学习 State Hook。我们将通过重新实现 State Hook 来了解它在内部是如何工作的。接下来,我们了解 Hook 的一些限制以及它们存在的原因。然后,我们将了解可能的替代 Hook API 及其相关问题。最后,我们将学习如何解决由于 Hook 的局限性而导致的常见问题。在本章结束时,我们将知道如何在 React 中使用 State Hook 来实现有状态函数组件。
本章将介绍以下主题:
- 将
useState
Hook 重新实现为一个简单的函数,它访问全局状态 - 将我们的重新实现与真正的 React Hook 进行比较并了解差异
- 了解可能的替代 Hook API 及其权衡
- 解决由 Hook 限制导致的常见问题
- 使用条件 Hook 解决问题
技术要求
应该已经安装了相当新版本的 Node.js(vll.l2.0 或更高版本)。还需要安装 Node.js 的 npm 包管理器。
本章的代码可以在 GitHub 存储库中找到:https://github.com/PacktPublishing/Learn-React-Hooks/tree/master/Chapter02。
观看以下视频,了解代码的实际应用:
请注意,强烈建议您自己编写代码。不要简单地运行以前提供的代码示例。重要的是您自己编写代码,以便您正确地学习和理解它。但是,如果遇到任何问题,始终可以参考代码示例。
现在,让我们从本章开始。
重新实现 useState 函数
为了更好地理解 Hook 在内部是如何工作的,我们将从头开始重新实现 useState
Hook。但是,我们不会将其实现为实际的 React Hook,而是作为一个简单的 JavaScript 函数实现——只是为了了解 Hook 实际在做什么。
请注意,这种重新实现并不完全是 React Hook 内部的工作方式。实际实现是相似的,因此具有类似的约束。但是,真正的实现比我们在这里实现的要复杂得多。
我们现在将开始重新实现 State Hook:
- 首先,我们从
chapter1_2
复制代码,我们将用我们自己的实现替换当前的useState
Hook。 - 打开
src/App.js
并通过删除以下行来删除 Hook 的导入:
import React, { useState } from 'react'
将其替换为以下代码行:
import React from 'react'
import ReactDOM from 'react-dom'
我们将需要ReactDOM
以便在我们重新实现useState
Hook 时强制重新渲染组件。如果我们使用实际的 React Hook,这将在内部处理。
- 现在,我们定义自己的
useState
函数。正如我们已经知道的,useState
函数将initialState
作为参数:
function useState (initialState) {
- 然后,我们定义一个值,我们将在其中存储我们的状态。首先,此值将设置为
initialState
,作为参数传递给函数:
let value = initialState
- 接下来,我们定义
setState
函数,我们将value
设置为不同的值,并强制重新渲染我们的MyName
组件:
function setState (nextValue) {
value = nextValue
ReactDOM.render(<MyName />, document.getElementByid('root'))
}
- 最后,我们将
value
和setState
函数作为数组返回:
return [ value, setState ]
}
使用数组而不是对象的原因是我们通常想要重命名 value
和 setState
变量。使用数组可以通过解构轻松重命名变量:
const [ name, setName ] = useState('')
正如我们所看到的,Hook 是处理副作用的简单 JavaScript 函数,例如设置有状态值。
我们的 Hook 函数使用闭包来存储当前值。闭包是变量存在和存储的环境。在我们的例子中,函数提供闭包,value
变量存储在该闭包中。setState
函数也在同一闭包中定义,这就是为什么我们可以访问该函数中的value
变量。在 useState
函数之外,除非我们从函数返回 value
变量,否则我们无法直接访问它。
简单 Hook 实现的问题
如果我们现在运行 Hook 实现,我们会注意到,当我们的组件重新渲染时,状态被重置,所以我们无法在字段中输入任何文本。这是由于每次渲染组件时都会重新初始化 value
变量,这是因为每次渲染组件时都会调用 useState
。
在接下来的部分中,我们将通过使用全局变量来解决此问题,然后将简单值转换为数组,从而允许我们定义多个 Hook。
使用全局变量
正如我们所了解的,该值存储在由 useState
函数定义的闭包中。每次组件重新渲染时,闭包都会重新初始化,这意味着我们的值将被重置。为了解决这个问题,我们需要将值存储在函数之外的全局变量中。这样,value
变量将位于函数外部的闭包中,这意味着当函数再次被调用时,闭包将不会重新初始化。
我们可以定义一个全局变量,如下所示:
- 首先,我们在
useState
函数定义上方添加第1
行:
let value
function useState (initialState) {
- 然后,我们将函数中的第
1
行替换为以下代码:
if (typeof value === 'undefined') value = initialState
现在,我们的 useState
函数使用全局 value
变量,而不是在其闭包中定义 value
变量,因此当函数再次被调用时,它不会被重新初始化。
定义多个 Hook
我们的 Hook 函数工作了!但是,如果我们想添加另一个 Hook,我们会遇到另一个问题:所有 Hook 都写入同一个全局 value
变量!
让我们通过向组件添加第二个 Hook 来仔细研究这个问题。
向我们的组件添加多个 Hook
假设我们要为用户的姓氏创建第二个字段,如下所示:
- 我们首先在函数的开头创建一个新的 Hook(第 2 行),在当前 Hook 之后:
const [ name, setName ] = useState('')
const [ lastName, setLastName ] = usestate('')
- 然后,我们定义另一个
handleChange
函数:
function handleLastNameChange (evt) {
setLastName(evt.target.value)
}
- 接下来,我们将
lastName
变量放在名字之后:
<h1>My name is: {name} {lastName}</h1>
- 最后,我们添加另一个
input
字段:
<input type="text" value={lastName} onChange=
{handleLastNameChange}
/>
当我们尝试这样做时,我们会注意到我们重新实现的 Hook 函数对两种状态使用相同的值,因此我们总是同时更改两个字段。
实现多个 Hook
为了实现多个 Hook,我们应该有一个 Hook 值数组,而不是只有一个全局变量。
我们现在要将 value
变量重构为 values
数组,以便我们可以定义多个 Hook:
- 删除以下代码行:
let value
将其替换为以下代码片段:
let values = []
let currentHook = 0
- 然后,编辑
useState
函数的第1
行,我们现在初始化值数组的currentHook
索引处的值:
if (typeof values[currentHook] === 'undefined')
values[currentHook] = initialState
- 我们还需要更新 setter 函数,以便只更新相应的状态值。在这里,我们需要将
currentHook
值存储在一个单独的hookIndex
变量中,因为currentHook
值稍后会发生变化。这可确保在useState
函数的闭包中创建当前 Hook 变量的副本。否则,useState
函数将从外部闭包访问currentHook
变量,每次调用useState
都会修改该变量:
let hookIndex = currentHook
function setState (nextValue) {
values[hookIndex] = nextValue
ReactDOM.render(<MyName />, document.getElementByid('root'))
}
- 编辑
useState
函数的最后一行,如下所示:
return [ values[currentHook++], setState ]
使用 values[currentHook++]
,我们将 currentHook
的当前值作为索引传递给 values
数组,然后将 currentHook
增加 1。这意味着从函数返回后,currentHook
将增加。
如果我们想先递增一个值,然后使用它,我们可以使用 arr[++indexToBeIncremented]
语句,该语法首先递增,然后将结果传递给数组。
- 当我们开始渲染组件时,我们仍然需要重置
currentHook
计数器。在组件定义之后添加如下第2
行代码:
function Name () {
currentHook = 0
最后,我们对 useState
Hook 的简单重新实现有效!以下截图突出显示了这一点:
我们的自定义 Hook 重新实现工作了
如我们所见,使用全局数组来存储我们的 Hook 值解决了我们在定义多个 Hook 时遇到的问题。
示例代码
简单 Hook 重新实现的示例代码可以在 Chapter02/chapter2_1
文件夹中找到。
只需运行 npm install
即可安装所有依赖项,运行 npm start
启动应用程序,然后在您的浏览器中访问 http://localhost:3000
(如果它没有自动打开)。
我们可以定义条件 Hook 吗?
如果我们想添加一个复选框来切换名字字段的使用,该怎么办?
让我们通过实现这样一个复选框来找出答案:
- 首先,我们新增一个 Hook 来存储复选框的状态:
const [ enableFirstName, setEnableFirstName ] = useState(false)
- 然后,我们定义一个处理函数:
function handleEnableChange (evt) {
setEnableFirstName(!enableFirstName)
}
- 接下来,我们渲染一个复选框:
<input type="checkbox" value={enableFirstName} onChange=
{handleEnableChange} />
- 如果名字被禁用,我们不想显示它。编辑以下现有行,添加对变量 的检查:
<h1>My name is: {enableFirstName ? name : ''} {lastName}</h1>
- 我们可以将 Hook 定义放入
if
条件或三元表达式中,就像我们在下面的代码片段中一样吗?
const [ name, setName ] = enableFirstName
? useState(''): [ '', () => {} ]
- 最新版本的
react-scripts
在定义条件 Hook 时实际上会抛出错误,因此我们需要通过运行以下命令来降级此示例的库:
npm install --save react-scripts@^2.1.8
在这里,我们要么使用 Hook,要么如果名字被禁用,我们返回初始状态和一个空的 setter 函数,这样编辑 input
字段将不起作用。
如果我们现在尝试这段代码,我们会注意到编辑姓氏仍然有效,但编辑名字不起作用,这正是我们想要的。正如我们在下面的屏幕截图中看到的,现在只有编辑姓氏才有效:
选中复选框之前的应用程序状态
当我们单击复选框时,发生了一些奇怪的事情:
- 复选框被选中了
- 名字输入字段已启用
- 姓氏字段的值现在是名字字段的值
我们可以在以下屏幕截图中看到单击复选框的结果:
选中复选框之后的应用程序状态
我们可以看到姓氏状态现在位于名字字段中。这些值已被交换,因为 Hook 的顺序很重要。正如我们从实现中知道的那样,我们使用 currentHook
索引来了解每个 Hook 的状态存储在哪里。但是,当我们在两个现有的 Hook 之间插入一个额外的 Hook 时,顺序就会搞砸。
在选中复选框之前,values
数组如下所示:
[false, '']
- Hook 顺序:
enableFirstName
,lastName
然后,我们在 lastName 字段中输入了一些文本:
[false, 'Hook']
- Hook 顺序:
enableFirstName
,lastName
接下来,我们切换了复选框,激活了我们的新 Hook:
[true, 'Hook', '']
- Hook 顺序:
enableFirstName
,name
,lastName
正如我们所看到的,在两个现有的 Hook 之间插入一个新的 Hook 会使 name
Hook 从下一个 Hook(lastName
)中窃取状态,因为它现在具有与 lastName
Hook 以前具有的相同索引。现在,lastName
Hook 没有值,这会导致它设置初始值(空字符串)。因此,切换该复选框会将 lastName
字段的值放入 name
字段中。
示例代码
我们的简单 Hook 重新实现的条件 Hook 问题的示例代码可以在 Chapter02/chapter2_2
文件夹中找到。
只需运行 npm install
即可安装所有依赖项,运行 npm start
启动应用程序,然后在您的浏览器中访问 http://localhost:3000
(如果它没有自动打开)。
将我们的重新实现与真正的 Hook 进行比较
简单的 Hook 实现已经让我们了解了 Hook 在内部是如何工作的。但是,实际上,Hook 不使用全局变量。相反,它们将状态存储在 React 组件中。它们还在内部处理 Hook 计数器,因此我们不需要在函数组件中手动重置计数。此外,当状态发生变化时,真正的 Hook 会自动触发组件的重新渲染。但是,为了能够做到这一点,需要从 React 函数组件调用 Hook。React Hook 不能在 React 外部或 React 类组件内部调用。
通过重新实现 useState
Hook,我们学到了几件事:
- Hook 只是访问 React 功能的函数
- Hook 处理在重新渲染中持续存在的副作用
- Hook 定义的顺序很重要
最后一点特别重要,因为这意味着我们不能有条件地定义 Hook。我们应该始终将所有 Hook 定义放在函数组件的开头,并且永远不要将它们嵌套在 if 或其他构造中。
在这里,我们还学到了以下内容:
- React Hook 需要在 React 函数组件中调用
- React Hook 不能有条件地定义,也不能在循环中定义
我们现在来看看允许条件 Hook 的替代 Hook APIs。
替代 Hook APIs
有时,有条件地或在循环中定义 Hook 会很好,但为什么 React 团队决定像现在这样实现 Hook呢?有哪些替代方案?让我们来看看其中的一些。
命名 Hook
我们可以给每个 Hook 起一个名字,然后将 Hook 存储在对象而不是数组中。然而,这不会是一个好的 API,我们还必须始终考虑为Hook 提供唯一的名称:
// NOTE: Not the actual React Hook API
const [ name, setName ] = useState('nameHook', '')
此外,当条件设置为 false
或从循环中删除项目时会发生什么?我们会清除 Hook 状态吗?如果我们不清除 Hook 状态,可能会导致内存泄漏。
即使我们解决了所有这些问题,仍然存在名称冲突的问题。例如,如果我们创建一个使用 useState
Hook 的自定义 Hook,并将其命名为 nameHook
,那么我们就不能再在我们的组件中命名任何其他 Hook 为 nameHook
,否则将导致名称冲突。来自库的 Hook 名称也是如此,因此我们需要确保避免与库定义的 Hook 发生名称冲突!
Hook 工厂
或者,我们也可以创建一个 Hook 工厂函数,它使用内部 Symbol
,以便为每个 Hook 提供一个唯一的键名:
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('')
// ...
}
但是,这意味着我们需要实例化每个 Hook 两次:一次在我们的组件外部,一次在函数组件内部。这为错误创造了更多的空间。例如,如果我们创建两个 Hook 并复制并粘贴样板代码,那么我们可能会在工厂函数中产生一个错误的Hook名称,或者可能会在组件中使用 Hook 时出错。
这种方法也使得创建自定义 Hook 变得更加困难,这迫使我们编写包装器函数。此外,调试这些包装的函数比调试一个简单函数更难。
其他替代方案
有许多针对 React Hook 的替代 API 提出,但它们中的每一个都遇到了类似的问题:要么使 API 更难使用,更难调试,要么引入名称冲突的可能性。
最后,React 团队决定最简单的 API 是通过计算 Hook 的调用顺序来跟踪它们。这种方法有其自身的缺点,例如无法有条件地或在循环中调用 Hook。但是,这种方法使我们很容易创建自定义 Hook,并且易于使用和调试。我们也不需要担心命名 Hook、名称冲突或编写包装器函数。Hook 的最终方法让我们像使用任何其他函数一样使用 Hook!
解决 Hook 的常见问题
正如我们所发现的,使用官方 API 实现 Hook 也有其自身的权衡和限制。我们现在将学习如何克服这些常见问题,这些问题源于 React Hook 的局限性。
正如我们所发现的,使用官方APi实现Hook也有其自身的权衡和限制。我们现在将学习如何克服这些常见的问题,这些问题源于React Hook的局限性。
我们将看看可用于克服这两个问题的解决方案:
- 求解条件 Hook
- 解决循环中的 Hook
求解条件 Hook
那么,我们如何实现条件 Hook呢?我们总是可以定义 Hook 并在需要时使用它,而不是让 Hook 具有条件。如果这不是一个选择,我们需要拆分我们的组件,这通常是更好的选择!
始终定义 Hook
对于简单的情况,例如我们之前的名字和姓氏示例,我们始终可以保持定义 Hook,如下所示:
const [ name, setName ] = useState('')
对于简单情况,始终定义 Hook 通常是一个很好的解决方案。
拆分组件
解决条件 Hook 的另一种方法是将一个组件拆分为多个组件,然后有条件地渲染组件。例如,假设我们要在用户登录后从数据库中获取用户信息。
我们不能执行以下操作,因为使用 if
条件可能会更改 Hook 的顺序:
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>
}
无论如何,对非登录和登录状态使用两个单独的组件是有意义的,因为我们希望坚持每个组件具有一个功能的原则。因此,如果我们坚持最佳实践,通常情况下,不能使用条件 Hook 并不是一个很大的限制。
解决循环中的 Hook
至于循环中的 Hook,我们可以使用包含数组的单个 State Hook,也可以拆分组件。例如,假设我们要显示所有在线用户。
使用数组
我们可以简单地使用一个包含所有 users
的数组,如下所示:
function OnlineUsers({ users }) {
const [userInfos, setUserInfos] = useState([])
//
// ... 获取并保持 userInfo 是最新的 ...
return (
<div>
{
users.map(username => {
const user = userInfos.find(u => u.username === username) return <UserInfo {...user} />
})
}
</div>
)
}
但是,这可能并不总是有意义的。例如,我们可能不希望通过 OnlineUsers
组件更新 user
状态,因为我们必须从数组中选择正确的 user
状态,然后修改数组。这可能行得通,但相当乏味。
拆分组件
更好的解决方案是改用 UserInfo
组件中的 Hook。这样,我们可以使每个用户的状态保持最新,而无需处理数组逻辑:
function OnlineUsers({ users }) {
return (
<div>
{users.map(username => <UserInfo username={username} />)}
</div>
)
}
function UserInfo({ username }) {
const info = useFetchUserInfo(username)
// ... 获取并保持 userInfo 是最新的 ...
return <div>{info}</div>
}
正如我们所看到的,为每个功能使用一个组件可以使我们的代码简单简洁,并且还避免了 React Hook 的限制。
使用条件 Hook 解决问题
现在我们已经了解了条件 Hook 的不同替代方案,我们将解决之前在小型示例项目中遇到的问题。解决此问题的最简单方法是始终定义 Hook,而不是有条件地定义它。在这样一个简单的项目中,始终定义 Hook 是最有意义的。
编辑 src/App.js
并删除以下条件 Hook:
const [name, setName] = enableFirstName
? useState('')
: ['', () => ]
将其替换为普通的 Hook,如下所示:
const [ name, setName ] = useState('')
现在,我们的示例工作正常!在更复杂的情况下,始终定义 Hook 可能不可行。在这种情况下,我们需要创建一个新组件,在那里定义 Hook,然后有条件地渲染该组件。
示例代码
条件 Hook 问题的简单解决方案的示例代码可以在 Chapter02/chapter2_3
文件夹中找到。
只需运行 npm install
即可安装所有依赖项,运行 npm start
启动应用程序,然后在您的浏览器中访问 http://localhost:3000
(如果它没有自动打开)。
总结
在本章中,我们首先通过使用全局状态和闭包来重新实现 useState
函数。然后我们了解到,为了实现多个 Hook,我们需要改用状态数组。但是,通过使用状态数组,我们被迫在函数调用中保持 Hook 的顺序一致。此限制使得条件 Hook 和循环中的 Hook 不可能实现。接下来,我们了解了 Hook API 的可能替代方案、它们的权衡以及为什么选择最终的 API。最后,我们学习了如何解决源于 Hook 局限性的常见问题。我们现在对 Hook 的内部工作原理和局限性有了深入的了解。此外,我们还深入了解了State Hook。
在下一章中,我们将使用 State Hook 创建一个博客应用程序,并学习如何组合多个 Hook。
问题
为了回顾我们在本章中学到的内容,请尝试回答以下问题:
- 我们在开发自己的
useState
Hook 重新实现时遇到了什么问题?我们如何解决这些问题? - 为什么条件 Hook 在 React 的 Hook 实现中是不可能的?
- 什么是 Hook,它们处理什么?
- 使用 Hook 时我们需要注意什么?
- Hook 的替代 API 想法的常见问题是什么?
- 我们如何实现条件 Hook?
- 我们如何在循环中实现 Hook?
延伸阅读
如果您有兴趣了解有关我们在本章中学到的概念的更多信息,请查看以下阅读材料:
- 有关替代 Hook API 缺陷的更多信息:https://overreacted.io/why-do-hooks-rely-on-call-order/
- 关于替代 Hook API 的官方评论:https://github.com/reactjs/rfcs/pull/68#issuecomment-439314884
- 关于为什么条件 Hook 不起作用的官方文档:http s://reactjs.org/docs/hooks-rules.html#explanation
评论 (0)