在JavaScript中对象变异并不总是坏事

61 阅读6分钟

我们人类喜欢以绝对的方式处理问题。这很容易。细微差别是困难的。对我们来说,不幸的是,一切都涉及细微差别。这就是为什么我们应该质疑自己,如果我们开始怀疑变异是否总是坏的。

事实是突变并不总是坏的,通常也不是坏的。它就是这样。它是一些语言提供给我们的操纵对象的优秀工具。就像任何工具一样,正确使用它是我们的责任。

什么是对象变异?

这里有一个关于对象突变的快速复习。比方说,我们有一个人的对象。

const person = { name: 'Jarvis', age: 32 };

如果我们要改变这个人的年龄,我们就会对这个对象进行突变。

person.age = 33;

这似乎是无害的,对吗?

变异出错的地方

编程是关于交流期望的。当一个操作的意图没有被清楚地传达,当开发者(或机器)的期望被违反时,突变就会出问题。

让我们来考虑以下对突变的(糟糕的)使用。

function copyPerson(person, newName, newAge) {
  const newPerson = person;
  newPerson.name = newName;
  newPerson.age = newAge;
  return newPerson;
}

为什么这很糟糕?好吧,让我们看看当我们在野外使用这个函数时会发生什么。

const jarvis = { name: 'Jarvis', age: 32, arms: 2, legs: 2 };
const stanley = copyPerson(jarvis, 'Stanley', 27);

console.log(stanley);
// { age: 27, arms: 2, legs: 2, name: "Stanley" }

console.log(jarvis);
// { age: 27, arms: 2, legs: 2, name: "Stanley" }

我们的期望被彻底违反了

在我们的copyPerson 函数中,我们不小心给newPerson 分配了一个对同一个person 对象的引用。因为它们引用了同一个对象,所以突变newPerson也会突变person

我们如何解决这个问题呢?我们可以通过使用传播操作符复制person 对象并同时覆盖nameage 的属性,完全不用变异。

function copyPerson(person, newName, newAge) {
  const newPerson = {
    ...person,
    name: newName,
    age: newAge,
  };
  return newPerson;
}

这样就可以了!但我们也可以突变的方式来做,这也是完全可以的。有些人甚至会觉得这样做更有可读性!

function copyPerson(person, newName, newAge) {
  const newPerson = { ...person };
  newPerson.name = newName;
  newPerson.age = newAge;
  return newPerson;
}

那么等等,如果这很好,突变实际上是罪魁祸首吗?不,不是的。是我们对引用如何工作缺乏了解

可变性和流行的前端框架

像React这样流行的前端框架在渲染逻辑中使用引用。让我们考虑下面的例子。

function App() {
  const [person, setPerson] = useState({ name: 'Jarvis', age: 32 });

  return <PersonCard person={person} />;
}

在这个例子中,如果person 变化,PersonCard 组件将重新渲染。

实际上,让我们在这里的措辞更谨慎一些:PersonCard 组件将重新渲染person 引用一个新的对象。同样,如果我们突变person ,而不是创建一个新的对象,我们会给自己带来麻烦。

由于这个原因,下面的代码将是错误的。

function App() {
  const [person, setPerson] = useState({ name: 'Jarvis', age: 32 });

  function incrementAge() {
    person.age++;
    setPerson(person);
  }

  return (
    <>
      <PersonCard person={person} />
      <button onClick={incrementAge}>Have a birthday</button>
    </>
  );
}

如果我们点击 "祝你生日快乐 "按钮,我们会递增person 对象的age 属性,然后尝试将person 状态设置为该对象。问题是,这不是一个新的对象,它是与之前的渲染相同的person 对象!React的差异化算法没有看到person 引用的变化,也没有重新渲染PersonCard

我们如何解决这个问题?你猜对了:我们只需要确保我们创建了一个基于person 的新对象。然后,我们可以通过突变新对象或其他方式来完成任务。

function App() {
  const [person, setPerson] = useState({ name: 'Jarvis', age: 32 });

  function incrementAge() {
    const newPerson = { ...person };
    newPerson.age++;
    setPerson(newPerson);
  }

  return (
    <>
      <PersonCard person={person} />
      <button onClick={incrementAge}>Have a birthday</button>
    </>
  );
}

如果你在这里的直觉是突变newPerson 是不好的,因为我们使用的是React,那么一定要检查你的假设!这里没有任何问题:newPerson 是一个变量,其作用域是incrementAge 函数。我们没有突变React正在追踪的东西,因此,我们 "在React中 "的事实在这里并没有发生作用。

再次,在这里认识到突变并不是坏事,这一点非常重要。我们对对象引用和React差异算法的误解是导致这里的错误行为的原因。

什么时候突变是好的?

现在我已经讨论了一些突变经常被指责为错误行为的场景,让我们来谈谈突变在什么时候真正发挥了作用。

清晰度

通常情况下,我发现突变会更清晰。我喜欢用的一个例子是,如果我们需要创建一个新的数组,并更新数组中的一个元素。在React中工作时,我经常看到以下的情况。

function updateItem(index, newValue) {
  const newItems = items.map((el, i) => {
    if (i === index) {
      return newValue;
    }
    return el;
  });
  setItems(newItems);
}

这样做很好,但有点混乱,对于不精通JavaScript数组方法的人来说,阅读起来可能有点困难。

在我看来,一个更易读的替代方法是简单地创建一个初始数组的副本,然后对复制的数组的适当索引进行突变。

function updateItem(index, newValue) {
  const newItems = [...items];
  newItems[index] = newValue;
  setItems(newItems);
}

我认为这要清楚得

处理复杂的结构

我最喜欢的一个例子是建立一个树状结构的可变性。你可以在O(n)时间内做到这一点,这要感谢引用和变异。

考虑一下下面的数组,它代表一棵扁平化的树。

const data = [
  { id: 56, parentId: 62 },
  { id: 81, parentId: 80 },
  { id: 74, parentId: null },
  { id: 76, parentId: 80 },
  { id: 63, parentId: 62 },
  { id: 80, parentId: 86 },
  { id: 87, parentId: 86 },
  { id: 62, parentId: 74 },
  { id: 86, parentId: 74 },
];

每个节点有一个id ,然后是其父节点的id (parentId)。我们构建树的代码可以如下。

// Get array location of each ID
const idMapping = data.reduce((acc, el, i) => {
  acc[el.id] = i;
  return acc;
}, {});

let root;
data.forEach((el) => {
  // Handle the root element
  if (el.parentId === null) {
    root = el;
    return;
  }
  // Use our mapping to locate the parent element in our data array
  const parentEl = data[idMapping[el.parentId]];
  // Add our current el to its parent's `children` array
  parentEl.children = [...(parentEl.children || []), el];
});

其工作原理是,我们首先在data 数组中循环一次,创建一个每个元素在数组中的映射。然后,我们在data 数组中再做一次循环,对于每个元素,我们使用映射来定位其在数组中的父元素。最后,我们改变父级的children 属性,将当前元素添加到其中。

如果我们console.log(root) ,我们最终会得到完整的树。

{
  id: 74,
  parentId: null,
  children: [
    {
      id: 62,
      parentId: 74,
      children: [{ id: 56, parentId: 62 }, { id: 63, parentId: 62 }],
    },
    {
      id: 86,
      parentId: 74,
      children: [
        {
          id: 80,
          parentId: 86,
          children: [{ id: 81, parentId: 80 }, { id: 76, parentId: 80 }],
        },
        { id: 87, parentId: 86 },
      ],
    },
  ],
};

这真的很巧妙,而且在没有变异的情况下,完成起来相当有挑战性。

关于对象突变的主要启示

随着时间的推移,我逐渐认识到,在突变方面有几个关键点需要理解。

  • 通常我们把突变归咎于我们自己对引用如何工作缺乏了解。
  • 像React这样流行的前端框架依靠比较对象的引用来实现渲染逻辑。突变旧版本的状态会引起各种头痛和难以理解的错误。开发者没有认识到这种细微的差别,而是经常在React代码中的任何地方完全避免突变。
  • 当突变的用法被明确告知时,它是一个很好的工具。
  • 如果本地化,突变是一个很好的工具(例如,被突变的对象永远不会逃出一个函数)。