Immer使用指南

React 作为当前前端最受欢迎的框架之一,极大的提升了前端开发效率。
其拥有庞大的开发者群体,其社区也非常活跃,因此围绕 React 也产出了非常多的第三方库。
Immer 就是其中之一。

一、Immer基本介绍

1
2
Immer (German for: always) is a tiny package that allows you to work with
immutable state in a more convenient way.

Immer 是一个很小的包,它能让您以更方便的方式处理不可变状态(immutable state)。
是 2019 年 React“年度突破”开源奖和“最具影响力贡献”JavaScript 开源奖得主。

Immer 简化了不可变数据结构的处理

Immer 可用于需要使用不可变数据结构的任何上下文中。
例如,结合 React 状态、React 或 Redux reducers 或配置管理等。

针对不可变的数据结构能够做到变更检测:

1
2
即如果对象的引用没有更改,则对象本身也没有更改。
(如果没有在 draft 中对 state 对象做修改,那么返回值和原对象是一样的,绝对相等)

此外,它还使得克隆成本相对较低:

1
原对象中,未更改的属性(树)部分不做复制,在内存中与原旧版本的属性共享属性(树)。

通常来说,为了不更改原对象、数组或映射的任何属性,但又需要创建新对象并对其属性进行操作的时候
我们通常是对原对象进行深拷贝,然后通过操作拷贝的对象的属性来实现。

但是,这在实践中,可能导致写出相当繁琐的代码,并且很容易意外影响到原对象。
Immer 的出现就是为了解决这些问题,它能解决如下痛点:

1
2
3
4
5
6
7
1.Immer会检测到意外变更并抛出错误。
2.Immer能避免对不可变对象进行深度更新时,所需要的常规手动拷贝代码的实现
如果没有Immer,对象副本需要在每一级上手工创建其副本,通常通过使用很解构操作(…obj)操作。
当使用Immer时,只需要对 draft对象进行更改,draft对象会先记录用户的修改,
然后仅创建有变更的必要的属性副本,不会影响原始对象。
3.在使用Immer时,您不需要额外学习专用的api或数据结构,
使用普通的JavaScript数据结构并使用常规方式修改数据即可,操作简单且安全。

为什么要使用Immer?

假如有如下 state 数组:

1
2
3
4
5
6
7
8
9
10
const baseState = [
{
title: 'Learn TypeScript',
done: true,
},
{
title: 'Try Immer',
done: false,
},
];

我们需要将 baseState 数组的状态进行变更,变更为一个新的 state 状态
同时,原本的 baseState 不能被修改。

1
2
1)对第二项数据的 done 值进行变更为 true
2)为 state 数组新增一个新的数据项```{ title: 'Tweet about it' }```。

如果不使用 Immer
我们将不得不小心翼翼地浅层复制状态结构的每一层,这将取决于我们的手工操作是否仔细。

1
2
3
4
5
6
7
8
9
const nextState = baseState.slice(); // 浅复制数组对象
// 替换数组第一项的数据
nextState[1] = {
...nextState[1], // 使用解构语法复制数组第一项的第一个元素对象
done: true, // 新增新属性,合并进入数组第一项的第一个元素对象
};
// 因为 nextState 是新克隆的,所以这里使用 push 是安全的,
// 但是在未来的其它时间,如果做同样的操作的时候就可能就会违反不可变原则,从而并引入 bug!,这需要我们非常小心
nextState.push({ title: 'Tweet about it' });

使用 Immer
使用 Immer,能让这个过程更直接。
我们可以利用 produce 函数,它的第一个参数为我们想要操作的初始的状态。
第二个参数是我们传递一个名为 recipe 的函数
该函数自动传入了一个 draft 对象作为参数,我们可以直接修改该 draft 对象。
一旦修改完成,这些修改将被记录下来并用于后续产生下一个状态。
之后,Produce 将负责将上面的变更进行必要的复制,并通对对象进行冻结,防止未来被意外修改。

实现代码如下:

1
2
3
4
5
6
import produce from 'immer';

const nextState = produce(baseState, (draft) => {
draft[1].done = true;
draft.push({ title: 'Tweet about it' });
});

很显然,使用 immer 之后,相比于之前手工实现简单了不少,失误的可能性更低了。
同时, produce 对其对象的冻结也避免了其在此后的操作中被意外修改的可能性。

1
2
3
Immer 就像是一个私人助理。
助理拿着一封信(当前状态),给你一份草稿纸,让你在上面写你想要做的修改。
当你写完之后,助手就会拿起你的草稿,根据草稿内容为你写出真正不能再被修改的、最后版本的书信(即下一个状态)。

Immer 的优势

1
2
3
4
5
6
7
1. 遵循不可变数据规范,同时使用普通的JavaScript对象、数组、集合和映射。不需要学习新的api或“语法”!
2. 强类型,没有基于字符串的路径选择器等。 结构共享,仅复制需要的数据部分。
3. 冻结对象,不会被轻易改变。
4. 深度更新轻而易举,不需要人工考虑其数据结构会被影响或者遗漏。
5. 使用简单,能使代码更简洁。
6. 对JSON补丁的一流支持
7. 体积小,gzipped 压缩后仅3 kb

二、Immer的使用场景

应用的场景有:

1
2
3
4
5
1. 用于 React 的 state 的变更。
React 的 state 本身是不可修改的,当你需要修改它的某个属性然后保存为新的状态的时候,
使用 immer 可以很方便的获得一个新的 state。
2. 需要复制一个不可变对象,在不改变原对象的情况下,修改其中的某个值,保存为一个新的对象。
3. 复制一个不可变的数组,在不改变原数组的情况下,修改其中某个值,保存为新的数组。

类似于深拷贝:

1
2
3
4
5
6
import produce from 'immer';
const baseState = { a: 1 };
const nextState = produce(baseState, (draft) => {
draft[1].done = true;
draft.push({ title: 'Tweet about it' });
});

如上代码,baseState 为原状态,draft 可以看做是 baseState 的深拷贝对象(其实不是,它是一个代理对象)。
当然,其效果和深拷贝对象是非常类似的,和操作一个对象的完全复制体一样,修改 draft 的时候并不会影响原来的 baseState。

为什么不直接使用深拷贝呢?

上面说了,draft 既然可以看做是 baseState 的深拷贝对象,为什么不直接使用深拷贝呢?
还是有区别的,因为 immer 处理对象也仅仅是看起来像是深拷贝,其实不是,还是有一些区别的。

首先,深拷贝是完全复制,拷贝之后的对象和原对象有不同的堆内存存储空间。

1
2
3
const baseState = [{ a: { b: 1 } }, { a: 2 }];
const nextState = JSON.parse(JSON.stringify(baseState));
console.log(nextState === baseState); // false

深拷贝之后其对象的指针已经不一样了,因此拷贝之后对象和原对象并不相等。

当然,即使是浅拷贝,新旧对象也不再相等,其对象的指针也会改变。
如下代码所示:

1
2
3
const baseState = [{ a: { b: 1 } }, { a: 2 }];
const nextState = [...baseState];
console.log(nextState === baseState); // false

再来看 immer:

1
2
3
4
5
6
import produce from 'immer';
const baseState = [{ a: { b: 1 } }, { a: 2 }];
const nextState = produce(baseState, (draft) => {
draft[0].a.b = 1;
});
console.log(nextState === baseState); // true

可以看出,经过 immer 处理之后,两个对象竟然是相等的
这是因为,immer 在处理 draft的时候,如果没有变更,或者变更之后和原来一样就不会改变对象,其对象指针还是同一个。

那么如果 draft 内部处理的时候有变更呢?

如下代码所示:

1
2
3
4
5
6
7
import produce from 'immer';
const baseState = [{ a: { b: 1 } }, { a: 2 }];
const nextState = produce(baseState, (draft) => {
draft[0].a.b = 2;
});
console.log(nextState === baseState); // false
console.log(nextState, baseState); // 二者的值不一样。

打印结果
从打印结果就可以看出来,当 draft 修改对象属性之后,二者就不再相等了。

显然 produce 返回的 nextState 对象和原来的 baseState不一样了。

这是为什么呢?
原因就在于 Immer draft 的实现:

1
2
3
4
5
6
7
draft 是个 Proxy 代理对象,对它的读写操作会走到内部定义的 getter/setter 里。
当访问 draft 时,其定义的 getter 会返回一个 Proxy 代理对象。
如果在 draft 中没有值的变更或者变更值和原对象一致,则返回原对象。

当给 draft 设置赋值产生变更之后,setter 就会对原对象的 copy 对象进行赋值,之后再返回 copy 对象。
当然,这个返回的 copy 对象并不是原对象的完全 copy,
而只是在原对象的基础上加上了相关变更数据,然后返回这个综合对象。

Immer 仅适用于处理不可变对象

我们可以再回头看看前文中 immer 基本介绍的那一句英文,此为官网的原文。

1
that allows you to work with immutable state in a more convenient way.

with immutable state不可变对象。
也就是说,immer 的根本目的是为了处理“不可变对象”而存在的(比如 React 的 state)。

为什么说是为了处理不可变对象呢?
对普通对象难道不行吗?

1
最好不要。

当然,也可以试试看,比如如下代码:

1
2
3
4
5
6
7
import produce from 'immer';
const baseState = [{ a: { b: 1 } }, { a: 2 }];
const nextState = produce(baseState, (draft) => {
draft[0].a.b = 2;
});
console.log(nextState); // 输出值: [{ a: { b: 2 } }, { a: 2 }]
console.log(baseState); // 输出值: [{ a: { b: 1 } }, { a: 2 }]

此时,我们来修改一下 nextState/baseState 对象的属性值:

1
2
// 直接修改 nextState 的属性值
nextState.a.b = 999;

打印结果

1
2
// 直接修改 baseState 的属性值
baseState.a.b = 999;

打印结果
可以看到,报错了。
很显然,经过 immer 处理之后的 nextState 修改属性值的时候报错了。
而且,原对象 baseState 修改属性值的时候同样会报错。

1
immer 改变了原对象!!!

原因也不难理解,毕竟这就是Immer的两大“亮点”:

1
2
1. 仅复制必要数据(非完全复制对象)
2. 防止未来被意外修改。

– 完结 –

相关链接

immer 官方文档