更新状态中的对象

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

You will learn

  • 如何在 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>
  );
}

Deep Dive

局部 mutation 是可以的

像这样的代码会有问题,因为它修改了状态中已有的对象:

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

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

使用展开语法复制对象

在前面的示例中,position 对象总是根据当前光标位置重新创建。但很多时候,你会希望把已有数据作为你正在创建的新对象的一部分。例如,你可能只想更新表单中的一个字段,同时保留其他所有字段的先前值。

这些输入框无法工作,因为 onChange 处理函数修改了状态:

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>
        名字:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        姓氏:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        电子邮件:
        <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, // 输入框中的新名字
lastName: person.lastName,
email: person.email
});

你可以使用 ... 对象展开语法,这样就不必分别复制每个属性。

setPerson({
...person, // 复制旧字段
firstName: e.target.value // 但覆盖这一项
});

现在这个表单可以工作了!

注意你并没有为每个输入框都声明一个单独的状态变量。对于大型表单,把所有数据分组存放在一个对象里非常方便——只要你正确更新它就行!

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>
        名字:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        姓氏:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        电子邮件:
        <input
          value={person.email}
          onChange={handleEmailChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

请注意,... 展开语法是“浅层”的——它只会复制一层内容。这使它很快,但也意味着如果你想更新嵌套属性,就必须多次使用它。

Deep Dive

对多个字段使用单个事件处理函数

你也可以在对象定义中使用 [] 大括号来指定一个动态名称的属性。下面是同一个示例,但用一个事件处理函数代替了三个不同的处理函数:

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>
        名字:
        <input
          name="firstName"
          value={person.firstName}
          onChange={handleChange}
        />
      </label>
      <label>
        姓氏:
        <input
          name="lastName"
          value={person.lastName}
          onChange={handleChange}
        />
      </label>
      <label>
        电子邮件:
        <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://react.dev/images/docs/scientists/Sd1AgUOm.jpg',
}
});

如果你想更新 person.artwork.city,用 mutation 的方式很容易做到:

person.artwork.city = 'New Delhi';

但在 React 中,你应该把 state 当作不可变的!为了改变 city,你首先需要生成新的 artwork 对象(预先填入前一个对象中的数据),然后再生成指向新 artwork 的新的 person 对象:

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

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

setPerson({
...person, // 复制其他字段
artwork: { // 但替换 artwork
...person.artwork, // 使用相同的那个
city: 'New Delhi' // 但城市改为 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://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>
        名字:
        <input
          value={person.name}
          onChange={handleNameChange}
        />
      </label>
      <label>
        标题:
        <input
          value={person.artwork.title}
          onChange={handleTitleChange}
        />
      </label>
      <label>
        城市:
        <input
          value={person.artwork.city}
          onChange={handleCityChange}
        />
      </label>
      <label>
        图片:
        <input
          value={person.artwork.image}
          onChange={handleImageChange}
        />
      </label>
      <p>
        <i>{person.artwork.title}</i>
        {' 作者 '}
        {person.name}
        <br />
        (位于 {person.artwork.city}</p>
      <img
        src={person.artwork.image}
        alt={person.artwork.title}
      />
    </>
  );
}

Deep Dive

对象其实并不是真的嵌套

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

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

然而,“嵌套”并不是理解对象行为的准确方式。当代码执行时,并不存在所谓“嵌套”的对象。你实际上看到的是两个不同的对象:

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

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 是同一个对象。当你把对象想成“嵌套”的时候,这一点很难看出来。实际上,它们是彼此独立的对象,只是通过属性“指向”彼此。

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

如果你的 state 嵌套很深,你可以考虑把它扁平化。但如果你不想改变 state 结构,你可能会更喜欢一个替代深层展开语法的快捷方式。Immer 是一个很受欢迎的库,它允许你使用方便但看起来像 mutation 的语法来编写代码,并负责为你生成副本。使用 Immer 时,你写出来的代码看起来像是在“违反规则”并修改对象:

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

但和普通的 mutation 不同,它不会覆盖过去的 state!

Deep Dive

Immer 是如何工作的?

Immer 提供的 draft 是一种特殊类型的对象,叫做 Proxy,它会“记录”你对它所做的操作。这就是为什么你可以随心所欲地修改它!在内部,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 是保持更新处理函数简洁的绝佳方式,尤其是在 state 有嵌套、并且对象复制会导致重复代码时。

Deep Dive

原因有几个:

  • 调试: 如果你使用 console.log 且不修改 state,那么过去的日志不会被更新的 state 变化覆盖。这样你就可以清楚地看到两次渲染之间 state 是如何变化的。
  • 优化: 常见的 React 优化策略 依赖于在前后 props 或 state 相同时跳过工作。如果你从不修改 state,就很容易检查是否发生了变化。如果 prevObj === obj,你就能确定它内部没有任何东西发生变化。
  • 新特性: 我们正在构建的 React 新特性依赖于将 state 当作快照来处理。如果你在修改 state 的历史版本,这可能会阻止你使用这些新特性。
  • 需求变化: 一些应用功能,比如实现撤销/重做、显示变更历史,或者让用户把表单重置为更早的值,在没有 mutation 的情况下更容易实现。这是因为你可以将过去的 state 副本保存在内存中,并在合适的时候重用它们。如果你一开始采用的是修改式写法,那么之后再添加这类功能会很困难。
  • 实现更简单: 因为 React 不依赖 mutation,它不需要对你的对象做任何特殊处理。它不需要劫持它们的属性、总是把它们包装成 Proxy,或者像许多“响应式”方案那样在初始化时做其他工作。这也是为什么 React 允许你把任何对象放入 state——不管它有多大——而不会带来额外的性能或正确性陷阱。

在实践中,你常常可以在 React 中“侥幸”直接修改 state,但我们强烈建议你不要这样做,这样你才能使用按照这种思路开发的新 React 特性。未来的贡献者,甚至未来的你自己,都会感谢你!

Recap

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

Challenge 1 of 3:
修复错误的 state 更新

这个表单有几个 bug。点击几次增加分数的按钮。注意分数并没有增加。然后编辑名字,注意分数突然“追上”了你的修改。最后,编辑姓氏,注意分数完全消失了。

你的任务是修复所有这些 bug。修复时,请解释每个 bug 发生的原因。

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>
        分数:<b>{player.score}</b>
        {' '}
        <button onClick={handlePlusClick}>
          +1
        </button>
      </label>
      <label>
        名字:
        <input
          value={player.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        姓氏:
        <input
          value={player.lastName}
          onChange={handleLastNameChange}
        />
      </label>
    </>
  );
}