英文原文:Immutability in React and Redux: The Complete Guide
不可变性是一个令人困惑的话题,它在React、Redux、Js中无处不在。
也许已经碰到过这样的bug:即使你已经改变过props的值,但是React组件没有重新渲染的,这时有人会告诉你:‘你应该做不可变的状态变更’。也许你或者你团队中的成员经常编写改变状态的readux reducers,而你不得不经常对此进行修正(要么修改reducers,要么修整你的团队成员😄)。
它是棘手的、微妙的,老实说,如果你不知道为什么它如此重要,你很难对它引起重视(它 = 不可变性)。
这篇指南将会什么是不可变性,如何编写不可变代码。以下是我们将要介绍的内容:
-
什么是不可变性?
-
什么是副作用?
-
为什么不可变性在React中如此重要
-
JS中的引用平等是如何工作的
-
const关键字强制不可变吗
-
如何在Redux中更新状态,包括:
- 更新对象
- 更新对象中的对象
- 通过key更新对象
- 在数组开头插入一个元素
- 在数组末尾插入一个元素
- 在数组中间插入一个元素
- 通过下标更新数组中的元素
- 通过map方法更新数组元素
- 更新Object类型的数组元素
- 通过filter方法过滤数组元素
-
使用Immer轻松更新状态
1、什么是不可变性
首先: 不可变 的反面是 可变,可变意味着可修改、可以被弄乱。
因此不可变的东西就是指无法改变的东西。
极端地说,这意味着你应该不断创建新变量来替换老变量。JS没有如此极端,但在有些语言中是完全不允许可变的(如:Elixir、Erlang、ML等)。
JS不是纯函数语言,但是有时候可以把它看作是。部分数组操作在JS中是不可变的(意味着返回一个新的数组,而不是修改原数组)。字符串操作都是不可变的(创建新的字符串来修改)。我们也自己写不可变函数,但是需要注意一些规则。
可变的代码示例
让我们来看一个示例,它将演示可变性是如何工作的。
我们首先创建一个person对象
let person = {
firstName: "Bob",
lastName: "Loblaw",
address: {
street: "123 Fake St",
city: "Emberton",
state: "NJ"
}
}
然后我们编写一个函数:赋予一个人超能力
function giveAwesomePowers(person) {
person.specialPower = "invisibility";
return person;
}
现在每个人都有相同的超能力了。
// Initially, Bob has no powers :(
console.log(person);
// Then we call our function...
let 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('Are they the same?', person === samePerson); // true
giveAwesomePowers函数改变了传递给它的person对象。执行上面这段代码,第一次打印时Bob没有specialPower属性。但是第二次打印时,它就有specialPower属性了。
由于giveAwesomePowers函数修改了person,我们不知道旧的对象应该是什么样子的了,对象被永久的改变了。
giveAwesomePowers函数返回的对象和我们传入的对象是同一个对象,但是对象内部的属性被修改了,对象已经被改变了。
这里我想重申一遍:对象的内部已经改变,但是对象的引用没有改变。在外面来看,它还是同一个对象。
如果我们希望giveAwesomePowers函数不改变person对象,我们需要进行一些修改。但是我们首先需要了解如何编写一个纯函数。
不可变性规则
纯函数必须满足以下规则:
- 纯函数在接收相同的输入时,输出也一定相同;
- 纯函数不能有副作用
什么是副作用
副作用是一个宽泛的术语,但是一般来说是指:修改了函数范围之外的内容。举几个副作用的示例:
- 修改了输入参数,如:
giveAwesomePowers - 修改了函数之外的状态,如全局变量、document.xxx、window.xxx
- 进行了API调用
- console.log()
- Math.random() 其中API调用可能会让你感到意外。毕竟,调用一个接口(如:fetch('/users'))可能不会对你的UI进行任何更改。但是,接口调用会在浏览器的Network中产生一条log记录,将会创建一个网络链接,如果服务器响应了请求,服务器端可能做任何操作,比如调用其他服务进行更多的修改。
正如我所说,副作用是一个相当宽泛的术语。让我们来看一个没有副作用的函数:
function add(a,b){
return a+b;
}
不管调用add多少次,都不会引发其他的更改,这满足了规则2-无副作用;另外,无论调用多少次add(1,2),结果都是一致的,这满足了规则1 - 相同的输入导致相同的输出。
JS Array的改变性API
一些Array方法会改变调用的array自身:
- push (在尾部插入一个元素)
- pop (在尾部移除一个元素)
- shift (在头部移除一个元素)
- unshift (在头部插入一个元素)
- sort
- reverse
- splice JS数组的sort方法不是不可变性的!它会在原地进行排序,修改了数组自身。如果想要保持不可变性,最简单的方法就是在进行这些操作前对数组进行一次拷贝,在拷贝的数组上进行操作。数组拷贝的方法有:
let a = [1,2,3];
let copy1 = [...a];
let copy2 = a.slice();
let copy3 = a.concat();
因此,如果你想对数组进行一次不可变性的排序,可以这样操作:
let sortedArray = [...originalArray].sort(compareFunction);
值得注意的是,sort接收的参数是一个函数,这个函数应该返回-1/0/1,而不是true/false。
纯函数中只能调用其他纯函数
你可能编写了一个完美的纯函数,但是如果你在结尾时调用了setState或dispath或者其他有副作用的函数,那么一切都徒劳无功了。
像console.log这样的日志函数是可以接受的副作用函数,尽管从技术角度来看会产生一些副作用,但是并不会产生什么影响。
giveAwesomePowers的纯函数版本
function giveAwesomePowers(person){
let newPerson = Object.assign({},person,{
specialPower:'invisibility'
})
return newPerson;
}
我们创建了一个newPerson对象,而不是改变原有的person。 上面的代码创建了一个新的空对象,然后把person的所有属性赋值给新的空对象,最后将specialPower属性也赋值给该对象。
另一种通过对象扩展运算符的写法:
function giveAwesomePowers(person){
let newPerson = {
...person,
specialPower:'invisibility'
}
return newPerson;
}
纯函数返回全新的对象
现在我们使用纯函数版本的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('Are they the same?', person === newPerson); // false
与之前的最大不同在于:person没有被修改,Bob没有发生改变。
这对于函数式编程有点奇怪。对象会被频繁的创建和销毁。我们没有修改Bob,而是创建了一个拷贝的对象,对拷贝的对象进行修改。
React偏爱不可变性
在React中,重要的一点在于不要改变state和props,不管是函数式组件或class组件都应该遵循这个规则。如果你准备书写这样的代码:this.state.something=...或this.props.something=...,你应当退后一步,尝试想出更好的方法。
应当一直使用this.setState修改state,对于这一点有兴趣的可以参考这篇文章:why not to modify state directly。
对于props,它们是单向的。props传入到组件中,它们不是双向的,至少不应该是通过给prop设置新的值这种方式。
如果需要给parent返回数据,或者触发父组件的一些操作,可以通过传递一个函数类型的prop给子组件,在子组件中调用此函数。让我们来看一个例子:
function Child(props) {
// When the button is clicked,
// it calls the function that Parent passed down.
return (
<button onClick={props.printMessage}>
Click Me
</button>
);
}
function Parent() {
function printMessage(){
console.log('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 (
<Child onClick={printMessage} />
)
}
不可变性对于Purecomponents很重要
默认情况下,父组件重新渲染时或者通过setState修改了state时,子组件会重新渲染。
一个优化React组件性能的方法是将其转换成class组件,然后继承React.PureComponent。在这种情况下:组件只会在修改state或者传入的props改变后重新渲染,不会再无条件地跟着父组件 的重新渲染而渲染了。
这就是不可变性的用处:如果将一个props传入PureComponent,你应该确保props以一种不可变的方式进行更新。这意味着,如果这些props是对象或者数组,在更新时你需要使用新创建的对象或数组来替换旧的。
如果你通过修改对象的属性、push等方式修改对象或数组的内部属性时,对象或数组的引用没有发生改变,PureComponent不会识别这些改变,这将会引发诡异的渲染Bug。
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
在Redux中如何更新state
Redux要求其reducers是纯函数。这意味着你不能直接修改state,你必须基于旧state来创建一个新的state。
编写代码来进行不可变状态更新可能很棘手。接下来,你将会发现一些通用模式。
你应当自己进行一些尝试,无论实在浏览器控制台或者真实的应用中。在练习时特别注意对象的更新。
... to be continued