React 初学者常见错误

27 阅读10分钟

引言

这篇文章介绍了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 将始终计算为 truefalse ,因此我们永远不会有任何问题。

或者,我们可以使用三元表达式:

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;

在增加计数状态变量后,我们将其值记录到控制台。令人奇怪的是,它记录了错误的值: image.png

问题出在这里: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!"。如图: image.png

这是问题所在,因为useEffect钩子并不期望我们返回一个 Promise!它期望我们返回无值(就像我们上面的示例中所做的);或者返回一个清除副作用函数,可以在「依赖项更改或组件卸载时」调用它。

通过使用"单独的异步函数"策略,我们仍然可以立即返回一个清除函数:

React.useEffect(() => {
  async function runEffect() {
    // Effect logic here
  }
  runEffect();
  return () => {
    // Cleanup logic here
  }
}, [userId]);