更新状态中的对象

状态可以保存任何类型的 JavaScript 值,包括对象。但是,你不应该直接更改保存在 React 状态中的对象。相反,当你想更新一个对象时,你需要创建一个新对象(或复制一个现有对象),然后将状态设置为使用该副本。

你将学习

  • 如何在 React 状态中正确更新对象
  • 如何在不改变嵌套对象的情况下更新它
  • 什么是不可变性,以及如何不破坏它
  • 如何使用 Immer 减少对象复制的重复性

什么是 mutation(变更)?

你可以在状态中存储任何类型的 JavaScript 值。

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

到目前为止,你一直在使用数字、字符串和布尔值。这些 JavaScript 值是“不可变的”,这意味着不可更改或“只读”。你可以触发重新渲染来*替换*一个值

setX(5);

x 状态从 0 变为 5,但*数字 0 本身*没有改变。在 JavaScript 中,无法对内置的原始值(如数字、字符串和布尔值)进行任何更改。

现在考虑一个状态中的对象

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

从技术上讲,可以更改*对象本身*的内容。这被称为 mutation(变更):

position.x = 5;

但是,尽管 React 状态中的对象在技术上是可变的,但你应该将它们*视为*不可变的,就像数字、布尔值和字符串一样。你不应该对它们进行变更,而应该始终替换它们。

将状态视为只读

换句话说,你应该*将放入状态中的任何 JavaScript 对象视为只读*。

此示例在状态中保存一个对象,以表示当前指针位置。当你触摸或将光标移动到预览区域上时,红点应该会移动。但是点停留在初始位置

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>
  );
}

问题出在这段代码上。

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

此代码修改了分配给 position 的对象,该对象来自上一次渲染。但是,如果不使用状态设置函数,React 就不知道对象已更改。所以 React 没有做出任何反应。这就像在你已经吃完饭之后再去改变订单一样。虽然在某些情况下修改状态可以工作,但我们不建议这样做。你应该将你在渲染中访问到的状态值视为只读的。

为了在这种情况下真正触发重新渲染,请*创建一个*新对象并将其传递给状态设置函数*:

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

使用 setPosition,你是在告诉 React

  • 用这个新对象替换 position
  • 并再次渲染此组件

请注意,当你触摸或将鼠标悬停在预览区域上时,红点现在如何跟随你的指针移动

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>
  );
}

深入探究

局部变更是可以的

像这样的代码是一个问题,因为它修改了状态中的*现有*对象

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

但是像这样的代码是 完全没问题 的,因为你是在修改一个你 刚刚创建 的全新对象。

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

事实上,它完全等同于这样写:

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

只有当你更改状态中 已有的 对象时,修改才会成为问题。修改你刚刚创建的对象是可以的,因为 还没有其他代码引用它。更改它不会意外地影响依赖于它的东西。这被称为“局部修改”。你甚至可以在 渲染时进行局部修改。非常方便而且完全没问题!

使用展开语法复制对象

在前面的例子中,position 对象总是从当前光标位置全新创建的。但通常情况下,你会希望将 现有 数据包含在你正在创建的新对象中。例如,你可能只想更新表单中的 一个 字段,但保留所有其他字段的先前值。

这些输入字段不起作用,因为 onChange 处理程序修改了状态。

import { useState } from 'react';

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

  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>
    </>
  );
}

例如,这行代码修改了过去渲染中的状态。

person.firstName = e.target.value;

获得你想要的行为的可靠方法是创建一个新对象并将其传递给 setPerson。但是在这里,你还希望 将现有数据复制到其中,因为只有一个字段发生了变化。

setPerson({
firstName: e.target.value, // New first name from the input
lastName: person.lastName,
email: person.email
});

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

setPerson({
...person, // Copy the old fields
firstName: e.target.value // But override this one
});

现在表单可以正常工作了!

请注意,你没有为每个输入字段声明单独的状态变量。对于大型表单来说,将所有数据分组在一个对象中非常方便,只要你正确地更新它即可!

import { useState } from 'react';

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

  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>
    </>
  );
}

注意,... 展开语法是“浅”的,它只复制一层深度的内容。这使得它速度很快,但也意味着如果你想更新一个嵌套属性,你将不得不多次使用它。

深入探究

为多个字段使用单个事件处理程序

你也可以在对象定义中使用 [] 花括号来指定具有动态名称的属性。下面是相同的例子,但使用了一个事件处理程序而不是三个不同的事件处理程序。

import { useState } from 'react';

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

  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 属性。

更新嵌套对象

考虑一个像这样的嵌套对象结构:

const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});

如果你想更新 person.artwork.city,很清楚如何使用修改来做到这一点:

person.artwork.city = 'New Delhi';

但是在 React 中,你要将状态视为不可变的!为了更改 city,你首先需要生成新的 artwork 对象(预先填充了来自先前对象的数据),然后生成指向新的 artwork 的新的 person 对象:

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

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

setPerson({
...person, // Copy other fields
artwork: { // but replace the artwork
...person.artwork, // with the same one
city: 'New Delhi' // but in New Delhi!
}
});

这有点冗长,但在很多情况下都能很好地工作。

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://i.imgur.com/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}
      />
    </>
  );
}

深入探究

对象不是真正嵌套的

像这样的对象在代码中看起来是“嵌套”的:

let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};

然而,“嵌套”是一种不准确的思考对象行为方式。当代码执行时,没有“嵌套”对象这样的东西。你实际上是在看两个不同的对象:

let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};

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

obj1 对象不在 obj2“内部”。例如,obj3 也可以“指向”obj1

let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/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 是同一个对象。当你把对象想象成“嵌套”的,就很难看到这一点。相反,它们是独立的对象,通过属性相互“指向”。

使用 Immer 编写简洁的更新逻辑

如果你的状态是深度嵌套的,你可能要考虑 将其扁平化。但是,如果你不想改变你的状态结构,你可能更喜欢使用嵌套展开的快捷方式。Immer 是一个流行的库,它可以让你使用方便但会改变数据的语法编写代码,并为你处理生成副本的工作。使用 Immer,你编写的代码看起来就像你在“打破规则”并修改了一个对象:

updatePerson(draft => {
draft.artwork.city = 'Lagos';
});

但与常规修改不同,它不会覆盖过去的状态!

深入探究

Immer 是如何工作的?

Immer 提供的 draft 是一种特殊类型的对象,称为 代理,它会“记录”你对它所做的操作。这就是为什么你可以随心所欲地自由修改它!在底层,Immer 会找出 draft 中哪些部分发生了变化,并生成一个包含你所做编辑的全新对象。

尝试 Immer

  1. 运行 npm install use-immer 将 Immer 作为依赖项添加
  2. 然后将 import { useState } from 'react' 替换为 import { useImmer } from 'use-immer'

以下是转换为 Immer 的上述示例

{
  "dependencies": {
    "immer": "1.7.3",
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "use-immer": "0.5.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "devDependencies": {}
}

请注意,事件处理程序变得更加简洁。你可以在单个组件中根据需要混合搭配使用 useStateuseImmer。Immer 是保持更新处理程序简洁的好方法,尤其是当你的状态中存在嵌套,并且复制对象会导致代码重复时。

深入探究

有几个原因

  • 调试:如果你使用 console.log 并且不修改状态,那么你之前的日志就不会被最近的状态更改覆盖。所以你可以清楚地看到状态在两次渲染之间是如何变化的。
  • 优化:常见的 React 优化策略 依赖于在之前的 props 或状态与下一次相同的情况下跳过工作。如果你从不修改状态,那么检查是否有任何更改就非常快。如果 prevObj === obj,你可以确定它内部没有任何变化。
  • 新功能:我们正在构建的新 React 功能依赖于将状态 视为快照。 如果你正在修改过去版本的状态,那可能会阻止你使用新功能。
  • 需求变更:一些应用程序功能,例如实现撤消/重做、显示更改历史记录或让用户将表单重置为之前的版本,在没有任何修改的情况下更容易实现。这是因为你可以在内存中保留过去的状态副本,并在适当的时候重用它们。如果你从可变方法开始,那么像这样的功能在以后可能很难添加。
  • 更简单的实现:因为 React 不依赖于修改,所以它不需要对你的对象做任何特殊处理。它不需要劫持它们的属性,也不需要总是将它们包装在代理中,或者像许多“反应式”解决方案那样在初始化时进行其他工作。这也是为什么 React 允许你将任何对象放入状态中(无论大小),而不会产生额外的性能或正确性陷阱。

在实践中,你经常可以在 React 中“侥幸”修改状态,但我们强烈建议你不要这样做,这样你就可以使用我们基于这种方法开发的新 React 功能。未来的贡献者,甚至是你自己,都会感谢你!

总结

  • 将 React 中的所有状态都视为不可变的。
  • 当你在状态中存储对象时,修改它们不会触发渲染,并且会更改之前渲染“快照”中的状态。
  • 不要修改对象,而是创建它的一个版本,并通过将状态设置为新版本来触发重新渲染。
  • 你可以使用 {...obj, something: 'newValue'} 对象扩展语法来创建对象的副本。
  • 扩展语法是浅层的:它只复制一层深度。
  • 要更新嵌套对象,你需要从要更新的位置一直创建副本。
  • 要减少重复的复制代码,请使用 Immer。

挑战 1 3:
修复不正确的状态更新

此表单有一些错误。多次点击增加分数的按钮。你会发现它没有增加。然后编辑名字,你会发现分数突然“赶上”了你的更改。最后,编辑姓氏,你会发现分数完全消失了。

你的任务是修复所有这些错误。在你修复它们时,解释一下它们各自发生的原因。

import { useState } from 'react';

export default function Scoreboard() {
  const [player, setPlayer] = useState({
    firstName: 'Ranjani',
    lastName: 'Shettar',
    score: 10,
  });

  function handlePlusClick() {
    player.score++;
  }

  function handleFirstNameChange(e) {
    setPlayer({
      ...player,
      firstName: e.target.value,
    });
  }

  function handleLastNameChange(e) {
    setPlayer({
      lastName: e.target.value
    });
  }

  return (
    <>
      <label>
        Score: <b>{player.score}</b>
        {' '}
        <button onClick={handlePlusClick}>
          +1
        </button>
      </label>
      <label>
        First name:
        <input
          value={player.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={player.lastName}
          onChange={handleLastNameChange}
        />
      </label>
    </>
  );
}