Immer & 不可变数据

kaichikaichi • 2021-12-26

6 分钟0 阅读

const 关键字

思考这个问题前,我们先来看看 JavaScript 中的 const 关键字。const 关键字用来声明常量,常量的值是无法被改变的,也不能被重新赋值。这点通常使初学者很困惑,我们看下面这个例子。

const person = { name: 'kaichi', age: 18 };

person.age = 19;

console.log(person); //-> ??

最后会打印什么结果呢?答案是 { name: "kaichi", age: 19 }。令人困惑的是,person 是用 const 声明的常量,为什么会被改变呢?person 确实没被改变,因为其持有的对象引用没变,但是对象本身变了(详细解释)。

person = { name: 'somebody', age: 20 };
//-> Uncaught TypeError: Assignment to constant variable.

当给常量 person 重新赋值时,则会导致错误,很好理解,person 持有的引用不可被修改,所以常量确实无法被修改,但是引用的值是可以被修改的。

Object.freeze() 可以冻结对象使其不能再被修改。

不可变数据

上面的例子中,person 的持有的引用没变,但是对象变了,那么如果要追踪变化将会变得繁琐。试想如果一个组件依赖 person 的数据渲染,如果引用没变就可以认为对象没变的话,便可以高效的检测变化。看下面这个例子。

function Person() {
  const [person, setPerson] = useState({ name: 'kaichi', age: 18 });

  const updateAge = (newAge) =>
    setPerson((prev) => {
      prev.age = newAge;
      return prev; // prev => { name: "kaichi", age: <newAge> }
    });

  return (
    <div>
      <span>{person.age}</span>
      <button onClick={() => updateAge(19)}>age => 19</button>
    </div>
  );
}

在点击按钮更新 person.age 时,age 确实更改了,但是视图没有更新。这不是 react 的 bug,react 期望不可变数据作为 state,这样使得追踪状态的变化变得简单。上面的例子中 person 引用没变,react 对比新旧 state 时发现是同一个引用,所以不需要重新渲染。

const updateAge = (newAge) =>
  setPerson((prev) => {
    // 在需要更新的时候更新
    if (prev.age !== newAge) {
      const next = { ...prev, age: newAge };
      return next; // new obj
    }
    // 不需要更新
    return prev;
  });

修改代码修复这个 bug,并且在需要更新时更新。由于需要返回新的对象,使用 ... 拷贝对象属性到新的对象中。对象中未变化的部分在内存中共享结构,因此拷贝的代价较低。例如 person 中有属性 hobbies 存储兴趣爱好。

const [person, setPerson] = useState({
  name: 'kaichi',
  age: 18,
  hobbies: ['code', 'music', 'game']
});

如果更新 person 时没有更新 hobbies,浅拷贝 person 时只是拷贝了 hobbies 的引用。

Immer

虽然不可变数据带来了好处,但是在编写代码时非常恼人。因为不能更改 ObjectArrayMap 的任何属性,而要始终创建一个更改过的副本。这意味着需要手动拷贝没被修改的部分。看下面这个例子。

const state = [
  {
    title: '学习 TypeScript',
    done: true
  },
  {
    title: '试一试 Immer',
    done: false
  }
];

const nextState = [...state];

nextState[1] = {
  ...nextState[1],
  done: true
};

nextState.push({ title: '学习新技能' });

试想 state 层级更深了呢?让我们试试 Immer。

import { produce } from 'immer';

const nextState = produce(state, (draft) => {
  draft[1].done = true;
  draft.push({ title: '学习新技能' });
});

使用 Immer 就简单了。使用 produce 方法,只需要通过其传递出来的 draft 对状态进行修改,produce 便会记录下用以产生下个状态,负责处理所有必要的复制,以及冻结数据防止意外修改。应用到 react 中。

import React, { useState } from 'react';
import { produce } from 'immer';

function Todos() {
  const [todos, setTodos] = useState([
    {
      title: '学习 TypeScript',
      done: true
    },
    {
      title: '试一试 Immer',
      done: false
    }
  ]);

  const addTodo = (todo) => {
    setTodos((prev) =>
      produce(prev, (draft) => {
        draft.push(todo);
      })
    );
  };

  const completeTodo = (index) => {
    setTodos((prev) =>
      produce(prev, (draft) => {
        draft[index].done = true;
      })
    );
  };

  // ...省略部分代码
}

再也不用手动创建更改过的副本来更新状态,而且更新 draft 和更改普通数组对象一样。但是好像还是有些麻烦,setTodosproduce 的回调函数遵循相同的模式,use-immer 自动包裹 updaterproduce 中,使用 useImmer 简化代码。

- import { produce } from "immer";
+ import { useImmer } from "use-immer";

-  const [todos, setTodos] = useState([
+  const [todos, setTodos] = useImmer([
    {
      title: "学习 TypeScript",
      done: true
    },
    {
      title: "试一试 Immer",
      done: false
    }
  ]);

  const addTodo = (todo) => {
    setTodos((prev) =>
-      produce(prev, (draft) => {
-        draft.push(todo);
-      })
+     {
+        prev.push(todo);
+     }
    );
  };

  const completeTodo = (index) => {
    setTodos((prev) =>
-      produce(prev, (draft) => {
-        draft[index].done = true;
-      })
+     {
+        prev[index].done = true;
+     }
    );
  };

useImmeruseState 的用法几乎一致,和 immer 搭配的 useState 使得不可变数据状态更新更加简洁。

在 Github 上编辑