Immer & 不可变数据
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
虽然不可变数据带来了好处,但是在编写代码时非常恼人。因为不能更改 Object
、Array
或 Map
的任何属性,而要始终创建一个更改过的副本。这意味着需要手动拷贝没被修改的部分。看下面这个例子。
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
和更改普通数组对象一样。但是好像还是有些麻烦,setTodos
和 produce
的回调函数遵循相同的模式,use-immer 自动包裹 updater 到 produce
中,使用 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;
+ }
);
};
useImmer
和 useState
的用法几乎一致,和 immer 搭配的 useState
使得不可变数据状态更新更加简洁。