Intro

最近看了一篇文章:Common Beginner Mistakes with React,讲的是 React 新手可能会犯的错误,文章写得很好,确实有很多我学习 React 初期犯过的错误,并且文中给了非常详细的代码和样例,因此将其翻译为中文,也作为自己的笔记。推荐看得懂英文的直接查看原文。

本文是对原文的翻译,稍微简略了原文中的一些描述,对于部分内容添加了补充信息,版权归原作者 Josh W Comeau 所有。

Evaluating with zero

首先看看下面这段代码,你觉得页面上会显示什么呢?

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;

答案是:会显示一个 0

原因是 items.length 的计算结果为 0,而 0 是一个 False 的值,&& 的运算被短路,因此整个表达式就会解析为 0,因此上面那段代码本质上就等于:

function App() {
  return (
    <div>
      {0}
    </div>
  );
}

因此,在表达式里面应该使用纯正的布尔值:

function App() {
  const [items, setItems] = React.useState([]);

  return (
    <div>
      {items.length > 0 && (
        <ShoppingList items={items} />
      )}
    </div>
  );
}

或者使用三元运算符:

function App() {
  const [items, setItems] = React.useState([]);

  return (
    <div>
      {items.length
        ? <ShoppingList items={items} />
        : null}
    </div>
  );
}

Mutating state

假设现在要向购物车中添加新项目:

每当用户提交新项目的时候,都会调用 handleAddItem 函数:

function handleAddItem(value) {
  items.push(value);
  setItems(items);
}

但是这个函数不起作用,新的项目不会被添加到购物清单中,因为这违反了 React 中最神圣的规则:我们正在改变 state。

重点就是 items.push(value) 这一行,React 依赖于状态变量的 identity 来判断状态是否发生了变化。当我们向数组中 push 新的元素时,并没有改变该数组的 identity,因此 React 无法知道该值已经发生了变化。(译者注:可以理解为,当你向数组中添加了一个新元素之后,数组还是原来的那个数组,并没有生成一个新的数组,因此 React 无法判断该值已经发生了更改)

因此,如果要修复这个问题,我们需要创建一个全新的数组,就像这样:

function handleAddItem(value) {
  const nextItems = [...items, value];
  setItems(nextItems);
}

不修改现有数组,而是重新创建了一个新数组,它包含所有原数组的元素和新的元素(使用 ... 展开语法实现)。

这里的区别是编辑现有项与创建新项之间的区别。当我们将一个值传递给像 setCount 这样的状态设置函数时,它必须是一个新实体。

除了数组,对象也遵循同样的规律:

// ❌ 修改一个现有的对象
function handleChangeEmail(nextEmail) {
  user.email = nextEmail;
  setUser(user);
}

// ✅ 创建一个新对象
function handleChangeEmail(email) {
  const nextUser = { ...user, email: nextEmail };
  setUser(nextUser);
}

Not generating keys

你以前可能见过这样的警告:

Warning: Each child in a list should have a unique "key" prop.

例如这就是一个例子:

import React from 'react';

function ShoppingList({ items }) {
  return (
    <ul>
      {items.map((item) => {
        return (
          <li>{item}</li>
        );
      })}
    </ul>
  );
}

export default ShoppingList;

这将导致以下错误:

Warning: Each child in a list should have a unique "key" prop.
Check the render method of `ShoppingList`. See https://reactjs.org/link/warning-keys for more information.
    at li
    at ShoppingList (https://sandpack-bundler.vercel.app/ShoppingList.js:17:3)
    at div
    at App (https://sandpack-bundler.vercel.app/App.js:26:42)

当我们渲染一个元素数组时,我们需要为 React 提供一些额外的上下文,以便它可以识别每个独立的元素。重要的是,这需要是一个唯一的标识符。

很多网上的文章都会建议使用数组索引来解决这个问题:

function ShoppingList({ items }) {
  return (
    <ul>
      {items.map((item, index) => {
        return (
          <li key={index}>{item}</li>
        );
      })}
    </ul>
  );
}

我不认为这是个好建议。这种方法有时会奏效,但在其他情况下可能会导致一些相当大的问题。

(译者注:作者这里没有详细说明会导致什么问题,其实在 React 官方的 docs 中有提到过这个问题:

We don’t recommend using indexes for keys if the order of items may change. This can negatively impact performance and may cause issues with component state. Check out Robin Pokorny’s article for an in-depth explanation on the negative impacts of using an index as a key. If you choose not to assign an explicit key to list items then React will default to using indexes as keys.

其中 Robin Pokorny 的那篇文章就有一个导致问题的例子)

因此,作者推荐在新的元素被添加到列表时,为其生成一个唯一的 ID:

function handleAddItem(value) {
  const nextItem = {
    id: crypto.randomUUID(),
    label: value,
  };

  const nextItems = [...items, nextItem];
  setItems(nextItems);
}

crypto.randomUUID 是浏览器内置的方法,在主流浏览器中都可以使用,这个方法会生成一个类似于 d9bb3c4c-0459-48b9-a94c-7ca3963f7bd0 的唯一字符串。

但是,千万不要在更新状态时生成 ID,例如:

<li key={crypto.randomUUID()}>
  {item.label}
</li>

这会导致每次渲染时,Key 都发生变化,Key 的变化会导致 React 销毁并重新创建这些元素,会对性能产生很大的负面影响。

例如从服务器获取数据时,可以通过以下方式为数据创建唯一的 ID:

const [data, setData] = React.useState(null);

async function retrieveData() {
  const res = await fetch('/api/data');
  const json = await res.json();

  // 这时候我们已经拿到了数据,接下来为每个元素生成一个 ID:
  const dataWithId = json.data.map(item => {
    return {
      ...item,
      id: crypto.randomUUID(),
    };
  });

  // 然后我们将 state 更新为带有 ID 的数据
  setData(dataWithId);
}

Missing whitespace

有以下代码:

import React from 'react';

function App() {
  return (
    <p>
      Welcome to Corpitech.com!
      <a href="/login">Log in to continue</a>
    </p>
  );
}

export default App;

代码的运行结果:

这两个句子显示在了同一行,这是因为 JSX 编译器无法真正区分是语法上的空格还是我们为了缩进或代码可读性而添加的空格。

因此,我们需要在文本和 a 标签之间添加一个明确的空格符号:

<p>
  Welcome to Corpitech.com!
  {' '}
  <a href="/login">Log in to continue</a>
</p>

一个小提示:如果你使用 Prettier,它会自动为你添加这些空格字符!只需确保让它进行格式化(不要预先将内容拆分为多行)。

Accessing state after changing it

有一个最小的计数器应用:

在点击按钮之后,我们将递增的 count 变量输出到 console,但奇怪的是,记录的值是错误的。 问题在于:React 中的状态设置函数(例如 setCount)是异步的。

这是有问题的代码:

function handleClick() {
  setCount(count + 1);
  console.log({ count });
}

很容易错误地认为 setCount 的功能类似于赋值,就好像它等同于:

count = count + 1;
console.log({ count });

但在 React 中,当调用 setCount 时,我们并没有重新分配变量,只是安排了更新。

我们可能需要一些时间才能完全理解这个想法,但是这里有一些能帮助你理解的东西:我们无法重新分配 count 变量,因为它是一个常量!

// 使用的是 `const` 而不是 `let`,因此不能重新分配
const [count, setCount] = React.useState(0);

count = count + 1; // Uncaught TypeError:
                   // Assignment to constant variable

那么如何解决这个问题?因为我们已经知道了这个值应该是多少,我们可以将其赋值给一个变量,并访问这个新变量:

function handleClick() {
  const nextCount = count + 1;
  setCount(nextCount);

  // 想引用新的值时,可以使用 `nextCount`
  console.log({ nextCount });
}

我喜欢使用 “next” 前缀来声明这类变量,例如 nextCountnextItemsnextEmail 等,这让我更清楚我们不是在更新现有的值,而是在安排下一个值。

Returning multiple elements

有时,一个组件需要返回多个顶级元素。

例如:

function LabeledInput({ id, label, ...delegated }) {
  return (
    <label htmlFor={id}>
      {label}
    </label>
    <input
      id={id}
      {...delegated}
    />
  );
}

export default LabeledInput;

上面代码将得到以下报错:

/LabeledInput.js: Adjacent JSX elements must be wrapped in an enclosing tag. Did you want a JSX fragment <>...</>? (6:4)
  4 |       {label}
  5 |     </label>
> 6 |     <input
    |     ^
  7 |       id={id}
  8 |       {...delegated}
  9 |     />

这是因为 JSX 编译为普通的 JavaScript,上面的代码将会被编译为:

function LabeledInput({ id, label, ...delegated }) {
  return (
    React.createElement('label', { htmlFor: id }, label)
    React.createElement('input', { id: id, ...delegated })
  );
}

在 JavaScript 中,不允许像这样返回多个返回值,这就跟以下函数相同:

function addTwoNumbers(a, b) {
  return (
    "the answer is"
    a + b
  );
}

那么如何解决这个问题呢?

在很长的一段时间里,标准的做法是将两个元素包装在一个 wrapper 标签中,例如 <div>

function LabeledInput({ id, label, ...delegated }) {
  return (
    <div>
      <label htmlFor={id}>
        {label}
      </label>
      <input
        id={id}
        {...delegated}
      />
    </div>
  );
}

我们可以使用 fragments 来让这个解决方案变得更好:

function LabeledInput({ id, label, ...delegated }) {
  return (
    <React.Fragment>
      <label htmlFor={id}>
        {label}
      </label>
      <input
        id={id}
        {...delegated}
      />
    </React.Fragment>
  );
}

React.Fragment 是一个纯粹为了解决这个问题而存在的 React 组件,它允许在不影响 DOM 的情况下返回多个顶级元素,这意味着不需要再使用 <div> 来污染我们的文档。

<React.Fragment> 还可以简写为 <>

function LabeledInput({ id, label, ...delegated }) {
  return (
    <>
      <label htmlFor={id}>
        {label}
      </label>
      <input
        id={id}
        {...delegated}
      />
    </>
  );
}

我喜欢这里的象征意义:React 团队使用一个空的 HTML 标签 <> 来表明 Fragment 不会产生任何真正的 HTML 标签。

Flipping from uncontrolled to controlled

下面的代码是一个典型的将一个输入绑定到一个 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;

当你在这个 input 中输入内容的时候,你会收到以下控制台警告:

Warning: A component is changing an uncontrolled input to be controlled.

以下是解决方法:将 email 状态初始化为空字符串。

const [email, setEmail] = React.useState('');

当我们设置 value 属性时,我们告诉 React 我们希望这是一个受控输入。但是,只有在我们传递一个已定义的值时才有效!通过将 email 初始化为空字符串,我们确保 value 永远不会被设置为 undefined

Missing style brackets

JSX 和 HTML 非常类似,但两者之间也存在一些令人惊讶的差异,让人措手不及。大多数差异在文档中都有详细记录,同时,console 中的警告信息也都很详细。例如,如果不小心写了 class 而不是 className,React 会准确告诉你问题出在哪里。

但是有一个差别容易使人误会:style 属性。

在 HTML 中,style 是一个字符串:

<button style="color: red; font-size: 1.25rem">
  Hello World
</button>

然而,在 JSX 中,style 属性需要是一个对象,并使用驼峰式的属性名称。

下面将展示一段有问题的代码,你能发现错误在哪吗:

import React from 'react';

function App() {
  return (
    <button
      style={ color: 'red', fontSize: '1.25rem' }
    >
      Hello World
    </button>
  );
}

export default App;

错误提示:

/App.js: Unexpected token, expected "}" (6:19)
  4 |   return (
  5 |     <button
> 6 |       style={ color: 'red', fontSize: '1.25rem' }
    |                    ^
  7 |     >
  8 |       Hello World
  9 |     </button>

问题是,我们需要使用两个花括号:

<button
  // 使用 "{{" 而不是 "{":
  style={{ color: 'red', fontSize: '1.25rem' }}
>
  Hello World
</button>

在 JSX 中,我们使用花括号来创建表达式槽(expression slot),我们可以在这个插槽中放置任何有效的 JS 表达式,例如:

<button className={isPrimary ? 'btn primary' : 'btn'}>

任何放在 {} 之间的内容都将作为 JavaScript 来运行,最终取运行的结果作为值,因此上面代码的 className 将会是 btn primarybtn

因此,对于 style 属性,我们首先需要创建一个表达式槽,然后将一个 JavaScript 对象传入这个槽中。

把对象作为一个变量可能会更清晰:

// 1. 创建一个 style 对象:
const btnStyles = { color: 'red', fontSize: '1.25rem' };

// 2. 将对象传入 style 属性:
<button style={btnStyles}>
  Hello World
</button>

// 或者,我们可以在一行中写完:
<button style={{ color: 'red', fontSize: '1.25rem' }}>

其中,最外层的花括号创建了一个表达式槽,内层的花括号创建了一个 JS 对象来描述样式。

Async effect function

假设有一个函数可以在 mount 的时候从 API 中获取用户数据,我们使用 useEffect,并使用 await,这是第一次尝试:

import React from 'react';
import { API } from './constants';

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;

这段代码将得到以下报错:

/UserProfile.js: 'await' is only allowed within async functions (9:16)
   7 |   React.useEffect(() => {
   8 |     const url = `${API}/get-profile?id=${userId}`;
>  9 |     const res = await fetch(url);
     |                 ^
  10 |     const json = await res.json();
  11 |    
  12 |     setUser(json.user);

那给函数加上 async 关键词呢:

React.useEffect(async () => {
  const url = `${API}/get-profile?id=${userId}`;
  const res = await fetch(url);
  const json = await res.json();
  
  setUser(json);
}, [userId]);

还是不行:

destroy is not a function

解决方法:

React.useEffect(() => {
  // 创建一个 async 函数...
  async function runEffect() {
    const url = `${API}/get-profile?id=${userId}`;
    const res = await fetch(url);
    const json = await res.json();
    setUser(json);
  }

  // ...然后调用它:
  runEffect();
}, [userId]);

要理解为什么需要这么写,需要考虑 async 关键词的实际作用,对于以下函数:

async function greeting() {
  return "Hello world!";
}

乍一看可能会以为它返回字符串 "Hello world!",但实际上,这个函数返回一个 promise。这就是问题所在,因为 useEffect 并不希望我们返回一个 promise,它希望我们要么什么都不返回,要么就返回一个清理函数。

Developing an intuition

最后,作者最近推出了一个 React 的课程,课程页面做得非常炫酷:https://www.joyofreact.com/

这个博主的文章质量一直很高,课程应该也不差,感兴趣的可以看看!