使用 useState 需要注意的 5 个问题
开发任何应用程序最具挑战性的方面通常是管理其状态。然而,我们经常需要在应用程序中管理多个状态片段,例如当从外部服务器检索数据或在应用程序中更新数据时。
状态管理的困难是今天存在如此多状态管理库的原因,而且更多的库仍在开发中。值得庆幸的是,React 以 hook 的形式提供了几个用于状态管理的内置解决方案,这使得 React 中的状态管理更加容易。
众所周知,hook 在 React 组件开发中变得越来越重要,特别是在功能组件中,因为它们已经完全取代了对基于类的组件的需求,而基于类的组件是管理有状态组件的传统方式。useState
hook 是 React 中引入的众多 hook 之一,但是尽管 useState
hook 已经出现几年了,开发人员仍然容易因为理解不足而犯常见的错误。
useState
hook 可能很难理解,特别是对于新手 React 开发人员或从基于类的组件迁移到函数组件的开发人员。在本文中,我们将探讨使用 useState
需要注意的 5 个问题,以及如何避免它们。
1. 初始化 useState 错误
错误地初始化 useState
hook 是开发人员在使用它时最常犯的错误之一。问题是 useState
允许你使用任何你想要的东西来定义它的初始状态。然而,没有人直接告诉你的是,根据组件在该状态下的期望,使用错误的类型值初始化 useState
可能会导致应用程序中意外的行为,例如无法呈现 UI,导致黑屏错误。
例如,我们有一个组件,它期望一个包含用户名称、图像和个人简历的用户对象状态——在这个组件中,我们呈现用户的属性。使用不同的数据类型(如空状态或空值)初始化 useState
将导致空白页错误,如下所示。
import { useState } from "react"; function App() { // 初始化状态 const [user, setUser] = useState(); // 渲染 UI return ( <div className='App'> <img src={user.image} alt='profile image' /> <p>User: {user.name}</p> <p>About: {user.bio}</p> </div> ); } export default App;
我们可以看到页面一篇空白,检查控制台将抛出如下所示的类似错误:
新手的开发人员在初始化他们的状态时经常犯这个错误,特别是在从服务器或数据库获取数据时,因为检索到的数据期望用实际的用户对象更新状态。然而,这是一种不好的做法,可能会导致预期的行为,如上所示。
初始化 useState
的首选方法是将预期的数据类型传递给它,以避免潜在的空白页错误。例如,一个空对象,如下所示,可以传递给状态:
import { useState } from "react"; function App() { // 使用期望的数据类型初始化状态 const [user, setUser] = useState({}); // 渲染 UI return ( <div className='App'> <img src={user.image} alt='profile image' /> <p>User: {user.name}</p> <p>About: {user.bio}</p> </div> ); } export default App;
这个时候我们可以看到页面如下所示:
我们可以更进一步,在初始化状态时定义用户对象的预期属性。
import { useState } from "react"; function App() { // 使用期望的数据类型初始化状态 const [user, setUser] = useState({ image: "", name: "", bio: "", }); // 渲染 UI return ( <div className='App'> <img src={user.image} alt='profile image' /> <p>User: {user.name}</p> <p>About: {user.bio}</p> </div> ); } export default App;
2. 没有使用可选链
有时,仅仅使用预期的数据类型初始化 useState
往往不足以防止意外的空白页错误。当试图访问深嵌套在相关对象链中的深嵌套对象的属性时,尤其如此。
你通常尝试通过使用点(.
)操作符通过相关对象来访问该对象,例如 user.names.firstName
。但是,如果丢失了任何链接的对象或属性,就会出现问题。页面将中断,用户将得到一个空白页错误。
例如:
import { useState } from "react"; function App() { // 使用期望的数据类型初始化状态 const [user, setUser] = useState({}); // 渲染 UI return ( <div className='App'> <img src={user.image} alt='profile image' /> <p>User: {user.names.firstName}</p> <p>About: {user.bio}</p> </div> ); } export default App;
我们可以看到页面一篇空白,检查控制台将抛出如下所示的类似错误:
对于这个错误和 UI 未呈现的典型解决方案是使用条件检查来验证状态的存在性,在呈现组件之前检查它是否可访问,例如 user.names && user.names.firstname
,它只在左侧表达式为真(如果 user.names
存在)时计算右侧表达式。然而,这个解决方案很混乱,因为它需要对每个对象链进行多次检查。
使用可选的链接操作符(?.
),你可以读取深埋在相关对象链中的属性值,而不需要验证每个引用的对象是否有效。可选的链接操作符(?.
)就像点链接操作符(.
),不同的是,如果引用的对象或属性缺失(即 null
或 undefined
),表达式短路并返回 undefined
值。简单地说,如果丢失了任何链接对象,它就不会继续进行链接操作(短路)。
例如,user?.names?.firstname
不会抛出任何错误或中断页面,因为一旦它检测到 user
或 names
对象丢失,它将立即终止操作。
import { useState } from "react"; function App() { // 使用期望的数据类型初始化状态 const [user, setUser] = useState({}); // 渲染 UI return ( <div className='App'> <img src={user.image} alt='profile image' /> <p>User: {user?.names?.firstName}</p> <p>About: {user.bio}</p> </div> ); } export default App;
在访问状态中的链接属性时,利用可选的链接操作符可以简化和缩短表达式,这在探索对象的内容时非常有用,对象的引用可能事先不知道。
3. 直接更新 useState
缺乏对 React 如何调度和更新状态的正确理解,很容易导致在更新应用程序状态时出现错误。在使用 useState
时,我们通常定义一个状态并使用 set state
函数直接更新状态。
例如,我们创建了一个计数状态和一个附加到按钮的 handler
函数,该函数在单击时为状态添加 1(+1):
import { useState } from "react"; function App() { const [count, setCount] = useState(0); // 直接更新状态 const increase = () => setCount(count + 1); // 渲染 UI return ( <div className='App'> <span>Count: {count}</span> <button onClick={increase}>Add +1</button> </div> ); } export default App;
在这里插入图片描述
这和预期的一样。但是,直接更新状态是一种不好的做法,在处理多个用户使用的实时应用程序时可能会导致潜在的错误。为什么?因为与你所想的相反,React 不会在单击按钮时立即更新状态。相反,React 获取当前状态的快照,并将更新(+1)安排在稍后执行,以获得性能提升——这发生在几毫秒内,因此肉眼不会注意到。然而,虽然预定的更新仍然处于暂挂的转换中,但当前状态可能会被其他内容更改(例如多个用户的情况)。预定的更新将无法知道这个新事件,因为它只有单击按钮时所获得的状态快照的记录。
这可能会导致应用程序出现严重的错误和奇怪的行为。让我们通过添加另一个按钮来查看实际操作,该按钮在延迟 2 秒后异步更新计数状态。
import { useState } from "react"; function App() { const [count, setCount] = useState(0); // 直接更新状态 const update = () => setCount(count + 1); // 在 2 秒后直接更新状态 const asyncUpdate = () => { setTimeout(() => { setCount(count + 1); }, 2000); }; // 渲染 UI return ( <div className='App'> <span>Count: {count}</span> <button onClick={update}>Add +1</button> <button onClick={asyncUpdate}>Add +1 later</button> </div> ); }
请注意输出中的错误:
注意到这个错误吗?我们首先两次点击第一个“Add +1”按钮(这将更新状态为1 +1 = 2
),之后,我们点击“Add +1 later” —— 这将获取当前状态(2)的快照,并在两秒后调度更新,向该状态添加 1。但是当这个计划更新还处于过渡阶段时,我们继续点击“Add +1”按钮三次,将当前状态更新为5(即2 +1 +1 +1 = 5
)。然而,异步定时更新尝试在两秒钟后使用它在内存中的快照(2)更新状态)即 2 + 1 = 3
),而没有意识到当前状态已更新为 5。结果,状态被更新为 3 而不是 6。
这个无意的错误经常困扰那些仅使用 setState(newValue)
函数直接更新状态的应用程序。更新 useState
的建议方法是通过函数更新来传递给 setState()
一个回调函数,在这个回调函数中我们传递该实例的当前状态,例如 setState(currentState => currentState + newValue)
。这将在预定的更新时间将当前状态传递给回调函数,从而可以在尝试更新之前知道当前状态。
因此,让我们修改示例演示,使用函数更新而不是直接更新。
import { useState } from "react"; function App() { const [count, setCount] = useState(0); // 直接更新状态 const update = () => setCount(count + 1); // 在 2 秒后直接更新状态 const asyncUpdate = () => { setTimeout(() => { setCount((currentCount) => currentCount + 1); }, 2000); }; // 渲染 UI return ( <div className='App'> <span>Count: {count}</span> <button onClick={update}>Add +1</button> <button onClick={asyncUpdate}>Add +1 later</button> </div> ); } export default App;
通过函数式更新,setState()
函数知道状态已更新为 5,因此它将状态更新为 6。
4. 更新特定对象属性
另一个常见错误是只修改对象或数组的属性而不修改引用本身。
例如,我们用定义好的 name
和 age
属性初始化一个用户对象。然而,我们的组件有一个按钮,它试图只更新用户名,如下所示。
import { useState, useEffect } from "react"; export default function App() { const [user, setUser] = useState({ name: "John", age: 25, }); // 更新用户状态属性 const changeName = () => { setUser((user) => (user.name = "Mark")); }; // 渲染 UI return ( <div className='App'> <p>User: {user.name}</p> <p>Age: {user.age}</p> <button onClick={changeName}>Change name</button> </div> ); }
点击按钮前的初始状态:
点击按钮后的更新状态:
正如你所看到的,用户不再是一个对象,而是被改写为字符串 "Mark"
,而不是特定的属性被修改。为什么?因为 setState()
将返回或传递给它的任何值赋值为新状态。
一种典型的老式方法是创建一个新的对象引用,并将前一个用户对象分配给它,直接修改用户名。
import { useState, useEffect } from "react"; export default function App() { const [user, setUser] = useState({ name: "John", age: 25, }); // 更新用户状态属性 const changeName = () => { setUser((user) => Object.assign({}, user, { name: "Mark" })); }; // 渲染 UI return ( <div className='App'> <p>User: {user.name}</p> <p>Age: {user.age}</p> <button onClick={changeName}>Change name</button> </div> ); }
点击按钮后的更新状态:
注意,只有用户名被修改了,而其他属性保持不变。
然而,更新特定属性、对象或数组的理想而现代的方法是使用 ES6 扩展操作符(...
)。在处理功能组件中的状态时,这是更新对象或数组的特定属性的理想方法。使用这个扩展操作符,你可以轻松地将现有项的属性解包到新项中,同时修改或向解包项添加新属性。
import { useState, useEffect } from "react"; export default function App() { const [user, setUser] = useState({ name: "John", age: 25, }); // 使用扩展操作符更新用户状态的属性 const changeName = () => { setUser((user) => ({ ...user, name: "Mark" })); }; // 渲染 UI return ( <div className='App'> <p>User: {user.name}</p> <p>Age: {user.age}</p> <button onClick={changeName}>Change name</button> </div> ); }
结果将与上一个状态相同。单击按钮后,name
属性将被更新,而其他用户属性保持不变。
5. 管理表单中的多个输入字段
管理表单中的几个受控输入通常是通过为每个输入字段手动创建多个 useState()
函数并将每个函数绑定到相应的输入字段来完成的。例如:
import { useState, useEffect } from "react"; export default function App() { const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const [age, setAge] = useState(""); const [userName, setUserName] = useState(""); const [password, setPassword] = useState(""); const [email, setEmail] = useState(""); // 渲染 UI return ( <div className='App'> <form> <input type='text' placeholder='First Name' /> <input type='text' placeholder='Last Name' /> <input type='number' placeholder='Age' /> <input type='text' placeholder='Username' /> <input type='password' placeholder='Password' /> <input type='email' placeholder='email' /> </form> </div> ); }
此外,必须为每个输入创建处理程序函数,以建立双向数据流,在输入值输入时更新每个状态。这可能是相当多余和耗时的,因为它涉及编写大量代码,降低了代码库的可读性。
但是,只使用一个 useState
hook 就可以管理表单中的多个输入字段。这可以通过以下方法实现:首先为每个输入字段指定一个惟一的名称,然后创建一个 useState()
函数,该函数使用与输入字段名称相同的属性进行初始化:
import { useState, useEffect } from "react"; export default function App() { const [user, setUser] = useState({ firstName: "", lastName: "", age: "", username: "", password: "", email: "", }); // 渲染 UI return ( <div className='App'> <form> <input type='text' name='firstName' placeholder='First Name' /> <input type='text' name='lastName' placeholder='Last Name' /> <input type='number' name='age' placeholder='Age' /> <input type='text' name='username' placeholder='Username' /> <input type='password' name='password' placeholder='Password' /> <input type='email' name='email' placeholder='email' /> </form> </div> ); }
在此之后,我们创建一个处理程序事件函数,该函数更新用户对象的特定属性,以反映每当用户输入内容时表单中的更改。这可以通过使用拓展操作符和使用 event.target.elementsName = event.target.value
动态访问触发处理程序函数的特定输入元素的名称来完成。
换句话说,我们通常检查传递给事件函数的事件对象,获取目标元素名称(与用户状态下的属性名称相同),并用目标元素中的关联值更新它,如下所示:
import { useState, useEffect } from "react"; export default function App() { const [user, setUser] = useState({ firstName: "", lastName: "", age: "", username: "", password: "", email: "", }); // 更新特定的输入字段 const handleChange = (e) => setUser(prevState => ({...prevState, [e.target.name]: e.target.value})) // 渲染 UI return ( <div className='App'> <form> <input type='text' onChange={handleChange} name='firstName' placeholder='First Name' /> <input type='text' onChange={handleChange} name='lastName' placeholder='Last Name' /> <input type='number' onChange={handleChange} name='age' placeholder='Age' /> <input type='text' onChange={handleChange} name='username' placeholder='Username' /> <input type='password' onChange={handleChange} name='password' placeholder='Password' /> <input type='email' onChange={handleChange} name='email' placeholder='email' /> </form> </div> ); }
在此实现中,对于每个用户输入都触发事件处理程序函数。在这个事件函数中,我们有一个 setUser()
状态函数,它接受用户的以前/当前状态,并使用拓展操作符解包这个用户状态。然后检查事件对象中触发函数的目标元素名(与状态中的属性名相关)。获得此属性名后,我们修改它以反映表单中的用户输入值。
6. 小结
作为一个创建高度交互用户界面的 React 开发人员,你可能犯过上面提到的一些错误。希望这些有用的 useState
实践能够帮助你在构建 React 驱动的应用程序时使用 useState
hook 避免这些潜在的错误。