React-添加交互

界面上的控件会根据用户的输入而更新。例如,点击按钮切换轮播图的展示。在 React 中,随时间变化的数据被称为…

界面上的控件会根据用户的输入而更新。例如,点击按钮切换轮播图的展示。在 React 中,随时间变化的数据被称为状态(state)。你可以向任何组件添加状态,并按需进行更新。在本章节中,你将学习如何编写处理交互的组件,更新它们的状态,并根据时间变化显示不同的效果。

响应事件

React 允许你向 JSX 中添加事件处理程序。事件处理程序是你自己的函数,它将在用户交互时被触发,如点击、悬停、焦点在表单输入框上等等。

<button> 等内置组件只支持内置浏览器事件,如 onClick。但是,你也可以创建你自己的组件,并给它们的事件处理程序 props 指定你喜欢的任何特定于应用的名称。

jsx
export default function App() {
  return (
    <Toolbar
      onPlayMovie={() => alert('Playing!')}
      onUploadImage={() => alert('Uploading!')}
    />
  );
}

function Toolbar({ onPlayMovie, onUploadImage }) {
  return (
    <div>
      <Button onClick={onPlayMovie}>
        Play Movie
      </Button>
      <Button onClick={onUploadImage}>
        Upload Image
      </Button>
    </div>
  );
}

function Button({ onClick, children }) {
  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
}

State: 组件的记忆

组件通常需要根据交互改变屏幕上的内容。在表单中键入更新输入栏,在轮播图上点击“下一个”改变显示的图片,点击“购买”将产品放入购物车。组件需要“记住”一些东西:当前的输入值、当前的图片、购物车。在 React 中,这种特定于组件的记忆被称为状态。

你可以用 useState Hook 为组件添加状态。Hook 是能让你的组件使用 React 功能的特殊函数(状态是这些功能之一)。useState Hook 让你声明一个状态变量。它接收初始状态并返回一对值:当前状态,以及一个让你更新状态的设置函数。

jsx
const [index, setIndex] = useState(0);
const [showMore, setShowMore] = useState(false);

下面是一个演示如何使用并在点击时更新状态:

jsx
import { useState } from 'react'
import './App.css'

function MyButtom(){

  const [count, setCount] = useState(0)
  function handleClick(){
    setCount(count+1)
  }
  return (<>
    <button onClick={handleClick}>当前点击次数:{count}</button>
  </>)
}


export default function App() {
  return (
    <>
      <MyButtom></MyButtom>
    </>
  )
}

渲染和提交

在你的组件显示在屏幕上之前,它们必须由 React 进行渲染。理解这个过程中的步骤有助于你思考你的代码如何执行并解释其行为。

想象一下,你的组件是厨房里的厨师,用食材制作出美味的菜肴。在这个场景中,React 是服务员,负责提出顾客的要求,并给顾客上菜。这个请求和服务 UI 的过程有三个步骤:

  1. 触发渲染(将食客的订单送到厨房)
  2. 渲染组件(在厨房准备订单)
  3. 提交到 DOM(将订单送到桌前)

image-20260531172020401

state 如同一张快照

与普通 JavaScript 变量不同,React 状态的行为更像一个快照。设置它并不改变你已有的状态变量,而是触发一次重新渲染。这在一开始可能会让人感到惊讶!

jsx
console.log(count);  // 0
setCount(count + 1); // 请求用 1 重新渲染
console.log(count);  // 仍然是 0!

React 这样工作是为了帮助你避免微妙的 bug。这里有一个小的聊天应用程序。试着猜一猜,如果先按下“发送”,然后再把收件人改为 Bob,会发生什么?五秒钟后,谁的名字会出现在 alert 中?

jsx
import { useState } from 'react';

export default function Form() {
  const [to, setTo] = useState('Alice');
  const [message, setMessage] = useState('Hello');

  function handleSubmit(e) {
    e.preventDefault();
    setTimeout(() => {
      alert(`You said ${message} to ${to}`);
    }, 5000);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        To:{' '}
        <select
          value={to}
          onChange={e => setTo(e.target.value)}>
          <option value="Alice">Alice</option>
          <option value="Bob">Bob</option>
        </select>
      </label>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}

事实上就是你点击的那个时刻的state

把一系列 state 更新加入队列

这个组件有问题:点击“+3”只能增加一次分数。

jsx
import { useState } from 'react';

export default function Counter() {
  const [score, setScore] = useState(0);

  function increment() {
    setScore(score + 1);
  }

  return (
    <>
      <button onClick={() => increment()}>+1</button>
      <button onClick={() => {
        increment();
        increment();
        increment();
      }}>+3</button>
      <h1>Score: {score}</h1>
    </>
  )
}

state 如同一张快照解释了为什么会出现这种情况。设置状态会请求一个新的重新渲染,但不会在已运行的代码中更改它。所以在你调用 setScore(score + 1) 后,score 仍然是 0

jsx
console.log(score);  // 0
setScore(score + 1); // setScore(0 + 1);
console.log(score);  // 0
setScore(score + 1); // setScore(0 + 1);
console.log(score);  // 0
setScore(score + 1); // setScore(0 + 1);
console.log(score);  // 0

你可以通过在设置状态时传递一个 更新器函数来解决这个问题。注意用 setScore(s => s + 1) 替换 setScore(score + 1) 是如何修复“+3”按钮的。如果你需要排队进行多次状态更新,那么这非常方便。

jsx
import { useState } from 'react';

export default function Counter() {
  const [score, setScore] = useState(0);

  function increment() {
    setScore(s => s + 1);
  }

  return (
    <>
      <button onClick={() => increment()}>+1</button>
      <button onClick={() => {
        increment();
        increment();
        increment();
      }}>+3</button>
      <h1>Score: {score}</h1>
    </>
  )
}

更新 state 中的对象

状态可以持有任何类型的 JavaScript 值,包括对象。但你不应该直接改变你在 React 状态中持有的对象和数组。相反,当你想更新一个对象和数组时,你需要创建一个新的对象(或复制现有的对象),然后用这个**副本**来更新状态。

通常情况下,你会使用 ... 展开语法来复制你想改变的对象和数组。例如,更新一个嵌套对象可以是这样的:

jsx
import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    name: 'Niki de Saint Phalle',
    artwork: {
      title: 'Blue Nana',
      city: 'Hamburg',
      image: 'https://react.dev/images/docs/scientists/Sd1AgUOm.jpg',
    }
  });

  function handleNameChange(e) {
    setPerson({
      ...person,
      name: e.target.value
    });
  }

  function handleTitleChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        title: e.target.value
      }
    });
  }

  function handleCityChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        city: e.target.value
      }
    });
  }

  function handleImageChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        image: e.target.value
      }
    });
  }

  return (
    <>
      <label>
        Name:
        <input
          value={person.name}
          onChange={handleNameChange}
        />
      </label>
      <label>
        Title:
        <input
          value={person.artwork.title}
          onChange={handleTitleChange}
        />
      </label>
      <label>
        City:
        <input
          value={person.artwork.city}
          onChange={handleCityChange}
        />
      </label>
      <label>
        Image:
        <input
          value={person.artwork.image}
          onChange={handleImageChange}
        />
      </label>
      <p>
        <i>{person.artwork.title}</i>
        {' by '}
        {person.name}
        <br />
        (located in {person.artwork.city})
      </p>
      <img
        src={person.artwork.image}
        alt={person.artwork.title}
      />
    </>
  );
}

更新 state 中的数组

数组是另一种可以存在状态中的可变 JavaScript 对象,应将其视为只读。就像对象一样,当你想更新存在状态中的数组时,你需要创建一个新数组(或者复制现有数组),然后用新数组来更新状态。

jsx
import { useState } from 'react';

const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
];

export default function BucketList() {
  const [list, setList] = useState(
    initialList
  );

  function handleToggle(artworkId, nextSeen) {
    setList(list.map(artwork => {
      if (artwork.id === artworkId) {
        return { ...artwork, seen: nextSeen };
      } else {
        return artwork;
      }
    }));
  }

  return (
    <>
      <h1>Art Bucket List</h1>
      <h2>My list of art to see:</h2>
      <ItemList
        artworks={list}
        onToggle={handleToggle} />
    </>
  );
}

function ItemList({ artworks, onToggle }) {
  return (
    <ul>
      {artworks.map(artwork => (
        <li key={artwork.id}>
          <label>
            <input
              type="checkbox"
              checked={artwork.seen}
              onChange={e => {
                onToggle(
                  artwork.id,
                  e.target.checked
                );
              }}
            />
            {artwork.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

可能会有疑惑,直接点击不是也能改变状态吗:

image-20260531175901351

不完全对,实际上连对勾都不会选中

因为这是受控组件

jsx
<input
  type="checkbox"
  checked={artwork.seen}  // 显示由 state 决定
  onChange={...}
/>

视觉上就是:点了没反应,checkbox 弹回原状

如果不绑定onChange会报错:

image-20260531174755181

就算绑定了onChange也不能改变行为:

  1. 你点击 → 浏览器把 checkbox 勾上(DOM 变了)
  2. onChange 触发 → 没有 setStateReact 不重新渲染
  3. 但 React 内部有一套机制,在合成事件处理完后会检查受控输入的 DOM 值和当前 props 是否一致
  4. 发现 DOM.checked = truechecked={false}强制把 DOM 改回去

这个行为叫 “controlled input enforcement”,不依赖重新渲染,是 React 单独处理的逻辑。

如果是vue里面:

双向绑定不影响,因为会自动监听

vue
<input type="checkbox" v-model="item.seen" />

单向的:

vue
<input :checked="item.seen" @change="() => {}" />

这个就只有触发重渲染的时候才会更新,不然就是点击选中

提示

React:数据是唯一真相(single source of truth)

UI 是数据的严格映射,React 不允许 DOM 和 state 出现任何不一致,哪怕是一帧。所以才有受控输入的主动巡检机制,代价是你必须显式写 onChange,否则直接锁死。

Vue:便利优先,信任开发者

v-model 帮你把 80% 的场景都处理好了,剩下的边界情况 Vue 不强制干预,DOM 和数据短暂不一致也不管。写起来更省事,但出了问题也更难察觉。

响应事件

使用 React 可以在 JSX 中添加 事件处理函数。其中事件处理函数为自定义函数,它将在响应交互(如点击、悬停、表单输入框获得焦点等)时触发。

添加事件处理函数

如需添加一个事件处理函数,你需要先定义一个函数,然后将其作为 prop 传入合适的 JSX 标签。例如,这里有一个没绑定任何事件的按钮:

jsx
export default function Button() {
  return (
    <button>
      未绑定任何事件
    </button>
  );
}

按照如下三个步骤,即可让它在用户点击时显示消息:

  1. Button 组件 内部 声明一个名为 handleClick 的函数。
  2. 实现函数内部的逻辑(使用 alert 来显示消息)。
  3. 添加 onClick={handleClick}<button> JSX 中。
jsx
export default function Button() {
  function handleClick() {
    alert('你点击了我!');
  }

  return (
    <button onClick={handleClick}>
      点我
    </button>
  );
}

你可以定义 handleClick 函数然后将其作为 prop传入 <button>。其中 handleClick 是一个 事件处理函数 。事件处理函数有如下特点:

  • 通常在你的组件 内部 定义。
  • 名称以 handle 开头,后跟事件名称。

按照惯例,通常将事件处理程序命名为 handle,后接事件名。你会经常看到 onClick={handleClick}onMouseEnter={handleMouseEnter} 等。

或者,你也可以在 JSX 中定义一个内联的事件处理函数:

jsx
<button onClick={function handleClick() {
  alert('你点击了我!');
}}>

或者,直接使用更为简洁箭头函数:

jsx
<button onClick={() => {
  alert('你点击了我!');
}}>

以上所有方式都是等效的。当函数体较短时,内联事件处理函数会很方便。

警告

传递给事件处理函数的函数应直接传递,而非调用。例如:

传递一个函数(正确)调用一个函数(错误)
<button onClick={handleClick}><button onClick={handleClick()}>

区别很微妙。在第一个示例中,handleClick 函数作为 onClick 事件处理函数传递。这会让 React 记住它,并且只在用户点击按钮时调用你的函数。

在第二个示例中,handleClick() 中最后的 () 会在 渲染 过程中 立即 触发函数,即使没有任何点击。这是因为位于 JSX {} 之间的 JavaScript 会立即执行。

当你编写内联代码时,同样的陷阱可能会以不同的方式出现:

传递一个函数(正确)调用一个函数(错误)
<button onClick={() => alert('...')}><button onClick={alert('...')}>

如果按如下方式传递内联代码,并不会在点击时触发,而是会在每次组件渲染时触发:

jsx
// 这个 alert 在组件渲染时触发,而不是点击时触发!
<button onClick={alert('你点击了我!')}>

如果你想要定义内联事件处理函数,请将其包装在匿名函数中,如下所示:

jsx
<button onClick={() => alert('你点击了我!')}>

这里创建了一个稍后调用的函数,而不会在每次渲染时执行其内部代码。

在这两种情况下,你都应该传递一个函数:

  • <button onClick={handleClick}> 传递了 handleClick 函数。
  • <button onClick={() => alert('...')}> 传递了 () => alert('...') 函数。

在事件处理函数中读取 props

由于事件处理函数声明于组件内部,因此它们可以直接访问组件的 props。示例中的按钮,当点击时会弹出带有 message prop 的 alert:

jsx
function AlertButton({ message, children }) {
  return (
    <button onClick={() => alert(message)}>
      {children}
    </button>
  );
}

export default function Toolbar() {
  return (
    <div>
      <AlertButton message="正在播放!">
        播放电影
      </AlertButton>
      <AlertButton message="正在上传!">
        上传图片
      </AlertButton>
    </div>
  );
}

此处有两个按钮,会展示不同的消息。你可以尝试更改传递给它们的消息。

将事件处理函数作为 props 传递

通常,我们会在父组件中定义子组件的事件处理函数。比如:置于不同位置的 Button 组件,可能最终执行的功能也不同 —— 也许是播放电影,也许是上传图片。

为此,将组件从父组件接收的 prop 作为事件处理函数传递,如下所示:

jsx
function Button({ onClick, children }) {
  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
}

function PlayButton({ movieName }) {
  function handlePlayClick() {
    alert(`正在播放 ${movieName}!`);
  }

  return (
    <Button onClick={handlePlayClick}>
      播放 "{movieName}"
    </Button>
  );
}

function UploadButton() {
  return (
    <Button onClick={() => alert('正在上传!')}>
      上传图片
    </Button>
  );
}

export default function Toolbar() {
  return (
    <div>
      <PlayButton movieName="魔女宅急便" />
      <UploadButton />
    </div>
  );
}

示例中,Toolbar 组件渲染了一个 PlayButton 组件和 UploadButton 组件:

  • PlayButtonhandlePlayClick 作为 onClick prop 传入 Button 组件内部。
  • UploadButton() => alert('正在上传!') 作为 onClick prop 传入 Button 组件内部。

最后,你的 Button 组件接收一个名为 onClick 的 prop。它直接将这个 prop 以 onClick={onClick} 方式传递给浏览器内置的 <button>。当点击按钮时,React 会调用传入的函数。

如果你遵循某个 设计系统 时,按钮之类的组件通常会包含样式,但不会指定行为。而 PlayButtonUploadButton 之类的组件则会向下传递事件处理函数。

命名事件处理函数 prop

内置组件(<button><div>)仅支持浏览器事件名称,例如 onClick。但是,当你构建自己的组件时,你可以按你个人喜好命名事件处理函数的 prop。

按照惯例,事件处理函数 props 应该以 on 开头,后跟一个大写字母。

例如,Button 组件的 onClick prop 本来也可以被命名为 onSmash

jsx
function Button({ onSmash, children }) {
  return (
    <button onClick={onSmash}>
      {children}
    </button>
  );
}

export default function App() {
  return (
    <div>
      <Button onSmash={() => alert('正在播放!')}>
        播放电影
      </Button>
      <Button onSmash={() => alert('正在上传!')}>
        上传图片
      </Button>
    </div>
  );
}

上述示例中,<button onClick={onSmash}> 代表浏览器内置的 <button>(小写)仍然需要使用 onClick prop,而自定义的 Button 组件接收到的 prop 名称可由你决定!

事件传播

事件处理函数还将捕获任何来自子组件的事件。通常,我们会说事件会沿着树向上“冒泡”或“传播”:它从事件发生的地方开始,然后沿着树向上传播。

下面这个 <div> 包含两个按钮。<div> 和每个按钮都有自己的 onClick 处理函数。你认为点击按钮时会触发哪些处理函数?

jsx
export default function Toolbar() {
  return (
    <div className="Toolbar" onClick={() => {
      alert('你点击了 toolbar !');
    }}>
      <button onClick={() => alert('正在播放!')}>
        播放电影
      </button>
      <button onClick={() => alert('正在上传!')}>
        上传图片
      </button>
    </div>
  );
}

如果你点击任一按钮,它自身的 onClick 将首先执行,然后父级 <div>onClick 会接着执行。因此会出现两条消息。如果你点击 toolbar 本身,将只有父级 <div>onClick 会执行。

在 React 中所有事件都会传播,除了 onScroll,它仅适用于你附加到的 JSX 标签。

阻止传播

事件处理函数接收一个 事件对象 作为唯一的参数。按照惯例,它通常被称为 e ,代表 “event”(事件)。你可以使用此对象来读取有关事件的信息。

这个事件对象还允许你阻止传播。如果你想阻止一个事件到达父组件,你需要像下面 Button 组件那样调用 e.stopPropagation()

jsx
function Button({ onClick, children }) {
  return (
    <button onClick={e => {
      e.stopPropagation();
      onClick();
    }}>
      {children}
    </button>
  );
}

export default function Toolbar() {
  return (
    <div className="Toolbar" onClick={() => {
      alert('你点击了 toolbar !');
    }}>
      <Button onClick={() => alert('正在播放!')}>
        播放电影
      </Button>
      <Button onClick={() => alert('正在上传!')}>
        上传图片
      </Button>
    </div>
  );
}

当你点击按钮时:

  1. React 调用了传递给 <button>onClick 处理函数。
  2. 定义在 Button 中的处理函数执行了如下操作:
    • 调用 e.stopPropagation(),阻止事件进一步冒泡。
    • 调用 onClick 函数,它是从 Toolbar 组件传递过来的 prop。
  3. Toolbar 组件中定义的函数,显示按钮对应的 alert。
  4. 由于传播被阻止,父级 <div>onClick 处理函数不会执行。

由于调用了 e.stopPropagation(),点击按钮现在将只显示一个 alert(来自 <button>),而并非两个(分别来自 <button> 和父级 toolbar <div>)。点击按钮与点击周围的 toolbar 不同,因此阻止传播对这个 UI 是有意义的。

重要

极少数情况下,你可能需要捕获子元素上的所有事件,即便它们阻止了传播。例如,你可能想对每次点击进行埋点记录,传播逻辑暂且不论。那么你可以通过在事件名称末尾添加 Capture 来实现这一点:

tsx
<div onClickCapture={() => { /* 这会首先执行 */ }}>
  <button onClick={e => e.stopPropagation()} />
  <button onClick={e => e.stopPropagation()} />
</div>

每个事件分三个阶段传播:

  1. 它向下传播,调用所有的 onClickCapture 处理函数。
  2. 它执行被点击元素的 onClick 处理函数。
  3. 它向上传播,调用所有的 onClick 处理函数。

捕获事件对于路由或数据分析之类的代码很有用,但你可能不会在应用程序代码中使用它们。

传递处理函数作为事件传播的替代方案

注意,此处的点击事件处理函数先执行了一行代码,然后调用了父组件传递的 onClick prop:

tsx
function Button({ onClick, children }) {
  return (
    <button onClick={e => {
      e.stopPropagation();
      onClick();
    }}>
      {children}
    </button>
  );
}

你也可以在调用父元素 onClick 函数之前,向这个处理函数添加更多代码。此模式是事件传播的另一种 替代方案 。它让子组件处理事件,同时也让父组件指定一些额外的行为。与事件传播不同,它并非自动。但使用这种模式的好处是你可以清楚地追踪因某个事件的触发而执行的整条代码链。

如果你依赖于事件传播,而且很难追踪哪些处理程序在执行,及其执行的原因,可以尝试这种方法。

阻止默认行为

某些浏览器事件具有与事件相关联的默认行为。例如,点击 <form> 表单内部的按钮会触发表单提交事件,默认情况下将重新加载整个页面:

tsx
export default function Signup() {
  return (
    <form onSubmit={() => alert('提交表单!')}>
      <input />
      <button>发送</button>
    </form>
  );
}

你可以调用事件对象中的 e.preventDefault() 来阻止这种情况发生:

tsx
export default function Signup() {
  return (
    <form onSubmit={e => {
      e.preventDefault();
      alert('提交表单!');
    }}>
      <input />
      <button>发送</button>
    </form>
  );
}

不要混淆 e.stopPropagation()e.preventDefault()。它们都很有用,但二者并不相关:

  • e.stopPropagation() 阻止触发绑定在外层标签上的事件处理函数。
  • e.preventDefault() 阻止少数事件的默认浏览器行为。

事件处理函数可以包含副作用吗?

当然可以!事件处理函数是执行副作用的最佳位置。

与渲染函数不同,事件处理函数不需要是 纯函数,因此它是用来 更改 某些值的绝佳位置。例如,更改输入框的值以响应键入,或者更改列表以响应按钮的触发。但是,为了更改某些信息,你首先需要某种方式存储它。在 React 中,这是通过 state(组件的记忆) 来完成的。你将在下一章节了解所有相关信息。

State:组件的记忆

组件通常需要根据交互更改屏幕上显示的内容。输入表单应该更新输入字段,单击轮播图上的“下一个”应该更改显示的图片,单击“购买”应该将商品放入购物车。组件需要“记住”某些东西:当前输入值、当前图片、购物车。在 React 中,这种组件特有的记忆被称为 state

当普通的变量无法满足时

以下是一个渲染雕塑图片的组件。点击 “Next” 按钮应该显示下一个雕塑并将 index 更改为 1,再次点击又更改为 2,以此类推。但这个组件现在不起作用

jsx
import { sculptureList } from './data.js';

export default function Gallery() {
  let index = 0;

  function handleClick() {
    index = index + 1;
  }

  let sculpture = sculptureList[index];
  return (
    <>
      <button onClick={handleClick}>
        Next
      </button>
      <h2>
        <i>{sculpture.name} </i>
        by {sculpture.artist}
      </h2>
      <h3>
        ({index + 1} of {sculptureList.length})
      </h3>
      <img
        src={sculpture.url}
        alt={sculpture.alt}
      />
      <p>
        {sculpture.description}
      </p>
    </>
  );
}

handleClick() 事件处理函数正在更新局部变量 index。但存在两个原因使得变化不可见:

  1. 局部变量无法在多次渲染中持久保存。 当 React 再次渲染这个组件时,它会从头开始渲染——不会考虑之前对局部变量的任何更改。
  2. 更改局部变量不会触发渲染。 React 没有意识到它需要使用新数据再次渲染组件。

要使用新数据更新组件,需要做两件事:

  1. 保留 渲染之间的数据。
  2. 触发 React 使用新数据渲染组件(重新渲染)。

useState Hook 提供了这两个功能:

  1. State 变量 用于保存渲染间的数据。
  2. State setter 函数 更新变量并触发 React 再次渲染组件。

添加一个 state 变量

要添加 state 变量,先从文件顶部的 React 中导入 useState

jsx
import { useState } from 'react';

然后,替换这一行:

jsx
let index = 0;

将其修改为

jsx
const [index, setIndex] = useState(0);

index 是一个 state 变量,setIndex 是对应的 setter 函数。

这里的 [] 语法称为数组解构,它允许你从数组中读取值。 useState 返回的数组总是正好有两项。

以下展示了它们在 handleClick() 中是如何共同起作用的:

jsx
function handleClick() {
  setIndex(index + 1);
}

现在点击 “Next” 按钮切换当前雕塑:

jsx
import { useState } from 'react';
import { sculptureList } from './data.js';

export default function Gallery() {
  const [index, setIndex] = useState(0);

  function handleClick() {
    setIndex(index + 1);
  }

  let sculpture = sculptureList[index];
  return (
    <>
      <button onClick={handleClick}>
        Next
      </button>
      <h2>
        <i>{sculpture.name} </i>
        by {sculpture.artist}
      </h2>
      <h3>
        ({index + 1} of {sculptureList.length})
      </h3>
      <img
        src={sculpture.url}
        alt={sculpture.alt}
      />
      <p>
        {sculpture.description}
      </p>
    </>
  );
}

遇见你的第一个 Hook

在 React 中,useState 以及任何其他以“use”开头的函数都被称为 Hook

Hook 是特殊的函数,只在 React 渲染时有效。它们能让你 “hook” 到不同的 React 特性中去。

State 只是这些特性中的一个,你之后还会遇到其他 Hook。

重要

Hooks ——以 use 开头的函数——只能在组件或自定义 Hook 的最顶层调用。 你不能在条件语句、循环语句或其他嵌套函数内调用 Hook。

剖析 useState

当你调用 useState 时,你是在告诉 React 你想让这个组件记住一些东西:

jsx
const [index, setIndex] = useState(0);

在这个例子里,你希望 React 记住 index

提示

惯例是将这对返回值命名为 const [thing, setThing]。你也可以将其命名为任何你喜欢的名称,但遵照约定俗成能使跨项目合作更易理解。

useState 的唯一参数是 state 变量的 初始值。在这个例子中,index 的初始值被 useState(0) 设置为 0

每次你的组件渲染时,useState 都会给你一个包含两个值的数组:

  1. state 变量 (index) 会保存上次渲染的值。
  2. state setter 函数 (setIndex) 可以更新 state 变量并触发 React 重新渲染组件。

以下是实际发生的情况:

jsx
const [index, setIndex] = useState(0);
  1. 组件进行第一次渲染。 因为你将 0 作为 index 的初始值传递给 useState,它将返回 [0, setIndex]。 React 记住 0 是最新的 state 值。
  2. 你更新了 state。当用户点击按钮时,它会调用 setIndex(index + 1)index0,所以它是 setIndex(1)。这告诉 React 现在记住 index1 并触发下一次渲染。
  3. 组件进行第二次渲染。React 仍然看到 useState(0),但是因为 React 记住 了你将 index 设置为了 1,它将返回 [1, setIndex]
  4. 以此类推!

赋予一个组件多个 state 变量

你可以在一个组件中拥有任意多种类型的 state 变量。该组件有两个 state 变量,一个数字 index 和一个布尔值 showMore,点击 “Show Details” 会改变 showMore 的值:

jsx
import { useState } from 'react';
import { sculptureList } from './data.js';

export default function Gallery() {
  const [index, setIndex] = useState(0);
  const [showMore, setShowMore] = useState(false);

  function handleNextClick() {
    setIndex(index + 1);
  }

  function handleMoreClick() {
    setShowMore(!showMore);
  }

  let sculpture = sculptureList[index];
  return (
    <>
      <button onClick={handleNextClick}>
        Next
      </button>
      <h2>
        <i>{sculpture.name} </i>
        by {sculpture.artist}
      </h2>
      <h3>
        ({index + 1} of {sculptureList.length})
      </h3>
      <button onClick={handleMoreClick}>
        {showMore ? 'Hide' : 'Show'} details
      </button>
      {showMore && <p>{sculpture.description}</p>}
      <img
        src={sculpture.url}
        alt={sculpture.alt}
      />
    </>
  );
}

如果它们不相关,那么存在多个 state 变量是一个好主意,例如本例中的 indexshowMore。但是,如果你发现经常同时更改两个 state 变量,那么最好将它们合并为一个。例如,如果你有一个包含多个字段的表单,那么有一个值为对象的 state 变量比每个字段对应一个 state 变量更方便。

State 是隔离且私有的

State 是屏幕上组件实例内部的状态。换句话说,如果你渲染同一个组件两次,每个副本都会有完全隔离的 state!改变其中一个不会影响另一个。

这就是 state 与声明在模块顶部的普通变量不同的原因。 State 不依赖于特定的函数调用或在代码中的位置,它的作用域“只限于”屏幕上的某块特定区域。你渲染了两个 <Gallery /> 组件,所以它们的 state 是分别存储的。

还要注意 Page 组件“不知道”关于 Gallery state 的任何信息,甚至不知道它是否有任何 state。与 props 不同,state 完全私有于声明它的组件。父组件无法更改它。这使你可以向任何组件添加或删除 state,而不会影响其他组件。

如果你希望两个画廊保持其 states 同步怎么办?在 React 中执行此操作的正确方法是从子组件中删除 state 并将其添加到离它们最近的共享父组件中。

渲染和提交

组件显示到屏幕之前,其必须被 React 渲染。理解这些处理步骤将帮助你思考代码的执行过程并能解释其行为。

想象一下,你的组件是厨房里的厨师,把食材烹制成美味的菜肴。在这种场景下,React 就是一名服务员,他会帮客户们下单并为他们送来所点的菜品。这种请求和提供 UI 的过程总共包括三个步骤:

  1. 触发 一次渲染(把客人的点单分发到厨房)
  2. 渲染 组件(在厨房准备订单)
  3. 提交 到 DOM(将菜品放在桌子上)

image-20260531184826559

步骤 1: 触发一次渲染

有两种原因会导致组件的渲染:

  1. 组件的 初次渲染。
  2. 组件(或者其祖先之一)的 状态发生了改变。

初次渲染

当应用启动时,会触发初次渲染。框架和沙箱有时会隐藏这部分代码,但它是通过调用 createRoot 方法并传入目标 DOM 节点,然后用你的组件调用 render 函数完成的:

jsx
import Image from './Image.js';
import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'))
root.render(<Image />);

试着注释掉 root.render(),然后你将会看到组件消失。

状态更新时重新渲染

一旦组件被初次渲染,你就可以通过使用 set 函数更新其状态来触发之后的渲染。更新组件的状态会自动将一次渲染送入队列。(

image-20260531185036576

步骤 2: React 渲染你的组件

在你触发渲染后,React 会调用你的组件来确定要在屏幕上显示的内容。“渲染中” 即 React 在调用你的组件。

  • 在进行初次渲染时, React 会调用根组件。
  • 对于后续的渲染, React 会调用内部状态更新触发了渲染的函数组件

这个过程是递归的:如果更新后的组件会返回某个另外的组件,那么 React 接下来就会渲染 那个 组件,而如果那个组件又返回了某个组件,那么 React 接下来就会渲染 那个 组件,以此类推。这个过程会持续下去,直到没有更多的嵌套组件并且 React 确切知道哪些东西应该显示到屏幕上为止。

在接下来的例子中,React 将会调用 Gallery()Image() 若干次:

jsx
export default function Gallery() {
  return (
    <section>
      <h1>鼓舞人心的雕塑</h1>
      <Image />
      <Image />
      <Image />
    </section>
  );
}

function Image() {
  return (
    <img
      src="https://react.dev/images/docs/scientists/ZF6s192.jpg"
      alt="'Floralis Genérica' by Eduardo Catalano: a gigantic metallic flower sculpture with reflective petals"
    />
  );
}
  • 在初次渲染中, React 将会为<section><h1> 和三个 <img> 标签创建 DOM 节点。
  • 在一次重渲染过程中, React 将计算它们的哪些属性(如果有的话)自上次渲染以来已更改。在下一步(提交阶段)之前,它不会对这些信息执行任何操作。

注意

渲染必须始终是一次纯计算:

  • 输入相同,输出相同。 给定相同的输入,组件应始终返回相同的 JSX。(当有人点了西红柿沙拉时,他们不应该收到洋葱沙拉!)
  • 只做它自己的事情。 它不应更改任何存在于渲染之前的对象或变量。(一个订单不应更改其他任何人的订单。)

否则,随着代码库复杂性的增加,你可能会遇到令人困惑的错误和不可预测的行为。在 “严格模式” 下开发时,React 会调用每个组件的函数两次,这可以帮助发现由不纯函数引起的错误。

步骤 3: React 把更改提交到 DOM 上

在渲染(调用)你的组件之后,React 将会修改 DOM。

  • 对于初次渲染,React 会使用 appendChild() DOM API 将其创建的所有 DOM 节点放在屏幕上。
  • 对于重渲染,React 将应用最少的必要操作(在渲染时计算!),以使得 DOM 与最新的渲染输出相互匹配。

React 仅在渲染之间存在差异时才会更改 DOM 节点。 例如,有一个组件,它每秒使用从父组件传递下来的不同属性重新渲染一次。注意,你可以添加一些文本到 <input> 标签,更新它的 value,但是文本不会在组件重渲染时消失:

jsx
export default function Clock({ time }) {
  return (
    <>
      <h1>{time}</h1>
      <input />
    </>
  );
}

这个例子之所以会正常运行,是因为在最后一步中,React 只会使用最新的 time 更新 <h1> 标签的内容。它看到 <input> 标签出现在 JSX 中与上次相同的位置,因此 React 不会修改 <input> 标签或它的 value

尾声:浏览器绘制

在渲染完成并且 React 更新 DOM 之后,浏览器就会重新绘制屏幕。尽管这个过程被称为“浏览器渲染”(“browser rendering”),但我们还是将它称为“绘制”(“painting”),以避免在这些文档的其余部分中出现混淆。

state 如同一张快照

也许 state 变量看起来和一般的可读写的 JavaScript 变量类似。但 state 在其表现出的特性上更像是一张快照。设置它不会更改你已有的 state 变量,但会触发重新渲染。

设置 state 会触发渲染

你可能会认为你的用户界面会直接对点击之类的用户输入做出响应并发生变化。

在这个例子中,当你按下 “send” 时,setIsSent(true) 会通知 React 重新渲染 UI:

jsx
import { useState } from 'react';

export default function Form() {
  const [isSent, setIsSent] = useState(false);
  const [message, setMessage] = useState('Hi!');
  if (isSent) {
    return <h1>Your message is on its way!</h1>
  }
  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      setIsSent(true);
      sendMessage(message);
    }}>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}

function sendMessage(message) {
  // ...
}

当你单击按钮时会发生以下情况:

  1. 执行 onSubmit 事件处理函数。
  2. setIsSent(true)isSent 设置为 true 并排列一个新的渲染。
  3. React 根据新的 isSent 值重新渲染组件。

让我们仔细看看 state 和渲染之间的关系。

渲染会及时生成一张快照

“正在渲染” 就意味着 React 正在调用你的组件——一个函数。你从该函数返回的 JSX 就像是在某个时间点上 UI 的快照。它的 props、事件处理函数和内部变量都是 根据当前渲染时的 state 被计算出来的。

与照片或电影画面不同,你返回的 UI “快照”是可交互的。它其中包括类似事件处理函数的逻辑,这些逻辑用于指定如何对输入作出响应。React 随后会更新屏幕来匹配这张快照,并绑定事件处理函数。因此,按下按钮就会触发你 JSX 中的点击事件处理函数。

当 React 重新渲染一个组件时:

  1. React 会再次调用你的函数
  2. 函数会返回新的 JSX 快照
  3. React 会更新界面以匹配返回的快照

image-20260531185304217

作为一个组件的记忆,state 不同于在你的函数返回之后就会消失的普通变量。state 实际上“活”在 React 本身中——就像被摆在一个架子上!——位于你的函数之外。当 React 调用你的组件时,它会为特定的那一次渲染提供一张 state 快照。你的组件会在其 JSX 中返回一张包含一整套新的 props 和事件处理函数的 UI 快照 ,其中所有的值都是 根据那一次渲染中 state 的值 被计算出来的!

image-20260531185327483

这里有个向你展示其运行原理的小例子。在这个例子中,你可能会以为点击“+3”按钮会调用 setNumber(number + 1) 三次从而使计数器递增三次。

看看你点击“+3”按钮时会发生什么:

jsx
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}>+3</button>
    </>
  )
}

请注意,每次点击只会让 number 递增一次!

设置 state 只会为下一次渲染变更 state 的值。在第一次渲染期间,number0。这也就解释了为什么在 那次渲染中的 onClick 处理函数中,即便在调用了 setNumber(number + 1) 之后,number 的值也仍然是 0

jsx
<button onClick={() => {
  setNumber(number + 1);
  setNumber(number + 1);
  setNumber(number + 1);
}}>+3</button>

以下是这个按钮的点击事件处理函数通知 React 要做的事情:

  1. setNumber(number + 1)number0 所以 setNumber(0 + 1)
    • React 准备在下一次渲染时将 number 更改为 1
  2. setNumber(number + 1)number0 所以 setNumber(0 + 1)
    • React 准备在下一次渲染时将 number 更改为 1
  3. setNumber(number + 1)number0 所以 setNumber(0 + 1)
    • React 准备在下一次渲染时将 number 更改为 1

尽管你调用了三次 setNumber(number + 1),但在 这次渲染的 事件处理函数中 number 会一直是 0,所以你会三次将 state 设置成 1。这就是为什么在你的事件处理函数执行完以后,React 重新渲染的组件中的 number 等于 1 而不是 3

你还可以通过在心里把 state 变量替换成它们在你代码中的值来想象这个过程。由于 这次渲染 中的 state 变量 number0,其事件处理函数看起来会像这样:

jsx
<button onClick={() => {
  setNumber(0 + 1);
  setNumber(0 + 1);
  setNumber(0 + 1);
}}>+3</button>

对于下一次渲染来说,number 是 1,因此 那次渲染中的 点击事件处理函数看起来会像这样:

<button onClick={() => {
  setNumber(1 + 1);
  setNumber(1 + 1);
  setNumber(1 + 1);
}}>+3</button>

这就是为什么再次点击按钮会将计数器设置为 2,下次点击时会设为 3,依此类推。

随时间变化的 state

好的,刚才那些很有意思。试着猜猜点击这个按钮会发出什么警告:

jsx
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        alert(number);
      }}>+5</button>
    </>
  )
}

如果你使用之前替换的方法,你就能猜到这个提示框将会显示 “0”:

jsx
setNumber(0 + 5);
alert(0);

但如果你在这个提示框上加上一个定时器, 使得它在组件重新渲染 之后 才触发,又会怎样呢?是会显示 “0” 还是 “5” ?猜一猜!

jsx
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setTimeout(() => {
          alert(number);
        }, 3000);
      }}>+5</button>
    </>
  )
}

惊讶吗?你如果使用替代法,就能看到被传入提示框的 state “快照”。

jsx
setNumber(0 + 5);
setTimeout(() => {
  alert(0);
}, 3000);

到提示框运行时,React 中存储的 state 可能已经发生了更改,但它是使用用户与之交互时状态的快照进行调度的!

一个 state 变量的值永远不会在一次渲染的内部发生变化(只读), 即使其事件处理函数的代码是异步的。在 那次渲染的 onClick 内部,number 的值即使在调用 setNumber(number + 5) 之后也还是 0。它的值在 React 通过调用你的组件“获取 UI 的快照”时就被“固定”了。

这里有个示例能够说明上述特性会使你的事件处理函数更不容易出现计时错误。下面是一个会在五秒延迟之后发送一条消息的表单。想象以下场景:

  1. 你按下“发送”按钮,向 Alice 发送“你好”。
  2. 在五秒延迟结束之前,将“To”字段的值更改为“Bob”。

你觉得 alert 会显示什么?它是会显示“你向 Alice 说了你好“还是会显示“你向 Bob 说了你好”?根据你已经学到的知识猜一猜,然后动手试一试:

jsx
import { useState } from 'react';

export default function Form() {
  const [to, setTo] = useState('Alice');
  const [message, setMessage] = useState('你好');

  function handleSubmit(e) {
    e.preventDefault();
    setTimeout(() => {
      alert(`你向 ${to} 说了${message}`);
    }, 5000);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        To:{' '}
        <select
          value={to}
          onChange={e => setTo(e.target.value)}>
          <option value="Alice">Alice</option>
          <option value="Bob">Bob</option>
        </select>
      </label>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit">发送</button>
    </form>
  );
}

React 会使 state 的值始终“固定”在一次渲染的各个事件处理函数内部。你无需担心代码运行时 state 是否发生了变化。

但是,万一你想在重新渲染之前读取最新的 state 怎么办?你应该使用 状态更新函数,下一页将会介绍!

把一系列 state 更新加入队列

设置组件 state 会把一次重新渲染加入队列。但有时你可能会希望在下次渲染加入队列之前对 state 的值执行多次操作。为此,了解 React 如何批量更新 state 会很有帮助。

React 会对 state 更新进行批处理

在下面的示例中,你可能会认为点击 “+3” 按钮会使计数器递增三次,因为它调用了 setNumber(number + 1) 三次:

jsx
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}>+3</button>
    </>
  )
}

但是,你可能还记得上一节中的内容,每一次渲染的 state 值都是固定的,因此无论你调用多少次 setNumber(1),在第一次渲染的事件处理函数内部的 number 值总是 0

jsx
setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);

但是这里还有另外一个影响因素需要讨论。React 会等到事件处理函数中的 所有 代码都运行完毕再处理你的 state 更新。 这就是重新渲染只会发生在所有这些 setNumber() 调用 之后 的原因。

这可能会让你想起餐厅里帮你点菜的服务员。服务员不会在你说第一道菜的时候就跑到厨房!相反,他们会让你把菜点完,让你修改菜品,甚至会帮桌上的其他人点菜。

image-20260531185659560

这让你可以更新多个 state 变量——甚至来自多个组件的 state 变量——而不会触发太多的 重新渲染。但这也意味着只有在你的事件处理函数及其中任何代码执行完成 之后,UI 才会更新。这种特性也就是 批处理,它会使你的 React 应用运行得更快。它还会帮你避免处理只更新了一部分 state 变量的令人困惑的“半成品”渲染。

React 不会跨 *多个* 需要刻意触发的事件(如点击)进行批处理——每次点击都是单独处理的。请放心,React 只会在一般来说安全的情况下才进行批处理。这可以确保,例如,如果第一次点击按钮会禁用表单,那么第二次点击就不会再次提交它。

在下次渲染前多次更新同一个 state

这是一个不常见的用例,但是如果你想在下次渲染之前多次更新同一个 state,你可以像 setNumber(n => n + 1) 这样传入一个根据队列中的前一个 state 计算下一个 state 的 函数,而不是像 setNumber(number + 1) 这样传入 下一个 state 值。这是一种告诉 React “用 state 值做某事”而不是仅仅替换它的方法。

现在尝试递增计数器:

jsx
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(n => n + 1);
        setNumber(n => n + 1);
        setNumber(n => n + 1);
      }}>+3</button>
    </>
  )
}

在这里,n => n + 1 被称为 更新函数。当你将它传递给一个 state 设置函数时:

  1. React 会将此函数加入队列,以便在事件处理函数中的所有其他代码运行后进行处理。
  2. 在下一次渲染期间,React 会遍历队列并给你更新之后的最终 state。
jsx
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);

下面是 React 在执行事件处理函数时处理这几行代码的过程:

  1. setNumber(n => n + 1)n => n + 1 是一个函数。React 将它加入队列。
  2. setNumber(n => n + 1)n => n + 1 是一个函数。React 将它加入队列。
  3. setNumber(n => n + 1)n => n + 1 是一个函数。React 将它加入队列。

当你在下次渲染期间调用 useState 时,React 会遍历队列。之前的 number state 的值是 0,所以这就是 React 作为参数 n 传递给第一个更新函数的值。然后 React 会获取你上一个更新函数的返回值,并将其作为 n 传递给下一个更新函数,以此类推:

更新队列n返回值
n => n + 100 + 1 = 1
n => n + 111 + 1 = 2
n => n + 122 + 1 = 3

React 会保存 3 为最终结果并从 useState 中返回。

这就是为什么在上面的示例中点击“+3”正确地将值增加“+3”。

如果你在替换 state 后更新 state 会发生什么

这个事件处理函数会怎么样?你认为 number 在下一次渲染中的值是什么?

jsx
<button onClick={() => {
  setNumber(number + 5);
  setNumber(n => n + 1);
}}>
jsx
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
      }}>增加数字</button>
    </>
  )
}

这是事件处理函数告诉 React 要做的事情:

  1. setNumber(number + 5)number0,所以 setNumber(0 + 5)。React 将 “替换为 5 添加到其队列中。
  2. setNumber(n => n + 1)n => n + 1 是一个更新函数。 React 将 该函数 添加到其队列中。

在下一次渲染期间,React 会遍历 state 队列:

更新队列n返回值
“替换为 50(未使用)5
n => n + 155 + 1 = 6

React 会保存 6 为最终结果并从 useState 中返回。

提示

你可能已经注意到,setState(x) 实际上会像 setState(n => x) 一样运行,只是没有使用 n

如果你在更新 state 后替换 state 会发生什么

让我们再看一个例子。你认为 number 在下一次渲染中的值是什么?

jsx
<button onClick={() => {
  setNumber(number + 5);
  setNumber(n => n + 1);
  setNumber(42);
}}>
jsx
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
        setNumber(42);
      }}>增加数字</button>
    </>
  )
}

以下是 React 在执行事件处理函数时处理这几行代码的过程:

  1. setNumber(number + 5)number0,所以 setNumber(0 + 5)。React 将 “替换为 5 添加到其队列中。
  2. setNumber(n => n + 1)n => n + 1 是一个更新函数。React 将该函数添加到其队列中。
  3. setNumber(42):React 将 “替换为 42 添加到其队列中。

在下一次渲染期间,React 会遍历 state 队列:

更新队列n返回值
“替换为 50(未使用)5
n => n + 155 + 1 = 6
“替换为 426(未使用)42

然后 React 会保存 42 为最终结果并从 useState 中返回。

总而言之,以下是你可以考虑传递给 setNumber state 设置函数的内容:

  • 一个更新函数(例如:n => n + 1)会被添加到队列中。
  • 任何其他的值(例如:数字 5)会导致“替换为 5”被添加到队列中,已经在队列中的内容会被忽略。

事件处理函数执行完成后,React 将触发重新渲染。在重新渲染期间,React 将处理队列。更新函数会在渲染期间执行,因此 更新函数必须是 纯函数 并且只 返回 结果。不要尝试从它们内部设置 state 或者执行其他副作用。在严格模式下,React 会执行每个更新函数两次(但是丢弃第二个结果)以便帮助你发现错误。

命名惯例

通常可以通过相应 state 变量的第一个字母来命名更新函数的参数:

jsx
setEnabled(e => !e);
setLastName(ln => ln.reverse());
setFriendCount(fc => fc * 2);

如果你喜欢更冗长的代码,另一个常见的惯例是重复使用完整的 state 变量名称,如 setEnabled(enabled => !enabled),或使用前缀,如 setEnabled(prevEnabled => !prevEnabled)

更新 state 中的对象

state 中可以保存任意类型的 JavaScript 值,包括对象。但是,你不应该直接修改存放在 React state 中的对象。相反,当你想要更新一个对象时,你需要创建一个新的对象(或者将其拷贝一份),然后将 state 更新为此对象。

什么是 mutation?

你可以在 state 中存放任意类型的 JavaScript 值。

jsx
const [x, setX] = useState(0);

到目前为止,你已经尝试过在 state 中存放数字、字符串和布尔值,这些类型的值在 JavaScript 中是不可变(immutable)的,这意味着它们不能被改变或是只读的。你可以通过替换它们的值以触发一次重新渲染。

jsx
setX(5);

state x0 变为 5,但是数字 0 本身并没有发生改变。在 JavaScript 中,无法对内置的原始值,如数字、字符串和布尔值,进行任何更改。

现在考虑 state 中存放对象的情况:

jsx
const [position, setPosition] = useState({ x: 0, y: 0 });

从技术上来讲,可以改变对象自身的内容。当你这样做时,就制造了一个 mutation

jsx
position.x = 5;

然而,虽然严格来说 React state 中存放的对象是可变的,但你应该像处理数字、布尔值、字符串一样将它们视为不可变的。因此你应该替换它们的值,而不是对它们进行修改。

将 state 视为只读的

换句话说,你应该 把所有存放在 state 中的 JavaScript 对象都视为只读的

在下面的例子中,我们用一个存放在 state 中的对象来表示指针当前的位置。当你在预览区触摸或移动光标时,红色的点本应移动。但是实际上红点仍停留在原处:

jsx
import { useState } from 'react';

export default function MovingDot() {
  const [position, setPosition] = useState({
    x: 0,
    y: 0
  });
  return (
    <div
      onPointerMove={e => {
        position.x = e.clientX;
        position.y = e.clientY;
      }}
      style={{
        position: 'relative',
        width: '100vw',
        height: '100vh',
      }}>
      <div style={{
        position: 'absolute',
        backgroundColor: 'red',
        borderRadius: '50%',
        transform: `translate(${position.x}px, ${position.y}px)`,
        left: -10,
        top: -10,
        width: 20,
        height: 20,
      }} />
    </div>
  );
}

问题出在下面这段代码中。

jsx
onPointerMove={e => {
  position.x = e.clientX;
  position.y = e.clientY;
}}

这段代码直接修改了 上一次渲染中 分配给 position 的对象。但是因为并没有使用 state 的设置函数,React 并不知道对象已更改。所以 React 没有做出任何响应。这就像在吃完饭之后才尝试去改变要点的菜一样。虽然在一些情况下,直接修改 state 可能是有效的,但我们并不推荐这么做。你应该把在渲染过程中可以访问到的 state 视为只读的。

在这种情况下,为了真正地触发一次重新渲染,你需要创建一个新对象并把它传递给 state 的设置函数

jsx
onPointerMove={e => {
  setPosition({
    x: e.clientX,
    y: e.clientY
  });
}}

通过使用 setPosition,你在告诉 React:

  • 使用这个新的对象替换 position 的值
  • 然后再次渲染这个组件

现在你可以看到,当你在预览区触摸或移动光标时,红点会跟随着你的指针移动:

jsx
import { useState } from 'react';

export default function MovingDot() {
  const [position, setPosition] = useState({
    x: 0,
    y: 0
  });
  return (
    <div
      onPointerMove={e => {
        setPosition({
          x: e.clientX,
          y: e.clientY
        });
      }}
      style={{
        position: 'relative',
        width: '100vw',
        height: '100vh',
      }}>
      <div style={{
        position: 'absolute',
        backgroundColor: 'red',
        borderRadius: '50%',
        transform: `translate(${position.x}px, ${position.y}px)`,
        left: -10,
        top: -10,
        width: 20,
        height: 20,
      }} />
    </div>
  );
}

警告

像这样的代码是有问题的,因为它改变了 state 中现有的对象:

jsx
position.x = e.clientX;
position.y = e.clientY;

但是像这样的代码就 没有任何问题,因为你改变的是你刚刚创建的一个新的对象:

jsx
const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);

事实上,它完全等同于下面这种写法:

jsx
setPosition({
  x: e.clientX,
  y: e.clientY
});

只有当你改变已经处于 state 中的 现有 对象时,mutation 才会成为问题。而修改一个你刚刚创建的对象就不会出现任何问题,因为 还没有其他的代码引用它。改变它并不会意外地影响到依赖它的东西。这叫做“局部 mutation”。你甚至可以 在渲染的过程中 进行“局部 mutation”的操作。这种操作既便捷又没有任何问题!

使用展开语法复制对象

在之前的例子中,始终会根据当前指针的位置创建出一个新的 position 对象。但是通常,你会希望把 现有 数据作为你所创建的新对象的一部分。例如,你可能只想要更新表单中的一个字段,其他的字段仍然使用之前的值。

下面的代码中,输入框并不会正常运行,因为 onChange 直接修改了 state :

jsx
import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleFirstNameChange(e) {
    person.firstName = e.target.value;
  }

  function handleLastNameChange(e) {
    person.lastName = e.target.value;
  }

  function handleEmailChange(e) {
    person.email = e.target.value;
  }

  return (
    <>
      <label>
        First name:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        Email:
        <input
          value={person.email}
          onChange={handleEmailChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

例如,下面这行代码修改了上一次渲染中的 state:

jsx
person.firstName = e.target.value;

想要实现你的需求,最可靠的办法就是创建一个新的对象并将它传递给 setPerson。但是在这里,你还需要 把当前的数据复制到新对象中,因为你只改变了其中一个字段:

jsx
setPerson({
  firstName: e.target.value, // 从 input 中获取新的 first name
  lastName: person.lastName,
  email: person.email
});

你可以使用 ... 对象展开 语法,这样你就不需要单独复制每个属性。

jsx
setPerson({
  ...person, // 复制上一个 person 中的所有字段
  firstName: e.target.value // 但是覆盖 firstName 字段
});

现在表单可以正常运行了!

可以看到,你并没有为每个输入框单独声明一个 state。对于大型表单,将所有数据都存放在同一个对象中是非常方便的——前提是你能够正确地更新它!

jsx
import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleFirstNameChange(e) {
    setPerson({
      ...person,
      firstName: e.target.value
    });
  }

  function handleLastNameChange(e) {
    setPerson({
      ...person,
      lastName: e.target.value
    });
  }

  function handleEmailChange(e) {
    setPerson({
      ...person,
      email: e.target.value
    });
  }

  return (
    <>
      <label>
        First name:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        Email:
        <input
          value={person.email}
          onChange={handleEmailChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

请注意 ... 展开语法本质是是“浅拷贝”——它只会复制一层。这使得它的执行速度很快,但是也意味着当你想要更新一个嵌套属性时,你必须得多次使用展开语法。

重要

使用一个事件处理函数来更新多个字段

你也可以在对象的定义中使用 [] 括号来实现属性的动态命名。下面是同一个例子,但它使用了一个事件处理函数而不是三个:

jsx
import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleChange(e) {
    setPerson({
      ...person,
      [e.target.name]: e.target.value
    });
  }

  return (
    <>
      <label>
        First name:
        <input
          name="firstName"
          value={person.firstName}
          onChange={handleChange}
        />
      </label>
      <label>
        Last name:
        <input
          name="lastName"
          value={person.lastName}
          onChange={handleChange}
        />
      </label>
      <label>
        Email:
        <input
          name="email"
          value={person.email}
          onChange={handleChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

在这里,e.target.name 引用了 <input> 这个 DOM 元素的 name 属性。

更新一个嵌套对象

考虑下面这种结构的嵌套对象:

jsx
const [person, setPerson] = useState({
  name: 'Niki de Saint Phalle',
  artwork: {
    title: 'Blue Nana',
    city: 'Hamburg',
    image: 'https://react.dev/images/docs/scientists/Sd1AgUOm.jpg',
  }
});

如果你想要更新 person.artwork.city 的值,用 mutation 来实现的方法非常容易理解:

jsx
person.artwork.city = 'New Delhi';

但是在 React 中,你需要将 state 视为不可变的!为了修改 city 的值,你首先需要创建一个新的 artwork 对象(其中预先填充了上一个 artwork 对象中的数据),然后创建一个新的 person 对象,并使得其中的 artwork 属性指向新创建的 artwork 对象:

jsx
const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);

或者,写成一个函数调用:

jsx
setPerson({
  ...person, // 复制其它字段的数据
  artwork: { // 替换 artwork 字段
    ...person.artwork, // 复制之前 person.artwork 中的数据
    city: 'New Delhi' // 但是将 city 的值替换为 New Delhi!
  }
});

这虽然看起来有点冗长,但对于很多情况都能有效地解决问题:

jsx
import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    name: 'Niki de Saint Phalle',
    artwork: {
      title: 'Blue Nana',
      city: 'Hamburg',
      image: 'https://react.dev/images/docs/scientists/Sd1AgUOm.jpg',
    }
  });

  function handleNameChange(e) {
    setPerson({
      ...person,
      name: e.target.value
    });
  }

  function handleTitleChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        title: e.target.value
      }
    });
  }

  function handleCityChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        city: e.target.value
      }
    });
  }

  function handleImageChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        image: e.target.value
      }
    });
  }

  return (
    <>
      <label>
        Name:
        <input
          value={person.name}
          onChange={handleNameChange}
        />
      </label>
      <label>
        Title:
        <input
          value={person.artwork.title}
          onChange={handleTitleChange}
        />
      </label>
      <label>
        City:
        <input
          value={person.artwork.city}
          onChange={handleCityChange}
        />
      </label>
      <label>
        Image:
        <input
          value={person.artwork.image}
          onChange={handleImageChange}
        />
      </label>
      <p>
        <i>{person.artwork.title}</i>
        {' by '}
        {person.name}
        <br />
        (located in {person.artwork.city})
      </p>
      <img
        src={person.artwork.image}
        alt={person.artwork.title}
      />
    </>
  );
}

重要

对象并非是真正嵌套的

下面这个对象从代码上来看是“嵌套”的:

jsx
let obj = {
  name: 'Niki de Saint Phalle',
  artwork: {
    title: 'Blue Nana',
    city: 'Hamburg',
    image: 'https://react.dev/images/docs/scientists/Sd1AgUOm.jpg',
  }
};

然而,当我们思考对象的特性时,“嵌套”并不是一个非常准确的方式。当这段代码运行的时候,不存在“嵌套”的对象。你实际上看到的是两个不同的对象:

jsx
let obj1 = {
  title: 'Blue Nana',
  city: 'Hamburg',
  image: 'https://react.dev/images/docs/scientists/Sd1AgUOm.jpg',
};

let obj2 = {
  name: 'Niki de Saint Phalle',
  artwork: obj1
};

对象 obj1 并不处于 obj2 的“内部”。例如,下面的代码中,obj3 中的属性也可以指向 obj1

jsx
let obj1 = {
  title: 'Blue Nana',
  city: 'Hamburg',
  image: 'https://react.dev/images/docs/scientists/Sd1AgUOm.jpg',
};

let obj2 = {
  name: 'Niki de Saint Phalle',
  artwork: obj1
};

let obj3 = {
  name: 'Copycat',
  artwork: obj1
};

如果你直接修改 obj3.artwork.city,就会同时影响 obj2.artwork.cityobj1.city。这是因为 obj3.artworkobj2.artworkobj1 都指向同一个对象。当你用“嵌套”的方式看待对象时,很难看出这一点。相反,它们是相互独立的对象,只不过是用属性“指向”彼此而已。

备注

为什么在 React 中不推荐直接修改 state?

有以下几个原因:

  • 调试:如果你使用 console.log 并且不直接修改 state,你之前日志中的 state 的值就不会被新的 state 变化所影响。这样你就可以清楚地看到两次渲染之间 state 的值发生了什么变化
  • 优化:React 常见的 优化策略 依赖于如果之前的 props 或者 state 的值和下一次相同就跳过渲染。如果你从未直接修改 state ,那么你就可以很快看到 state 是否发生了变化。如果 prevObj === obj,那么你就可以肯定这个对象内部并没有发生改变。
  • 新功能:我们正在构建的 React 的新功能依赖于 state 被 像快照一样看待 的理念。如果你直接修改 state 的历史版本,可能会影响你使用这些新功能。
  • 需求变更:有些应用功能在不出现任何修改的情况下会更容易实现,比如实现撤销/恢复、展示修改历史,或是允许用户把表单重置成某个之前的值。这是因为你可以把 state 之前的拷贝保存到内存中,并适时对其进行再次使用。如果一开始就用了直接修改 state 的方式,那么后面要实现这样的功能就会变得非常困难。
  • 更简单的实现:React 并不依赖于 mutation ,所以你不需要对对象进行任何特殊操作。它不需要像很多“响应式”的解决方案一样去劫持对象的属性、总是用代理把对象包裹起来,或者在初始化时做其他工作。这也是 React 允许你把任何对象存放在 state 中——不管对象有多大——而不会造成有任何额外的性能或正确性问题的原因。

在实践中,你经常可以“侥幸”直接修改 state 而不出现什么问题,但是我们强烈建议你不要这样做,这样你就可以使用我们秉承着这种理念开发的 React 新功能。未来的贡献者甚至是你未来的自己都会感谢你的!

更新 state 中的数组

数组是另外一种可以存储在 state 中的 JavaScript 对象,它虽然是可变的,但是却应该被视为不可变。同对象一样,当你想要更新存储于 state 中的数组时,你需要创建一个新的数组(或者创建一份已有数组的拷贝值),并使用新数组设置 state。

在没有 mutation 的前提下更新数组

在 JavaScript 中,数组只是另一种对象。同对象一样你需要将 React state 中的数组视为只读的。这意味着你不应该使用类似于 arr[0] = 'bird' 这样的方式来重新分配数组中的元素,也不应该使用会直接修改原始数组的方法,例如 push()pop()

相反,每次要更新一个数组时,你需要把一个的数组传入 state 的 setting 方法中。为此,你可以通过使用像 filter()map() 这样不会直接修改原始值的方法,从原始数组生成一个新的数组。然后你就可以将 state 设置为这个新生成的数组。

下面是常见数组操作的参考表。当你操作 React state 中的数组时,你需要避免使用左列的方法,而首选右列的方法:

避免使用 (会改变原始数组)推荐使用 (会返回一个新数组)
添加元素pushunshiftconcat[...arr] 展开语法(例子
删除元素popshiftsplicefilterslice例子
替换元素splicearr[i] = ... 赋值map例子
排序reversesort先将数组复制一份(例子

或者,你可以使用 Immer ,这样你便可以使用表格中的所有方法了。

不幸的是,虽然 slicesplice 的名字相似,但作用却迥然不同:

  • slice 让你可以拷贝数组或是数组的一部分。
  • splice 会直接修改 原始数组(插入或者删除元素)。

在 React 中,更多情况下你会使用 slice(没有 p !),因为你不想改变 state 中的对象或数组。更新对象这一章节解释了什么是 mutation,以及为什么不推荐在 state 里这样做。

向数组中添加元素

push() 会直接修改原始数组,而你不希望这样:

jsx
import { useState } from 'react';

let nextId = 0;

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState([]);

  return (
    <>
      <h1>振奋人心的雕塑家们:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        artists.push({
          id: nextId++,
          name: name,
        });
      }}>添加</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

相反,你应该创建一个 数组,其包含了原始数组的所有元素 以及 一个在末尾的新元素。这可以通过很多种方法实现,最简单的一种就是使用 ... 数组展开 语法:

jsx
setArtists( // 替换 state
  [ // 是通过传入一个新数组实现的
    ...artists, // 新数组包含原数组的所有元素
    { id: nextId++, name: name } // 并在末尾添加了一个新的元素
  ]
);

现在代码可以正常运行了:

jsx
import { useState } from 'react';

let nextId = 0;

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState([]);

  return (
    <>
      <h1>振奋人心的雕塑家们:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        setArtists([
          ...artists,
          { id: nextId++, name: name }
        ]);
      }}>添加</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

数组展开运算符还允许你把新添加的元素放在原始的 ...artists 之前:

jsx
setArtists([
  { id: nextId++, name: name },
  ...artists // 将原数组中的元素放在末尾
]);

这样一来,展开操作就可以完成 push()unshift() 的工作,将新元素添加到数组的末尾和开头。你可以在上面的 sandbox 中尝试一下!

从数组中删除元素

从数组中删除一个元素最简单的方法就是将它过滤出去。换句话说,你需要生成一个不包含该元素的新数组。这可以通过 filter 方法实现,例如:

jsx
import { useState } from 'react';

let initialArtists = [
  { id: 0, name: 'Marta Colvin Andrade' },
  { id: 1, name: 'Lamidi Olonade Fakeye'},
  { id: 2, name: 'Louise Nevelson'},
];

export default function List() {
  const [artists, setArtists] = useState(
    initialArtists
  );

  return (
    <>
      <h1>振奋人心的雕塑家们:</h1>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>
            {artist.name}{' '}
            <button onClick={() => {
              setArtists(
                artists.filter(a =>
                  a.id !== artist.id
                )
              );
            }}>
              删除
            </button>
          </li>
        ))}
      </ul>
    </>
  );
}

点击“删除”按钮几次,并且查看按钮处理点击事件的代码。

jsx
setArtists(
  artists.filter(a => a.id !== artist.id)
);

这里,artists.filter(s => s.id !== artist.id) 表示“创建一个新的数组,该数组由那些 ID 与 artists.id 不同的 artists 组成”。换句话说,每个 artist 的“删除”按钮会把 那一个 artist 从原始数组中过滤掉,并使用过滤后的数组再次进行渲染。注意,filter 并不会改变原始数组。

转换数组

如果你想改变数组中的某些或全部元素,你可以用 map() 创建一个数组。你传入 map 的函数决定了要根据每个元素的值或索引(或二者都要)对元素做何处理。

在下面的例子中,一个数组记录了两个圆形和一个正方形的坐标。当你点击按钮时,仅有两个圆形会向下移动 100 像素。这是通过使用 map() 生成一个新数组实现的。

jsx
import { useState } from 'react';

let initialShapes = [
  { id: 0, type: 'circle', x: 50, y: 100 },
  { id: 1, type: 'square', x: 150, y: 100 },
  { id: 2, type: 'circle', x: 250, y: 100 },
];

export default function ShapeEditor() {
  const [shapes, setShapes] = useState(
    initialShapes
  );

  function handleClick() {
    const nextShapes = shapes.map(shape => {
      if (shape.type === 'square') {
        // 不作改变
        return shape;
      } else {
        // 返回一个新的圆形,位置在下方 50px 处
        return {
          ...shape,
          y: shape.y + 50,
        };
      }
    });
    // 使用新的数组进行重渲染
    setShapes(nextShapes);
  }

  return (
    <>
      <button onClick={handleClick}>
        所有圆形向下移动!
      </button>
      {shapes.map(shape => (
        <div
          key={shape.id}
          style={{
          background: 'purple',
          position: 'absolute',
          left: shape.x,
          top: shape.y,
          borderRadius:
            shape.type === 'circle'
              ? '50%' : '',
          width: 20,
          height: 20,
        }} />
      ))}
    </>
  );
}

替换数组中的元素

想要替换数组中一个或多个元素是非常常见的。类似 arr[0] = 'bird' 这样的赋值语句会直接修改原始数组,所以在这种情况下,你也应该使用 map

要替换一个元素,请使用 map 创建一个新数组。在你的 map 回调里,第二个参数是元素的索引。使用索引来判断最终是返回原始的元素(即回调的第一个参数)还是替换成其他值:

jsx
import { useState } from 'react';

let initialCounters = [
  0, 0, 0
];

export default function CounterList() {
  const [counters, setCounters] = useState(
    initialCounters
  );

  function handleIncrementClick(index) {
    const nextCounters = counters.map((c, i) => {
      if (i === index) {
        // 递增被点击的计数器数值
        return c + 1;
      } else {
        // 其余部分不发生变化
        return c;
      }
    });
    setCounters(nextCounters);
  }

  return (
    <ul>
      {counters.map((counter, i) => (
        <li key={i}>
          {counter}
          <button onClick={() => {
            handleIncrementClick(i);
          }}>+1</button>
        </li>
      ))}
    </ul>
  );
}

向数组中插入元素

有时,你也许想向数组特定位置插入一个元素,这个位置既不在数组开头,也不在末尾。为此,你可以将数组展开运算符 ...slice() 方法一起使用。slice() 方法让你从数组中切出“一片”。为了将元素插入数组,你需要先展开原数组在插入点之前的切片,然后插入新元素,最后展开原数组中剩下的部分。

下面的例子中,插入按钮总是会将元素插入到数组中索引为 1 的位置。

jsx
import { useState } from 'react';

let nextId = 3;
const initialArtists = [
  { id: 0, name: 'Marta Colvin Andrade' },
  { id: 1, name: 'Lamidi Olonade Fakeye'},
  { id: 2, name: 'Louise Nevelson'},
];

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState(
    initialArtists
  );

  function handleClick() {
    const insertAt = 1; // 可能是任何索引
    const nextArtists = [
      // 插入点之前的元素:
      ...artists.slice(0, insertAt),
      // 新的元素:
      { id: nextId++, name: name },
      // 插入点之后的元素:
      ...artists.slice(insertAt)
    ];
    setArtists(nextArtists);
    setName('');
  }

  return (
    <>
      <h1>振奋人心的雕塑家们:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={handleClick}>
        插入
      </button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

其他改变数组的情况

总会有一些事,是你仅仅依靠展开运算符和 map() 或者 filter() 等不会直接修改原值的方法所无法做到的。例如,你可能想翻转数组,或是对数组排序。而 JavaScript 中的 reverse()sort() 方法会改变原数组,所以你无法直接使用它们。

然而,你可以先拷贝这个数组,再改变这个拷贝后的值。

例如:

jsx
import { useState } from 'react';

const initialList = [
  { id: 0, title: 'Big Bellies' },
  { id: 1, title: 'Lunar Landscape' },
  { id: 2, title: 'Terracotta Army' },
];

export default function List() {
  const [list, setList] = useState(initialList);

  function handleClick() {
    const nextList = [...list];
    nextList.reverse();
    setList(nextList);
  }

  return (
    <>
      <button onClick={handleClick}>
        翻转
      </button>
      <ul>
        {list.map(artwork => (
          <li key={artwork.id}>{artwork.title}</li>
        ))}
      </ul>
    </>
  );
}

在这段代码中,你先使用 [...list] 展开运算符创建了一份数组的拷贝值。当你有了这个拷贝值后,你就可以使用像 nextList.reverse()nextList.sort() 这样直接修改原数组的方法。你甚至可以通过 nextList[0] = "something" 这样的方式对数组中的特定元素进行赋值。

然而,即使你拷贝了数组,你还是不能直接修改其内部的元素。这是因为数组的拷贝是浅拷贝——新的数组中依然保留了与原始数组相同的元素。因此,如果你修改了拷贝数组内部的某个对象,其实你正在直接修改当前的 state。举个例子,像下面的代码就会带来问题。

jsx
const nextList = [...list];
nextList[0].seen = true; // 问题:直接修改了 list[0] 的值
setList(nextList);

虽然 nextListlist 是两个不同的数组,nextList[0]list[0] 却指向了同一个对象。因此,通过改变 nextList[0].seenlist[0].seen 的值也被改变了。这是一种 state 的 mutation 操作,你应该避免这么做!你可以用类似于 更新嵌套的 JavaScript 对象 的方式解决这个问题——拷贝想要修改的特定元素,而不是直接修改它。下面是具体的操作。

更新数组内部的对象

对象并不是 真的 位于数组“内部”。可能他们在代码中看起来像是在数组“内部”,但其实数组中的每个对象都是这个数组“指向”的一个存储于其它位置的值。这就是当你在处理类似 list[0] 这样的嵌套字段时需要格外小心的原因。其他人的艺术品清单可能指向了数组的同一个元素!

当你更新一个嵌套的 state 时,你需要从想要更新的地方创建拷贝值,一直这样,直到顶层。 让我们看一下这该怎么做。

在下面的例子中,两个不同的艺术品清单有着相同的初始 state。他们本应该互不影响,但是因为一次 mutation,他们的 state 被意外地共享了,勾选一个清单中的事项会影响另外一个清单:

jsx
import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
];

export default function BucketList() {
  const [myList, setMyList] = useState(initialList);
  const [yourList, setYourList] = useState(
    initialList
  );

  function handleToggleMyList(artworkId, nextSeen) {
    const myNextList = [...myList];
    const artwork = myNextList.find(
      a => a.id === artworkId
    );
    artwork.seen = nextSeen;
    setMyList(myNextList);
  }

  function handleToggleYourList(artworkId, nextSeen) {
    const yourNextList = [...yourList];
    const artwork = yourNextList.find(
      a => a.id === artworkId
    );
    artwork.seen = nextSeen;
    setYourList(yourNextList);
  }

  return (
    <>
      <h1>艺术愿望清单</h1>
      <h2>我想看的艺术清单:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>你想看的艺术清单:</h2>
      <ItemList
        artworks={yourList}
        onToggle={handleToggleYourList} />
    </>
  );
}

function ItemList({ artworks, onToggle }) {
  return (
    <ul>
      {artworks.map(artwork => (
        <li key={artwork.id}>
          <label>
            <input
              type="checkbox"
              checked={artwork.seen}
              onChange={e => {
                onToggle(
                  artwork.id,
                  e.target.checked
                );
              }}
            />
            {artwork.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

问题出在下面这段代码中:

jsx
const myNextList = [...myList];
const artwork = myNextList.find(a => a.id === artworkId);
artwork.seen = nextSeen; // 问题:直接修改了已有的元素
setMyList(myNextList);

虽然 myNextList 这个数组是新的,但是其内部的元素本身与原数组 myList 是相同的。因此,修改 artwork.seen,其实是在修改原始的 artwork 对象。而这个 artwork 对象也被 yourList 使用,这样就带来了 bug。这样的 bug 可能难以想到,但好在如果你避免直接修改 state,它们就会消失。

你可以使用 map 在没有 mutation 的前提下将一个旧的元素替换成更新的版本。

jsx
setMyList(myList.map(artwork => {
  if (artwork.id === artworkId) {
    // 创建包含变更的*新*对象
    return { ...artwork, seen: nextSeen };
  } else {
    // 没有变更
    return artwork;
  }
}));

此处的 ... 是一个对象展开语法,被用来创建一个对象的拷贝.

通过这种方式,没有任何现有的 state 中的元素会被改变,bug 也就被修复了。

jsx
import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
];

export default function BucketList() {
  const [myList, setMyList] = useState(initialList);
  const [yourList, setYourList] = useState(
    initialList
  );

  function handleToggleMyList(artworkId, nextSeen) {
    setMyList(myList.map(artwork => {
      if (artwork.id === artworkId) {
        // 创建包含变更的*新*对象
        return { ...artwork, seen: nextSeen };
      } else {
        // 没有变更
        return artwork;
      }
    }));
  }

  function handleToggleYourList(artworkId, nextSeen) {
    setYourList(yourList.map(artwork => {
      if (artwork.id === artworkId) {
        // 创建包含变更的*新*对象
        return { ...artwork, seen: nextSeen };
      } else {
        // 没有变更
        return artwork;
      }
    }));
  }

  return (
    <>
      <h1>艺术愿望清单</h1>
      <h2>我想看的艺术清单:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>你想看的艺术清单:</h2>
      <ItemList
        artworks={yourList}
        onToggle={handleToggleYourList} />
    </>
  );
}

function ItemList({ artworks, onToggle }) {
  return (
    <ul>
      {artworks.map(artwork => (
        <li key={artwork.id}>
          <label>
            <input
              type="checkbox"
              checked={artwork.seen}
              onChange={e => {
                onToggle(
                  artwork.id,
                  e.target.checked
                );
              }}
            />
            {artwork.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

通常来讲,你应该只直接修改你刚刚创建的对象。如果你正在插入一个的 artwork,你可以修改它,但是如果你想要改变的是 state 中已经存在的东西,你就需要先拷贝一份了。

评论

评论加载中……