引言
这篇文章介绍了React 开发中常见的 7 种陷阱,包括:使用 0 作为条件判断、返回多个元素、缺少样式括号、直接修改状态、在修改状态后访问它、从不受控制变为受控制状态和异步效果函数。对于每种陷阱,文章都提供了解决方法,并给出了示例代码。
目标受众 这篇文章是为那些对 React 基础知识已经有所了解,但在他们的学习旅程中仍然处于初级阶段的开发者编写的。
使用 0 作为条件判断
我们从其中一个最普遍的陷阱开始。在开发过程中经常会遇到这个问题!
请看下以下代码:
import React from 'react';
import ShoppingList from './ShoppingList';
function App() {
const [items, setItems] = React.useState([]);
return (
<div>
{items.length && <ShoppingList items={items} />}
</div>
);
}
export default App;
import React from 'react';
function ShoppingList({ items }) {
return (
<>
<h1>Shopping List</h1>
<ul>
{items.map((item, index) => {
// NOTE: We shouldn't use “index” as the key!
// This is covered later in this post 😄
return (
<li key={index}>
{item}
</li>
);
})}
</ul>
</>
);
}
export default ShoppingList
我们的目标是有条件地显示购物清单。如果我们在数组中至少有 1 个项目,我们应该渲染一个 ShoppingList
组件。否则,我们不应该渲染任何东西。
然而,我们最终在 UI 中得到一个随机的 0 !
发生这种情况是因为 items.length
的计算结果为 0 。由于 0 在 JavaScript 中是一个虚假值,因此 && 运算符短路,整个表达式解析为 0 。实际上,就好像我们这样做了一样:
function App() {
return (
<div>
{0}
</div>
);
}
与其他虚假值( '' 、 null 、 false
等)不同,数字 0 是 JSX 中的有效值。毕竟,在很多情况下,我们确实想打印数字 0 !
如何修复: 我们的表达式应该使用一个“纯粹”的布尔值(true/false
):
function App() {
const [items, setItems] = React.useState([]);
return (
<div>
{items.length > 0 && (
<ShoppingList items={items} />
)}
</div>
);
}
items.length > 0
将始终计算为 true
或 false
,因此我们永远不会有任何问题。
或者,我们可以使用三元表达式:
function App() {
const [items, setItems] = React.useState([]);
return (
<div>
{items.length
? <ShoppingList items={items} />
: null}
</div>
);
}
这两种方法都是完全有效的,取决于个人喜好。
返回多个元素
有时,一个组件需要返回多个顶级元素。例如:
function LabeledInput({ id, label, ...delegated }) {
return (
<label htmlFor={id}>
{label}
</label>
<input
id={id}
{...delegated}
/>
);
}
export default LabeledInput;
我们希望我们的 LabeledInput
组件返回两个元素: <label>
和 <input>
。令人沮丧的是,我们收到一个错误:
Adjacent JSX elements must be wrapped in an enclosing tag.
相邻的 JSX 元素必须包装在封闭的标记中。
这是因为 JSX 会编译为普通的 JavaScript。下面是在浏览器中运行时这段代码的样子:
function LabeledInput({ id, label, ...delegated }) {
return (
React.createElement('label', { htmlFor: id }, label)
React.createElement('input', { id: id, ...delegated })
);
}
在 JavaScript 中,我们不能像这样返回多个值。这也是为什么这种写法不起作用的原因:
function addNumbers(a, b) {
return (
"the answer is"
a + b
);
}
我们该如何修复呢?很长一段时间以来,标准做法是将这两个元素包装在一个包裹标签中,比如 <div>
:
function LabeledInput({ id, label, ...delegated }) {
return (
<div>
<label htmlFor={id}>
{label}
</label>
<input
id={id}
{...delegated}
/>
</div>
);
}
通过将 <label>
和 <input>
包装在 <div>
中,我们只返回一个顶层元素!以下是它编译为JavaScript 后的样子:
function LabeledInput({ id, label, ...delegated }) {
return React.createElement(
'div',
{},
React.createElement('label', { htmlFor: id }, label),
React.createElement('input', { id: id, ...delegated })
);
}
JSX 是一个很棒的抽象,但它常常会掩盖关于 JavaScript 的基本真理。我认为,查看 JSX 如何转换为普通的 JavaScript,对了解实际情况往往很有帮助。
通过这种新的方法,我们返回一个单独的元素,而该元素包含两个子元素。问题解决了!
但我们可以使用 Fragment
组件进一步改进这个解决方案:
function LabeledInput({ id, label, ...delegated }) {
return (
<React.Fragment>
<label htmlFor={id}>
{label}
</label>
<input
id={id}
{...delegated}
/>
</React.Fragment>
);
}
React.Fragment
是一个专门用来解决这个问题的 React 组件。它允许我们将多个顶级元素组合在一起,而不会影响 DOM(即不会影响布局和样式)。
这非常棒:意味着我们不用在标记中加入不必要的<div>
。
它还有一个便捷的简写方式,<Fragment></Fragment>
可以简写为空的 JSX 元素<>\</>
,我们可以像这样简写:
function LabeledInput({ id, label, ...delegated }) {
return (
<>
<label htmlFor={id}>
{label}
</label>
<input
id={id}
{...delegated}
/>
</>
);
}
缺少样式括号
JSX 被设计得看起来很像 HTML,但它们之间有一些令人惊讶的差异,往往会让人措手不及。
大多数差异都有很好的文档记录,而且控制台的警告通常非常具体和有帮助。例如,如果你意外使用 class
而不是className
,React 会准确告诉你问题所在。
但有一个微妙的差异经常让人困惑:style
属性。
在 HTML 中,style
是以字符串的形式表示的:
<button style="color: red; font-size: 1.25rem">
Hello World
</button>
但是,在 JSX 中,我们需要将其指定为一个对象,并带有驼峰属性名称。
在下面的代码中,我试图做到这一点,但出现了错误。你能找出错误吗?
import React from 'react';
function App() {
return (
<button
style={ color: 'red', fontSize: '1.25rem' }
>
Hello World
</button>
);
}
export default App;
问题是需要使用双大括号,如下所示:
<button
// "{{", instead of "{":
style={{ color: 'red', fontSize: '1.25rem' }}
>
Hello World
</button>
要理解为什么是这样写,我们需要深入研究一下这个语法。
在 JSX 中,我们使用大括号来创建一个表达式插槽。我们可以在这个插槽中放置任何有效的 JavaScript 表达式。例如:
<button className={isPrimary ? 'btn primary' : 'btn'}>
无论我们在{}
中放置什么,都将被视为 JavaScript 进行求值,并将结果设置为该属性的值。className
将是'btn primary'
或'btn'
。
对于style
,我们首先需要创建一个表达式插槽,然后将一个 JavaScript 对象传递到这个插槽中。
我认为如果我们将对象提取到一个变量中,会更清晰明了:
const btnStyles = { color: 'red', fontSize: '1.25rem' };
// 写法1
<button style={btnStyles}>
Hello World
</button>
// 写法2
<button style={{ color: 'red', fontSize: '1.25rem' }}>
外层的大括号创建了 JSX 中的"表达式插槽"。内层的大括号创建了一个 JavaScript 对象,用于保存我们的样式。
直接修改状态
请看以下示例,假设我们能够添加新项目:
import React from 'react';
import ShoppingList from './ShoppingList';
import NewItemForm from './NewItemForm';
function App() {
const [items, setItems] = React.useState([
'apple',
'banana',
]);
function handleAddItem(value) {
items.push(value);
setItems(items);
}
return (
<div>
{items.length > 0 && <ShoppingList items={items} />}
<NewItemForm handleAddItem={handleAddItem} />
</div>
)
}
export default App;
每当用户提交一个新项目时,handleAddItem
函数会被调用。不幸的是,它目前无法正常工作!当我们输入一个项目并提交表单时,该项目并没有被添加到购物清单ShoppingList
组件中。
问题出在我们违反了React中最重要的规则之一:我们修改了状态的值。
具体来说,问题是这一行:
function handleAddItem(value) {
items.push(value); // ❌
setItems(items);
}
React 依靠状态变量的身份(也就是这里的 useState hooks)来判断状态何时发生了变化。当我们把一个项目推到数组中时,我们不会改变该数组的身份,所以 React 无法判断这个值已经改变。
如何修复: 我们需要创建一个全新的数组。以下是我的做法:
function handleAddItem(value) {
const nextItems = [...items, value]; // ✅ 遵守 React 的不可变性 原则
setItems(nextItems);
}
我没有修改现有的数组,而是选择从头开始创建一个新的数组。这个新数组包含了与原数组完全相同的所有项(借助展开语法...
),以及新添加的项(value
)。
这里的区别在于修改现有项与创建新项之间的差异。当我们将一个值传递给像 setItems
这样的状态设置函数时,它需要是一个新的实体。
对于对象也是如此:
function handleChangeEmail(nextEmail) {
user.email = nextEmail; // ❌
setUser(user);
}
function handleChangeEmail(email) {
const nextUser = { ...user, email: nextEmail }; // ✅
setUser(nextUser);
}
在修改状态后访问它
下面是一个最简单的计数器应用程序:点击按钮会增加计数。看看你能否发现问题所在:
import React from 'react';
function App() {
const [count, setCount] = React.useState(0);
function handleClick() {
setCount(count + 1);
console.log({ count });
}
return (
<button onClick={handleClick}>
{count}
</button>
);
}
export default App;
在增加计数状态变量后,我们将其值记录到控制台。令人奇怪的是,它记录了错误的值:
问题出在这里:React 中的状态设置函数(例如setCount
)是异步的。
这是有问题的代码:
function handleClick() {
setCount(count + 1);
console.log({ count });
}
很容易错误地认为setCount
函数类似于赋值,好像这样做是等同于这样的操作:
count = count + 1;
console.log({ count });
然而,React 并不是这样构建的。当我们调用setCount
函数时,我们并没有重新赋值给一个变量,而是安排了一个更新操作。
对于我们完全理解这个概念可能需要一些时间,但下面的解释或许有助于更好地理解:我们无法重新赋值给 count 变量,因为它是一个常量!
const [count, setCount] = React.useState(0);
count = count + 1; // ❌
如何修复它: 我们需要将它存储在一个变量中,以便我们可以访问它:
function handleClick() {
const nextCount = count + 1; // 再次验证不可变性的重要性,使用新的变量记录最新的状态
setCount(nextCount);
console.log({ nextCount });
}
从不受控制变为受控制状态
让我们来看一个典型的表单示例,将一个输入与 React 状态绑定起来:
import React from 'react';
function App() {
const [email, setEmail] = React.useState();
return (
<form>
<label htmlFor="email-input">
Email address
</label>
<input
id="email-input"
type="email"
value={email}
onChange={event => setEmail(event.target.value)}
/>
</form>
);
}
export default App;
如果你在这个输入框中开始输入,你会注意到控制台上会出现一个警告:
Warning: A component is changing an uncontrolled input to be controlled.
警告:组件正在更改要控制的不受控制的输入。
解决方法如下: 我们需要将 email
状态初始化为空字符串:
const [email, setEmail] = React.useState('');
当我们设置value
属性时,我们告诉 React 我们希望这是一个受控输入框。但是,这只有在我们传递一个定义的值时才起作用!
通过将email
初始化为空字符串,我们确保 value 永远不会被设置为 undefined。
异步效果函数(useEffect)
假设我们有一个函数,在挂载时从 API 中获取一些用户数据。我们将使用useEffect
钩子,并希望使用
await
关键字。以下是我第一次尝试的代码:
import React from 'react';
const API = 'https://test-api/api';
function UserProfile({ userId }) {
const [user, setUser] = React.useState(null);
React.useEffect(() => {
const url = `${API}/get-profile?id=${userId}`;
const res = await fetch(url);
const json = await res.json();
setUser(json.user);
}, [userId]);
if (!user) {
return 'Loading…';
}
return (
<section>
<dl>
<dt>Name</dt>
<dd>{user.name}</dd>
<dt>Email</dt>
<dd>{user.email}</dd>
</dl>
</section>
);
}
export default UserProfile;
不幸的是,我们收到一个错误信息:
'await' is only allowed within async functions
“await”仅在异步函数中允许
好吧,没问题。让我们将useEffect
更新为异步函数,方法是在它前面加上async
关键字:
React.useEffect(async () => {
const url = `${API}/get-profile?id=${userId}`;
const res = await fetch(url);
const json = await res.json();
setUser(json.user);
}, [userId]);
不幸的是,这也行不通。我们收到一个错误消息:
Effect callbacks are synchronous to prevent race conditions. Put the async function inside.
Effect回调是同步的,以防止出现竞赛条件。请把异步函数放在里面。
这是修正的方法: 我们需要在useEffect
中创建一个单独的异步函数:
React.useEffect(() => {
// Create an async function...
async function runEffect() {
const url = `${API}/get-profile?id=${userId}`;
const res = await fetch(url);
const json = await res.json();
setUser(json);
}
// ...and then invoke it:
runEffect();
}, [userId]);
为了理解为什么需要这个变通方法,我们要思考一下async
关键字的实际作用。
例如,你会猜测下面这个函数返回什么?
async function greeting() {
return "Hello world!";
}
乍一看,似乎很明显:它返回字符串 "Hello world!"
!但实际上,这个函数返回一个 Promise。这个 Promise 解析为字符串 "Hello world!"
。如图:
这是问题所在,因为useEffect
钩子并不期望我们返回一个 Promise!它期望我们返回无值(就像我们上面的示例中所做的);或者返回一个清除副作用函数,可以在「依赖项更改或组件卸载时」调用它。
通过使用"单独的异步函数"策略,我们仍然可以立即返回一个清除函数:
React.useEffect(() => {
async function runEffect() {
// Effect logic here
}
runEffect();
return () => {
// Cleanup logic here
}
}, [userId]);