美文网首页
第二五章 添加交互-在状态中更新对象

第二五章 添加交互-在状态中更新对象

作者: 深圳都这么冷 | 来源:发表于2023-02-14 08:59 被阅读0次

    在状态中更新对象

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

    你将学习

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

    什么是不变性

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

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

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

    setX(5);
    

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

    现在考虑一个处于状态的对象:

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

    从技术上讲,可以更改对象本身的内容。 这称为突变:

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

    此代码修改分配给先前渲染位置的对象。 但是如果不使用状态设置函数,React 并不知道对象发生了变化。 所以 React 不会做任何响应。 这就像你已经吃完饭后试图改变订单。 虽然改变状态在某些情况下可行,但我们不推荐这样做。 您应该将在渲染中有权访问的状态值视为只读。

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

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

    使用 setPosition,你是在告诉 React:

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

    当您触摸或悬停在预览区域上时,请注意红点现在如何跟随您的指针:

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

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

    使用展开语法复制对象

    在前面的示例中,位置对象始终是从当前光标位置重新创建的。 但通常,您会希望将现有数据作为您正在创建的新对象的一部分。 例如,您可能只想更新表单中的一个字段,但保留所有其他字段的先前值。

    这些输入字段不起作用,因为 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>
            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: '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>
        </>
      );
    }
    

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

    深度阅读:对多个字段使用单个事件处理程序

    您还可以在对象定义中使用 [ 和 ] 大括号来指定具有动态名称的属性。 这是相同的示例,但使用单个事件处理> 程序而不是三个不同的事件处理程序:

    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 元素的名称属性。

    更新嵌套对象

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

    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的数据),然后生成指向新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.city 和 obj1.city。 这是因为 obj3.artwork、obj2.artwork 和 obj1 是同一个对象。 当您将对象视为“嵌套”时,很难看到这一点。 相反,它们是具有属性的相互“指向”的独立对象。

    用 Immer 编写简洁的更新逻辑

    如果您的状态嵌套很深,您可能需要考虑将其展平。 但是,如果你不想改变你的状态结构,你可能更喜欢嵌套展开的捷径。 Immer 是一个流行的库,它允许您使用方便但可变的语法进行编写,并负责为您生成副本。 使用 Immer,您编写的代码看起来像是在“打破规则”并改变对象:

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

    但与常规突变不同的是,它不会覆盖过去的状态!

    深度阅读:Immer怎样工作

    Immer 提供的 draft 是一种特殊类型的对象,称为 Proxy,它会“记录”你用它做了什么。 这就是为什么你可以随心所欲地改变它! 在幕后,Immer 找出草稿的哪些部分已被更改,并生成一个包含您的编辑的全新对象。

    为了尝试 Immer:

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

    这是上面的示例转换为 Immer:

    import { useImmer } from 'use-immer';
    
    export default function Form() {
      const [person, updatePerson] = useImmer({
        name: 'Niki de Saint Phalle',
        artwork: {
          title: 'Blue Nana',
          city: 'Hamburg',
          image: 'https://i.imgur.com/Sd1AgUOm.jpg',
        }
      });
    
      function handleNameChange(e) {
        updatePerson(draft => {
          draft.name = e.target.value;
        });
      }
    
      function handleTitleChange(e) {
        updatePerson(draft => {
          draft.artwork.title = e.target.value;
        });
      }
    
      function handleCityChange(e) {
        updatePerson(draft => {
          draft.artwork.city = e.target.value;
        });
      }
    
      function handleImageChange(e) {
        updatePerson(draft => {
          draft.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}
          />
        </>
      );
    }
    

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

    深度阅读:为什么在 React 中不推荐改变状态?

    有几个原因:

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

    在实践中,你经常可以在 React 中“逃避”改变状态,但我们强烈建议你不要这样做,以便你可以使用以这种方法开发的新 React 特性。 未来的贡献者,甚至你未来的自己都会感谢你!

    回顾

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

    相关文章

      网友评论

          本文标题:第二五章 添加交互-在状态中更新对象

          本文链接:https://www.haomeiwen.com/subject/dxgxkdtx.html