理解 JavaScript 中的不可变性

Avatar of Kingsley Silas
Kingsley Silas

DigitalOcean 提供适合您旅程各个阶段的云产品。立即开始使用 200 美元的免费信用额度!

如果您之前没有在 JavaScript 中使用过不可变性,您可能会发现它很容易与将变量分配给新值或重新分配混淆。虽然可以使用 `let` 或 `var` 重新分配使用 `let` 或 `var` 声明的变量和值,但当您尝试使用 `const` 时,您将开始遇到问题。

假设我们将 `value` Kingsley 分配给名为 `firstName` 的变量

let firstName = "Kingsley";

我们可以将一个新值重新分配给同一个变量,

firstName = "John";

这是可能的,因为我们使用了 `let`。如果我们碰巧使用 `const` 而不是这样

const lastName = "Silas";

…当我们尝试将其分配给一个新值时,我们将得到一个错误;

lastName = "Doe"
// TypeError: Assignment to constant variable.

这不是不可变性。

您在使用 React 等框架时会听到的一个重要概念是,改变状态是一个坏主意。道具也是如此。然而,重要的是要知道不可变性不是一个 React 概念。React 在处理状态和道具等方面恰好利用了不可变性的思想。

到底是什么意思?这就是我们将要开始的地方。

不可变性是关于坚持事实

不可变数据不能改变其结构或其中的数据。它是在一个不能改变的变量上设置一个值,使该值成为一个事实,或者类似于一个真理的来源——就像一个公主亲吻一只青蛙,希望它能变成一个英俊的王子一样。不可变性表明青蛙永远都是青蛙。

另一方面,对象和数组允许变异,这意味着数据结构可以改变。如果我们告诉它,亲吻这两只青蛙中的任何一只确实可能导致王子变身。

假设我们有一个这样的用户对象

let user = { name: "James Doe", location: "Lagos" }

接下来,让我们尝试使用这些属性创建一个 `newUser` 对象

let newUser = user

现在,让我们假设第一个用户改变了位置。它将直接改变 `user` 对象,并影响 `newUser`

user.location = "Abia"
console.log(newUser.location) // "Abia"

这可能不是我们想要的。您可以看到这种重新分配是如何导致意外后果的。

使用不可变对象

我们要确保我们的对象不会被改变。如果我们要使用一个方法,它必须返回一个新的对象。本质上,我们需要一个叫做纯函数的东西。

纯函数有两个特性使其独一无二

  1. 它返回的值取决于传递的输入。只要输入不改变,返回的值就不会改变。
  2. 它不会改变其作用域之外的东西。

通过使用 `Object.assign()`,我们可以创建一个不改变传递给它的对象的函数。这将通过将第二个和第三个参数复制到作为第一个参数传递的空对象中来生成一个新的对象。然后返回新对象。

const updateLocation = (data, newLocation) => {
    return {
      Object.assign({}, data, {
        location: newLocation
    })
  }
}

`updateLocation()` 是一个纯函数。如果我们传入第一个 `user` 对象,它将返回一个新的 `user` 对象,其中 location 的值是新的。

另一种方法是使用 展开运算符

const updateLocation = (data, newLocation) => {
  return {
    ...data,
    location: newLocation
  }
}

好吧,这一切与 React 有什么关系?我们接下来要讨论这个。

React 中的不可变性

在典型的 React 应用程序中,状态是一个对象。(Redux 使用不可变对象作为应用程序存储的基础。)React 的协调过程确定组件是否应该重新渲染,或者它是否需要一种方法来跟踪更改。

换句话说,如果 React 无法弄清楚组件的状态是否已更改,那么它将不知道要更新虚拟 DOM。

不可变性在实施时,可以跟踪这些更改。这允许 React 将对象的旧状态与其新状态进行比较,并根据该差异重新渲染组件。

这就是为什么在 React 中直接更新状态通常被不鼓励的原因

this.state.username = "jamesdoe";

React 将不确定状态是否已更改,并且无法重新渲染组件。

Immutable.js

Redux 遵循不可变性的原则。它的 reducers 应该都是纯函数,因此,它们不应改变当前状态,而应根据当前状态和操作返回一个新的对象。我们通常会像之前那样使用展开运算符,但可以使用名为 Immutable.js 的库来实现相同的功能。

虽然普通的 JavaScript 可以处理不可变性,但您可能会在路上遇到一些陷阱。使用 Immutable.js 保证不可变性,同时提供了一个丰富的 API,该 API 具有很高的性能。我们不会在本文中深入探讨 Immutable.js 的所有细节,但我们将看一个快速示例,演示如何在由 React 和 Redux 提供支持的待办事项应用程序中使用它。

首先,让我们从导入所需的模块开始,并在我们进行操作时设置 `Todo` 组件。


const { List, Map } = Immutable;
const { Provider, connect } = ReactRedux;
const { createStore } = Redux;

如果您正在本地机器上操作,则需要安装这些软件包

npm install redux react-redux immutable 

导入语句将如下所示。

import { List, Map } from "immutable";
import { Provider, connect } from "react-redux";
import { createStore } from "redux";

然后,我们可以继续使用一些标记来设置我们的 `Todo` 组件

const Todo = ({ todos, handleNewTodo }) => {
  const handleSubmit = event => {
    const text = event.target.value;
    if (event.keyCode === 13 && text.length > 0) {
      handleNewTodo(text);
      event.target.value = "";
    }
  };

  return (
    <section className="section">
      <div className="box field">
        <label className="label">Todo</label>
        <div className="control">
          <input
            type="text"
            className="input"
            placeholder="Add todo"
            onKeyDown={handleSubmit}
          />
        </div>
      </div>
      <ul>
        {todos.map(item => (
          <div key={item.get("id")} className="box">
            {item.get("text")}
          </div>
        ))}
      </ul>
    </section>
  );
};

我们使用 `handleSubmit()` 方法来创建新的待办事项。出于本例的目的,用户将只创建新的待办事项,我们只需要一个操作来完成该操作

const actions = {
  handleNewTodo(text) {
    return {
      type: "ADD_TODO",
      payload: {
        id: uuid.v4(),
        text
      }
    };
  }
};

我们创建的 `payload` 包含待办事项的 ID 和文本。然后,我们可以继续设置 reducer 函数,并将我们在上面创建的操作传递给 reducer 函数

const reducer = function(state = List(), action) {
  switch (action.type) {
    case "ADD_TODO":
      return state.push(Map(action.payload));
    default:
      return state;
  }
};

我们将使用 `connect` 来创建一个容器组件,以便我们可以连接到存储。然后,我们需要将 `mapStateToProps()` 和 `mapDispatchToProps()` 函数传递给 `connect`。

const mapStateToProps = state => {
  return {
    todos: state
  };
};

const mapDispatchToProps = dispatch => {
  return {
    handleNewTodo: text => dispatch(actions.handleNewTodo(text))
  };
};

const store = createStore(reducer);

const App = connect(
  mapStateToProps,
  mapDispatchToProps
)(Todo);

const rootElement = document.getElementById("root");

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  rootElement
);

我们使用 `mapStateToProps()` 来为组件提供存储的数据。然后,我们使用 `mapDispatchToProps()` 通过将操作绑定到它,使操作创建者可以作为道具提供给组件。

在 reducer 函数中,我们使用 Immutable.js 中的 `List` 来创建应用程序的初始状态。

const reducer = function(state = List(), action) {
  switch (action.type) {
    case "ADD_TODO":
      return state.push(Map(action.payload));
    default:
      return state;
  }
};

将 `List` 视为 JavaScript 数组,这就是为什么我们可以对 state 使用 `.push()` 方法的原因。用于更新 state 的值是一个对象,它继续说明 `Map` 可以被识别为一个对象。这样,就没有必要使用 `Object.assign()` 或展开运算符,因为这保证了当前状态不会改变。这看起来更简洁,特别是如果状态最终嵌套得很深——我们不需要在所有地方都使用展开运算符


不可变状态使代码能够快速确定是否发生了更改。我们不需要对数据进行递归比较来确定是否发生了更改。也就是说,重要的是要提到,当使用大型数据结构时,您可能会遇到性能问题——复制大型数据对象会带来代价。

但是数据需要改变,因为否则就没有必要进行动态网站或应用程序。重要的是如何改变数据。不可变性提供了一种正确的方式来改变应用程序的数据(或状态)。这使得跟踪状态的更改成为可能,并确定应用程序的哪些部分应该重新渲染,以响应该更改。

第一次了解不可变性会很令人困惑。但是当您遇到状态被改变时出现的错误时,您会变得更好。这通常是理解不可变性的需求和益处的最清晰的方式。

进一步阅读