65.9K
CodeProject 正在变化。 阅读更多。
Home

React 和 Redux 中的不变性:完整指南

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2018年9月18日

CPOL

21分钟阅读

viewsIcon

12122

不变性可能是一个令人困惑的话题,它在 React、Redux 以及整个 JavaScript 中随处可见。

不变性可能是一个令人困惑的话题,它在 React、Redux 以及整个 JavaScript 中随处可见。

你可能遇到过这样的 bug:你的 React 组件没有重新渲染,即使你知道你已经改变了 props,有人说,“你应该进行不可变的状态更新。”也许你或你的队友经常编写会改变状态的 Redux reducer,你不得不不断纠正他们(reducer 或你的队友😄)。

这很棘手。它可能非常微妙,特别是如果你不确定要寻找什么。老实说,如果你不确定它为什么重要,就很难关心它。

本指南将解释什么是不变性以及如何在自己的应用程序中编写不可变代码。以下是我们将涵盖的内容:

什么是不变性?

首先,*不可变*是*可变*的反义词——而可变意味着*可改变的*、可修改的……可以被修改的。

因此,*不可*变的东西是*不能改变的*东西。

推向极端,这意味着你将不断创建新值并替换旧值,而不是拥有传统的变量。JavaScript 并没有这么极端,但有些语言是——Elixir、Erlang、Lisp、ML、Clojure……所有“真正”的函数式语言。

虽然 JavaScript 并不是一种纯粹的函数式语言,但它有时可以假装是。JS 中的某些数组操作是不可变的(这意味着它们返回一个新数组,而不是修改原始数组)。`String` 操作总是不可变的(它们创建了一个带有更改的新`string`)。你也可以编写自己的不可变函数。你只需要注意一些规则。

一个带有突变的示例代码

让我们看一个例子,了解可变性是如何工作的。我们从这个 `person` 对象开始

let person = {
	firstName: "Bob",
	lastName: "Loblaw",
	address: {
		street: "123 Fake St",
		city: "Emberton",
		state: "NJ"
	}
}

然后假设我们编写了一个函数,赋予 `person` 特殊能力

function giveAwesomePowers(person) {
	person.specialPower = "invisibility";
	return person;
}

好的,所以每个人都获得了相同的能力。无所谓,隐形能力很棒。

现在让我们给罗布劳先生一些特殊能力。

// Initially, Bob has no powers :(
console.log(person);

// Then we call our function...
var samePerson = giveAwesomePowers(person);

// Now Bob has powers!
console.log(person);
console.log(samePerson);

// He's the same person in every other respect, though.
console.log(<span class="s1">'Are they the same?', person === samePerson); // true

这个函数 `giveAwesomePowers` *改变*了传入的 `person`。运行这段代码,你会看到第一次打印 `person` 时,鲍勃没有 `specialPower` 属性。但第二次,他突然拥有了隐形 `specialPower`。

问题是,由于这个函数*修改了*传入的 `person`,我们不再知道旧的 `person` 是什么样子了。它们永远改变了。

`giveAwesomePowers` 返回的对象与传入的对象是*同一个对象*,但它的内部已被修改。它的属性已更改。它已被*改变*。

我想再强调一次,因为它很重要:对象的*内部*已经改变,但对象的引用没有改变。从外部看,它是同一个对象(这就是为什么 `person === samePerson` 这样的相等性检查会是 `true` 的原因)。

如果我们想让 `giveAwesomePowers` 函数不修改这个人,我们必须做一些改变。不过,首先,让我们看看是什么让一个函数成为*纯粹的*,因为它与不变性密切相关。

不变性规则

为了*纯粹*,一个函数必须遵循这些规则

  1. 一个纯函数在给定相同输入时必须总是返回相同的值。
  2. 纯函数不得有任何副作用。

什么是“副作用”?

“副作用”是一个广义的术语,但基本上,它意味着修改该直接函数范围之外的事物。一些副作用的例子:

  • 修改/更改输入参数,就像 `giveAwesomePowers` 所做的那样
  • 修改函数之外的任何其他状态,例如全局变量,或 `document.(anything)` 或 `window.(anything)`
  • 进行 API 调用
  • console.log()
  • Math.random()

API 调用可能会让你感到惊讶。毕竟,调用像 `fetch('/users')` 这样的东西似乎根本不会改变你的 UI 中的任何东西。

但问问你自己:如果你调用了 `fetch('/users')`,它会改变*任何地方的任何东西*吗?即使在你的 UI 之外?

是的。它会在浏览器的网络日志中创建一个条目。它会创建(并可能稍后关闭)一个到服务器的网络连接。一旦该调用到达服务器,所有规则都将失效。服务器可以做任何它想做的事情,包括调用其他服务并进行更多修改。至少,它可能会在某个日志文件中放置一个条目(这是一种修改)。

所以,正如我所说:“副作用”是一个相当广泛的术语。这是一个没有副作用的函数

function add(a, b) {
  return a + b;
}

你可以调用它一次,也可以调用它一百万次,世界上没有任何其他东西会改变。我的意思是,从技术上讲,在函数运行时,世界上的事情可能会改变。时间会流逝……帝国可能会衰落……但*调用这个函数*不会直接导致任何这些事情。这满足了规则 2——没有副作用。

更重要的是,每次你调用 `add(1, 2)` 这样的函数,你都会得到相同的答案。无论你调用 `add(1, 2)` 多少次,你都会得到相同的答案。这满足了规则 1——相同输入 == 相同答案。

纯函数只能调用其他纯函数

一个潜在的问题来源是从纯函数中调用非纯函数。

纯度是可传递的,而且是全有或全无的。你可以编写一个完美的纯函数,但如果你最终调用了*其他*某个函数,而该函数最终调用了 `setState` 或 `dispatch` 或导致了其他某种副作用……那么一切就都说不准了。

现在,有一些“可接受”的副作用。使用 `console.log` 记录消息是可以的。是的,它在技术上是一种副作用,但它不会影响任何东西。

一个纯净版本的 giveAwesomePowers

现在我们可以根据规则重写我们的函数。

function giveAwesomePowers(person) {
  let newPerson = <span class="nb">Object.assign({}, person, {
    specialPower: <span class="s1">'invisibility'
  })

  return newPerson;
}

现在有点不同了。我们不再修改这个人,而是创建了一个*全新的*人。

如果你还没有见过 `Object.assign`,它的作用是将属性从一个对象分配到另一个对象。你可以向它传递一系列对象,它会将它们全部从左到右合并,同时覆盖任何重复的属性。不过它不进行*深合并*——只有每个参数的直接子属性会被移动。同样重要的是,它不创建属性的副本或克隆。它按原样分配它们,保持引用不变。

因此,上面的代码创建了一个空对象,然后将 `person` 的所有属性分配给该空对象,然后也将 `specialPower` 属性分配给该对象。另一种编写方式是使用对象展开运算符

function giveAwesomePowers(person) {
  let newPerson = {
    ...person,
    specialPower: <span class="s1">'invisibility'
  }

  return newPerson;
}

你可以这样理解:“创建一个新对象,然后插入 `person` 的属性,然后添加另一个名为 `specialPower` 的属性”。截至撰写本文时,这种对象展开语法并非 JavaScript 规范的正式部分,但它受 Babel 支持并广泛使用。Create React App 的默认配置也支持它。

纯函数返回全新的对象

现在我们可以重新运行我们之前的实验,使用我们新的*纯*版本 `giveAwesomePowers`。

// Initially, Bob has no powers :(
console.log(person);

// Then we call our function...
var newPerson = giveAwesomePowers(person);

// Now Bob's clone has powers!
console.log(person);
console.log(newPerson);

// The newPerson is a clone
console.log(<span class="s1">'Are they the same?', person === newPerson); // false

最大的区别是 `person` 没有被修改。`Bob` 没有改变。该函数创建了 `Bob` 的*克隆*,拥有所有相同的属性,加上隐形的能力。

这是函数式编程的一个怪异之处。对象不断地被创建和销毁。我们不改变 `Bob`;我们创建了一个克隆,修改了他的克隆,然后用他的克隆替换了 `Bob`。有点残酷,真的。如果你看过电影《致命魔术》,就有点像那样。(如果你没看过,就当我没说过。)

React 偏爱不变性

在 React 中,重要的是永远不要改变状态或 props。一个组件是函数还是类对这条规则无关紧要。如果你即将编写像 `this.state.something = ...` 或 `this.props.something = ...` 这样的代码,请退后一步,尝试想出更好的方法。

要修改状态,请始终使用 `this.setState`。如果你好奇,可以阅读更多关于为什么不要直接修改 React 状态的内容。

至于 props,它们是单向的。Props 传入组件。它们不是双向的,至少不是通过将 prop 设置为新值这样的可变操作。

如果你需要将一些数据发送回父级,或者在父组件中触发一些事情,你可以通过传入一个*函数*作为 prop,然后在子组件内部需要与父级通信时*调用该函数*来实现。这里有一个回调 prop 的快速示例,它就是这样工作的

function Child(props) {
  // When the button is clicked,
  // it calls the function that Parent passed down.
  return (
    <<span class="nt">button onClick=<span class="si">{props.printMessage<span class="si">}>
      Click Me
    </<span class="nt">button>
  );
}

function Parent() {
  function printMessage() {
    console.log(<span class="s1">'you clicked the button');
  }

  // Parent passes a function to Child as a prop
  // Note: it passes the function name, not the result of
  // calling it. It's printMessage, not printMessage()
  return (
    <<span class="nc">Child onClick=<span class="si">{printMessage<span class="si">} />
  );
}

不变性对于 PureComponents 很重要

默认情况下,React 组件(无论是 `function` 类型还是 `class` 类型,如果它继承自 `React.Component`)会在其父组件重新渲染时,或者在你使用 `setState` 更改其状态时重新渲染。

优化 React 组件性能的一个简单方法是将其制作成一个类,并使其继承 `React.PureComponent` 而不是 `React.Component`。这样,该组件将仅在其状态更改或其*props 更改*时重新渲染。它将不再盲目地在其父组件每次重新渲染时重新渲染;它将*仅*在自上次渲染以来其某个 props 更改时重新渲染。

这就是不变性的用武之地:如果你将 props 传递给 `PureComponent`,你必须确保这些 props 以不可变的方式更新。这意味着,如果它们是对象或数组,你必须将整个值替换为新的(修改过的)对象或数组。就像对待 `Bob` 一样——销毁它并用克隆替换它。

如果你修改了对象或数组的内部——通过更改属性、推送新项,甚至修改数组*内部*的项——那么该对象或数组与其旧身*引用相等*,`PureComponent` 将不会注意到它已更改,并且不会重新渲染。将会出现奇怪的渲染错误。

还记得我们第一个关于 `Bob` 和 `giveAwesomePowers` 函数的例子吗?还记得函数返回的对象与传入的 `person` 完全相同,三等号,`===` 吗?那是因为两个变量都指向同一个对象。只有内部发生了改变。

JavaScript 中引用相等性如何工作

“引用相等”是什么意思?好的,这会是一个简短的离题,但理解它很重要。

JavaScript 对象和数组存储在内存中。(你现在应该点头了。)

假设内存中的一个位置就像一个盒子。变量名“指向”盒子,盒子持有实际值。

A variable points to a memory location

在 JavaScript 中,这些盒子(内存地址)是未命名的且不可知的。你无法找出变量指向的内存地址。(在其他一些语言中,例如 C,你实际上可以检查变量的内存地址并查看它位于何处。)

如果你*重新赋值*一个变量,它将指向一个新的内存位置。

reassigning a variable points it to a new memory location

如果你修改变量的内部,它仍然指向相同的地址。

Mutating a variable doesn't change its address

这很像拆掉一所房子的内部,然后换上新墙、厨房、客厅游泳池等等——那所房子的*地址*保持不变。你不需要提醒你的亲戚把生日钱寄到哪里,因为你仍然住在同一个地方。

**关键在于:** 当你使用 `===` 运算符比较两个对象或数组时,JavaScript 实际上比较的是它们指向的*地址*——也称为它们的*引用*。JS 甚至不会窥视对象内部。它只比较引用。这就是“引用相等”的含义。

所以,如果你取一个对象并修改它,它将修改对象的*内容*,但不会改变它的引用。

另一件事是,当你将一个对象赋值给另一个对象(或将其作为函数参数传入,这实际上是做同样的事情)时,另一个对象只是指向*同一内存位置*的另一个指针,就像一个巫毒娃娃。你对第二个对象所做的任何操作也会直接影响第一个对象的值。

这里有一些代码让这更具体一些

// This creates a variable, `crayon`, that points to a box (unnamed),
// which holds the object `{ color: 'red' }`
let crayon = { color: <span class="s1">'red' };

// Changing a property of `crayon` does NOT change the box it points to
crayon.color = <span class="s1">'blue';

// Assigning an object or array to another variable merely points
// that new variable at the old variable's box in memory
let crayon2 = crayon;
console.log(crayon2 === crayon); // true. both point to the same box.

// Niw, any further changes to `crayon2` will also affect `crayon1`
crayon2.color = <span class="s1">'green';
console.log(crayon.color); // changed to green!
console.log(crayon2.color); // also green!

// ...because these two variables refer to the same object in memory
console.log(crayon2 === crayon);

为什么不进行深度相等检查?

在声明两个对象相等之前,检查它们的内部似乎更“正确”。虽然这没错,但它也更慢。

慢多少?嗯,这取决于被比较的对象。一个拥有 10,000 个子属性和孙子属性的对象会比一个拥有 2 个属性的对象慢。这是不可预测的。

引用相等检查是计算机科学家所说的“常数时间”。常数时间,即 O(1),意味着操作总是花费相同的时间,无论输入有多大。

另一方面,深度相等检查更可能是*线性时间*,即 O(N),这意味着它所需的时间与对象中的键数成正比。一般来说,线性时间比常数时间慢。

这样想:假设每次 JS 比较两个值(例如 `a === b`)需要一整秒。现在,你希望只做一次,检查引用?还是希望深入到两个对象的内部,比较每一个属性?听起来很慢,对吧?

实际上,相等性检查比一整秒快得多,但“做最少的工作”的原则仍然适用。在其他条件相同的情况下,使用性能最好的选项。它会为你节省以后弄清楚为什么你的应用程序很慢的时间。如果你小心(也许有点幸运),它一开始就不会变慢:)

const 会阻止更改吗?

简短的回答是:不会。`let`、`const` 和 `var` 都不会阻止你更改对象的内部。这三种声明变量的方式都允许你修改其内部。

“但它叫 `const`!它不应该是常量吗?”

嗯,有点像。`const` 只会阻止你重新赋值引用。它不会阻止你更改对象。这里有一个例子

const order = { type: "coffee" }

// const will allow changing the order type...
order.type = "tea"; // this is fine

// const will prevent reassigning `order`
order = { type: "tea" } // this is an Error

下次你看到 `const` 时请记住这一点。

我喜欢使用 `const` 来提醒自己一个对象或数组不应该被修改(大多数时候)。如果我正在编写代码,并且我确定会修改一个数组或对象,我就会用 `let` 声明它。这只是一种约定。(而且,像大多数约定一样,如果你*时不时*地打破它,那和根本没有约定一样好)。

如何在 Redux 中更新状态

Redux 要求其 reducer 是*纯函数*。这意味着你不能直接修改状态——你必须基于旧状态创建一个新状态,就像我们上面对 Bob 所做的那样。(如果你不确定,请阅读有关reducer 是什么以及该名称来源的内容)

编写代码进行不可变状态更新可能很棘手。下面,你会发现一些常见的模式。

在浏览器开发控制台或真实应用程序中亲自尝试一下。特别注意嵌套对象更新,并练习它们。我发现那些是最棘手的。

所有这些实际上也适用于 React 状态,所以无论你是否使用 Redux,本指南中学习的内容都将适用。

最后,我们将看看如何使用一个名为 Immer 的库使其更容易——但不要跳到最后!如果你要处理现有代码库,理解如何“手动”完成这些事情会非常有用。

... 展开运算符

这些例子大量使用了数组和对象的**展开**运算符。下面是它的工作原理。

当 `...` 符号放在对象或数组前面时,它会展开其中的子元素,并将其直接插入到那里。

// For arrays:
let nums = [1, 2, 3];
let newNums = [...nums]; // => [1, 2, 3]
nums === newNums // => false! it's a new array

// For objects:
let person = {
  name: "Liz",
  age: 32
}
let newPerson = {...person};
person === newPerson // => false! it's a new object

// Internal properties are left alone:
let company = {
  name: "Foo Corp",
  people: [
    {name: "Joe"},
    {name: "Alice"}
  ]
}
let newCompany = {...company};
newCompany === company // => false! not the same object
newCompany.people === company.people // => true!

如上所示,展开运算符可以轻松创建一个新对象或数组,其中包含与另一个对象或数组完全相同的内容。这对于创建对象/数组的副本,然后覆盖你需要更改的特定属性很有用

let liz = {
  name: "Liz",
  age: 32,
  location: {
    city: "Portland",
    state: "Oregon"
  },
  pets: [
    {type: "cat", name: "Redux"}
  ]
}

// Make Liz one year older, while leaving everything
// else the same:
let olderLiz = {
  ...liz,
  age: 33
}

对象的展开运算符是一个第 3 阶段草案,这意味着它尚未正式成为 JS 的一部分。你需要在代码中使用 Babel 等转译器才能使用它。如果你使用 Create React App,则已经可以使用它。

更新状态的秘诀

这些示例是在从 Redux reducer 返回状态的上下文中编写的。我将展示传入状态的样子,然后展示如何返回更新后的状态。

为了保持示例的清晰,我将完全忽略“action”参数。假设这种状态更新将针对*任何* action 发生。当然,在你自己的 reducer 中,你可能会有一个带有每个 action 的 `case` 的 `switch` 语句,但我认为这只会在这里增加噪音。

在 React 中更新状态

要将这些示例应用于普通的 React 状态,你只需要在这些示例中调整几处。

由于 React 会*浅合并*你传递给 `this.setState()` 的对象,你不需要像使用 Redux 那样展开现有状态。

在 Redux reducer 中,你可能会这样写

return {
  ...state,
  (updates here)
}

对于纯 React 状态,你可以这样写,不需要展开运算符

this.setState({
  updates here
})

请记住,由于 `setState` 进行*浅*合并,当您更新状态中深度嵌套的项(任何比第一层更深的项)时,您需要使用对象(或数组)展开运算符。

Redux:更新对象

当你想更新 Redux 状态对象中的顶级属性时,使用 `...state` 复制现有状态,然后列出你要更改的属性及其新值。

function reducer(state, action) {
  /*
    State looks like:

    state = {
      clicks: 0,
      count: 0
    }
  */

  return {
    ...state,
    clicks: state.clicks + 1,
    count: state.count - 1
  }
}

Redux:更新对象中的对象

当你要更新的对象在 Redux 状态中嵌套了一层(或多层)时,你需要复制*每一层*,包括你要更新的对象。这是一个嵌套一层的示例

function reducer(state, action) {
  /*
    State looks like:

    state = {
      house: {
        name: "Ravenclaw",
        points: 17
      }
    }
  */

  // Two points for Ravenclaw
  return {
    ...state, // copy the state (level 0)
    house: {
      ...state.house, // copy the nested object (level 1)
      points: state.house.points + 2
    }
  }

这里是另一个例子,这次更新一个嵌套两层的对象

function reducer(state, action) {
  /*
    State looks like:

    state = {
      school: {
        name: "Hogwarts",
        house: {
          name: "Ravenclaw",
          points: 17
        }
      }
    }
  */

  // Two points for Ravenclaw
  return {
    ...state, // copy the state (level 0)
    school: {
      ...state.school, // copy level 1
      house: {         // replace state.school.house...
        ...state.house, // copy existing house properties
        points: state.school.house.points + 2  // change a property
      }
    }
  }

当你更新深度嵌套的项目时,这段代码可能会变得难以阅读!

Redux:按键更新对象

function reducer(state, action) {
  /*
    State looks like:

    const state = {
      houses: {
        gryffindor: {
          points: 15
        },
        ravenclaw: {
          points: 18
        },
        hufflepuff: {
          points: 7
        },
        slytherin: {
          points: 5
        }
      }
    }
  */

  // Add 3 points to Ravenclaw,
  // when the name is stored in a variable
  const key = "ravenclaw";
  return {
    ...state, // copy state
    houses: {
      ...state.houses, // copy houses
      [key]: {  // update one specific house (using Computed Property syntax)
        ...state.houses[key],  // copy that specific house's properties
        points: state.houses[key].points + 3   // update its `points` property
      }
    }
  }

Redux:在数组前面添加一个项目

可变的方式是使用 Array 的 `.unshift` 函数将一个项目添加到前面。但是,`Array.prototype.unshift` 会改变数组,这不是我们想做的。

以下是如何以不可变的方式在数组开头添加一个项目,适用于 Redux

function reducer(state, action) {
  /*
    State looks like:

    state = [1, 2, 3];
  */

  const newItem = 0;
  return [    // a new array
    newItem,  // add the new item first
    ...state  // then explode the old state at the end
  ];

Redux:向数组添加一个项目

可变的方式是使用 Array 的 `.push` 函数将项目添加到末尾。但是,那会修改数组。

以下是如何不可变地将一个项目附加到数组的末尾

function reducer(state, action) {
  /*
    State looks like:

    state = [1, 2, 3];
  */

  const newItem = 0;
  return [    // a new array
    ...state, // explode the old state first
    newItem   // then add the new item at the end
  ];

Redux:在数组中间插入一个项目

可变的方法是使用 Array 的 `.splice` 函数。

不可变的方法涉及复制所有在新元素*之前*的元素,然后插入新元素,然后复制所有在新元素*之后*的元素。

专业提示:为这些东西编写单元测试!很容易出现差一错误。

function reducer(state, action) {
  /*
    State looks like:

    state = [1, 2, 3, 5, 6];
  */

  const newItem = 4;
  return [                // make a new array
    ...state.slice(0, 3), // copy the first 3 items unchanged
    newItem,              // insert the new item
    ...state.slice(3)     // copy the rest, starting at index 3
  ];
}

Redux:按索引更新数组中的一个项目

这与添加项目非常相似,只是索引不同。你将复制要更改项目之前(但不包括)的项目,然后插入更改后的项目,然后复制其余的项目。

如果你已经知道要更新的项目的索引,这会很好用。如果你不知道索引,请参阅使用 `.map` 的示例。

专业提示:为这些东西编写单元测试!很容易出现差一错误。

function reducer(state, action) {
  /*
    State looks like:

    state = [1, 2, "X", 4];
  */

  const newItem = 3;
  return [                // make a new array
    ...state.slice(0, 2), // copy the first 2 items unchanged
    newItem,              // replace index 2 with newItem
    ...state.slice(3)     // copy the rest, starting at index 3
  ];
}

Redux:使用 `map` 更新数组中的一个项目

Array 的 `.map` 函数将通过调用你提供的函数,传入每个现有项,并使用你的返回值作为新项的值来返回一个新数组。

换句话说,如果你有一个包含 N 个项目的数组,并且想要一个仍然包含 N 个项目的新数组,请使用 `.map`。

(如果你有一个包含 N 个项目的数组,并且最终想要*更少*的项目,请使用 `.filter`。请参阅下面的从数组中删除项目)。

function reducer(state, action) {
  /*
    State looks like:

    state = [1, 2, "X", 4];
  */

  return state.map((item, index) => {
    // Replace "X" with 3
    // alternatively: you could look for a specific index
    if(item === "X") {
      return 3;
    }

    // Leave every other item unchanged
    return item;
  });
}

Redux:使用 `filter` 从数组中删除一个项目

Array 的 `.filter` 函数将调用你提供的函数,传入每个现有项,并返回一个新数组,其中只包含你的函数返回“`true`”(或真值)的项。如果你返回 `false`,则该项将被移除。

如果你有一个包含 N 个项目的数组,并且你希望最终得到*更少*的项目,请使用 `.filter`。

function reducer(state, action) {
  /*
    State looks like:

    state = [1, 2, "X", 4];
  */

  return state.filter((item, index) => {
    // Remove item "X"
    // alternatively: you could look for a specific index
    if(item === "X") {
      return <span class="kc">false;
    }

    // Every other item stays
    return <span class="kc">true;
  });
}

查看 Redux 文档中关于不可变更新模式的部分,了解其他一些不错的技巧。

使用 Immer 轻松更新状态

如果你看了上面一些不可变状态更新的代码,想尖叫着跑开,我不怪你。

深度嵌套的对象更新难以阅读、难以编写且难以正确实现。单元测试是必不可少的,但即使它们也不能使代码更容易阅读和编写。

谢天谢地,有一个库可以提供帮助。使用 Michael Weststrate 的 Immer,你可以编写你熟悉和喜爱的可变代码,所有你能够塞进去的 `[].push`、`[].pop` 和 `=`——Immer 会将这些代码变成完美的不可变更新,就像魔法一样。

太棒了。让我来告诉你它是如何工作的

首先,你需要安装 Immer。(根据 VSCode 的便捷 Import Cost 插件,gzipped 后为 3.9K。根据 Immer 的 GitHub 页面,为 2K。无论哪种方式——对于它增加的巨大功能来说,都非常小。)

yarn add immer

然后,你需要从 Immer 导入 `produce` 函数。它只有这一个导出;这个函数就是*它所做的全部*。这很棒,简洁而专注。

import produce from <span class="s1">'immer';

顺便说一下,它被称为“produce”,因为它产生一个新值,这个名字有点像“reduce”的反义词。在 Immer 的 GitHub 上有一个问题,他们最初讨论了这个名字。

从那时起,你可以使用 `produce` 函数为自己构建一个不错的可变游乐场,所有的修改都将通过 JS Proxies 的魔力来处理。以下是修改前后的对比,首先是更新对象中嵌套值的 reducer 的纯 JS 版本,然后是 Immer 版本

/*
  State looks like:

  state = {
    houses: {
      gryffindor: {
        points: 15
      },
      ravenclaw: {
        points: 18
      },
      hufflepuff: {
        points: 7
      },
      slytherin: {
        points: 5
      }
    }
  }
*/

function plainJsReducer(state, action) {
  // Add 3 points to Ravenclaw,
  // when the name is stored in a variable
  const key = "ravenclaw";
  return {
    ...state, // copy state
    houses: {
      ...state.houses, // copy houses
      [key]: {  // update one specific house (using Computed Property syntax)
        ...state.houses[key],  // copy that specific house's properties
        points: state.houses[key].points + 3   // update its `points` property
      }
    }
  }
}

function immerifiedReducer(state, action) {
  const key = "ravenclaw";

  // produce takes the existing state, and a function
  // It'll call the function with a "draft" version of the state
  return produce(state, draft => {
    // Modify the draft however you want
    draft.houses[key].points += 3;

    // The modified draft will be
    // returned automatically.
    // No need to return anything.
  });
}

将 Immer 与 React State 结合使用

Immer 也适用于纯 React 状态——`setState` 的“函数式”形式。

你可能已经知道 React 的 `setState` 有一种“函数式”形式,它接受一个函数并将其传递给当前状态。然后该函数返回新状态

onIncrementClick = () => {
  // The normal form:
  this.setState({
    count: this.state.count + 1
  });

  // The functional form:
  this.setState(state => {
    return {
      count: state.count + 1
    }
  });
}

Immer 的 `produce` 函数可以作为状态更新函数插入。你会注意到这种调用 `produce` 的方式只传递了一个参数——更新函数——而不是像我们在 reducer 示例中那样传递两个参数 `(state, draft => {})`。

onIncrementClick = () => {
  // The Immer way:
  this.setState(produce(draft => {
    draft.count += 1
  });
}

之所以有效,是因为 Immer 的 `produce` 函数在仅使用 1 个参数调用时,会*返回一个“柯里化”函数*。在这种情况下,它返回的函数已准备好接受一个状态,并使用 draft 调用你的更新函数。

逐步采用 Immer

Immer 的一个优点是,因为它非常小巧且专注(只有一个函数可以生成新状态),所以很容易将其添加到现有代码库中并进行尝试。

Immer 也向后兼容现有的 Redux reducer。如果你用 Immer 的 `produce` 函数包装你现有的 `switch/case`,你所有的 reducer 测试都应该仍然通过。

早些时候,我展示了你传递给 `produce` 的 `update` 函数可以隐式返回 `undefined`,并且它会自动识别对 `draft` 状态的更改。我没有提到的是,`update` 函数可以*选择性地*返回一个全新的状态,只要它没有对 `draft` 进行任何更改。

这意味着你现有的 Redux reducer,它们已经返回全新的状态,可以被 Immer 的 `produce` 函数包装,并且它们应该继续以完全相同的方式工作。此时,你可以随意、逐个替换难以阅读的不可变代码。查看官方示例,了解从生产者返回数据的不同方式

React 和 Redux 中的不变性:完整指南最初由 Dave Ceddia 于 2018 年 9 月 17 日在 Dave Ceddia 发布。

© . All rights reserved.