讲解了一些不可变性的概念,以此来帮助理解React、Redux中使用的immutable的概念。或许可以当作函数式编程的一点入门。
本文是一篇翻译文章,翻译自https://daveceddia.com/react-redux-immutability-guide ,对原文进行了部分修改。
引言
不可变性是一个令人困惑的话题,但这个概念却在React、Redux乃至整个JavaScript中随处可见。
你可能遇到过这样的bug:你的React组件不会重新渲染,即使你知道你已经改变了props。有人说:"你应该做不可变的状态更新。" 也许你或你的一个队友经常在写Redux代码时写出不纯的reducer,而你必须不断地纠正它们(如果解决不了reducer的问题就解决你的队友.jpg)。
这个问题非常微妙且棘手,尤其是当你不确定要关注哪些部分的时候。老实说,如果你不清楚相关概念的话,你很难在实践中注意它。本指南将解释什么是不可变性,以及如何在你自己的应用程序中编写不可变的代码。
什么是不可变性?
不可变性是与可变性相对的概念。可变性的含义即为一个变量可以随便替换、更改等,这导致具有可变性的东西很容易就弄得一团糟;而不可变性是在说一个东西是完全不能够变化的,这意味着我们不再使用传统的变量概念(如C++中的变量),而是不断地创造新的值并替换旧的。
JavaScript并没有强制要求不可变性,你仍然可以随便修改一个变量,但有些语言根本不允许可变性操作(比如Elixir、Erlang和ML等)。
虽然JavaScript并不是一种纯粹的函数式语言,但它有时也可以假装是。JS中的某些数组操作是不可变的(意味着它们返回一个新的数组,而不是修改原来的数组)。字符串操作总是不可变的(它们会创建一个新的字符串并进行修改)。你自己写的函数也可以是不可变的,只需要注意一些规则就可以了。
示例
这个例子展示了传统的可变性工作方式。现在我们有一个名叫person的object:
let person = {
firstName: "Bob",
lastName: "Loblaw",
address: {
street: "123 Fake St",
city: "Emberton",
state: "NJ"
}
}
如下函数将赐予这个person一些超能力:
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
这个函数**修改(mutate)**了传入其中的person
。可以看到在函数运行后,Bob有了超能力,但代价就是因为我们直接修改了传入的person
,我们将无从得知原来的person
是什么样子了,这个变量指向的内存区域已经被改变了。
我们注意到,giveAwesomePowers
返回了传入的person,并且这个samePerson
是与person
完全相同的。这是因为这个函数改变了对象的内部属性(对象的值被修改了),但对于这个对象的引用是没有变化的。
如果我们不想让这个函数去修改传入的person的话,我们需要对它进行一些改变,将其变为纯函数。
纯函数
一个函数是纯函数,则它必须满足以下的原则:
- 对于纯函数而言,只要输入相同,则无论何时调用都必须返回同一个返回值;
- 一个纯函数不能有任何的Side Effect(副作用)。
什么是副作用?
Side Effect(副作用)是一个比较泛泛的术语,这里我们只需了解其基本的含义:修改了这个函数作用域外的任何东西。
如下是一些副作用的常见例子:
- 直接对传入的参数进行变化,比如上面的
giveAwesomePowers
函数; - 对函数外的一些变量进行修改(比如全局变量)或
document.(anything)
或window.(anything)
; - 进行API调用;
console.log()
;Math.random()
(每次调用的输出不同)。
API调用为啥也是不纯的?简单来说API调用将会使得被调用方进行很多操作,这些操作中很有可能就包含着会一些修改操作,这就导致了副作用,从而使得函数不纯。
一个标准的纯函数形如:
function add(a, b) {
return a + b;
}
你可以调用一次,也可以调用一百万次,世界上的其他东西都不会改变。我的意思是,从技术上讲,当这个函数运行时,世界上的事物可能会发生变化。时间会过去......帝国可能会灭亡......但调用这个函数不会直接导致任何这些事情。这就满足了规则2:没有副作用。更重要的是,每次你调用这个函数,如add(1, 2)
,你将得到相同的答案。无论你调用add(1, 2)
多少次,你都会得到相同的答案。这满足了规则1:相同的输入得到相同的答案。
JS中不纯的那些函数
JS中array的某些方法是不纯的,这表明这些方法将会直接修改调用它们的数组:
push
向数组末尾添加元素pop
从数组末尾取出元素shift
从数组头取出元素unshift
向数组头添加元素sort
原地排序reverse
splice
注意,sort
函数是不纯的,所以如果想要一个新的排序好的数组的话,需要在排序之前将原数组拷贝一份,如下是一些在JS中拷贝数组的方法:
let copy1 = [...a].sort();
let copy2 = a.slice().sort();
let copy3 = a.concat().sort();
纯函数版的 giveAwesomePowers
function giveAwesomePowers(person) {
let newPerson = Object.assign({}, person, {
specialPower: 'invisibility'
})
return newPerson;
}
现在情况有些不同。我们不是在修改传入的person,而是在创建一个全新的person。
该例中,Object.assign
的作用是把一个对象的属性分配给另一个对象。你可以把一系列的对象传给它,它将从左到右把它们合并在一起,同时覆盖任何重复的属性。(我说的 "从左到右 "是指执行Object.assign(result, a, b, c)
会把a复制到result,然后是b,然后是c)。
但它并不做深度合并,只有每个参数的直接子属性会被移过去。重要的是,它也不会创建属性的副本或克隆。它按原样分配它们,保持引用不变。
所以上面的代码创建了一个空对象,然后把人的所有属性都分配给这个空对象,然后把specialPower
属性也分配给这个对象。另一种写法是使用对象扩散操作符:
function giveAwesomePowers(person) {
let newPerson = {
...person, // 三个点就是扩散操作符
specialPower: 'invisibility'
}
return newPerson;
}
含义为:创建新对象,向将person中所有的属性插入到新对象中,最后在末尾添加上specialPower
属性。
扩散操作符也可以用于方便的拷贝:
let pldOne = { a: 'b' }
let newOne = {...oldOne};
console.log(newOne === oldOne) // false
需要注意的是,纯函数版的函数将不会返回相同的对象了:
console.log('Are they the same?', person === newPerson); // false
原先的person将不会改变,返回的值将会是原先person的一个克隆,指向内存中的不同位置,因此两个引用将不会相等了。
React更倾向于采用不可变性
在使用React时,重要的是永远不要直接修改状态或props。对于这条规则来说,一个组件是一个函数还是一个类并不重要,所以不要试图写出this.state.something = ...
或this.props.something = ...
这样的代码。要修改状态,请使用this.setState
。如果你感到好奇,你可以阅读更多关于为什么不直接修改状态的内容。
至于props,它们是一个单向的东西。props是进入组件的。它们不是双向的,至少不是通过可变的操作,比如将props设置为一个新值。如果你需要把一些数据送回给父类,或者在父类组件中触发一些东西,你可以通过在props中传递一个函数,然后向父类传递信息时,从子类内部调用该函数来实现:
const Child = (props) {
// 当button按下时将会调用父类传进来的函数
return (
<button onClick={props.printMessage}>
Click Me
</button>
);
}
const Parent = () {
const printMessage = () {
console.log('you clicked the button');
}
// 父类将函数传入子类
// 注:这里传入的是函数本身:printMessage,而不是函数调用的结果:printMessage()
// 在函数式语言中,函数本身是一等公民,可以传来传去
return (
<Child onClick={printMessage} />
);
}
*不可变性的好处是什么?
此段可以略过。
不可变性可以为你的应用程序带来更高的性能,并使得编程和调试更加简单,因为从不改变的数据比在你的应用程序中自由改变的数据更容易debug。
特别是,在Web应用的背景下,不变性使得复杂的变化检测技术可以简单而廉价地实现,确保更新DOM的计算成本很高的过程只发生在绝对必要的时候(这是React比其他库的性能改进的基石)。
简而言之就是不可变的玩意肯定比能瞎变的玩意更容易调试,而且React等Web框架很依赖于对象之间的比较,而不可变性能让比较这件事变得容易得多。
*不可变性用于构建纯组件
此段可以略过。
默认情况下,React组件(包括函数类型和类类型)会在它们的父类重新渲染时,或者在你用
setState
改变它们的状态时重新渲染。优化React组件性能的一个简单方法是让它成为一个类,并让它扩展
React.PureComponent
而不是React.Component
。这样一来,该组件只有在其状态改变或其props改变时才会重新渲染。它将不再无意识地在每次其父辈重新渲染时重新渲染;它将只在其props之一自上次渲染后发生变化时重新渲染。这就是不变性的作用:如果你把props传给
PureComponent
,你必须确保这些props是以不变的方式更新。这意味着,如果它们是对象或数组,你必须用一个新的(修改过的)对象或数组替换整个值。就像对待Bob一样--把它干掉,然后用一个克隆来代替它。如果你修改了一个对象或数组的内部结构--通过改变一个属性,或推送一个新的项目,甚至修改数组中的一个项目--那么这个对象或数组在参考上就等同于它的旧自身,
PureComponent
不会注意到它已经改变了,也不会重新渲染。奇怪的渲染错误将随之而来。还记得我们关于Bob和
giveAwesomePowers
函数的第一个例子吗?还记得函数返回的对象是如何与传入的人一模一样的,三等分,===?那是因为两个变量都是指同一个对象。只有内部的内容被改变了。
JavaScript的值与引用
大伙马上都是要学计组的人了,值和引用的区别应该能搞懂,所以这段我就先略去了,若有需要之后再翻译
Redux
注:下面这些写法看看就行,除非你想挑战自我否则一般没必要这么写。
更新一个对象
function reducer(state, action) {
/*
State looks like:
state = {
clicks: 0,
count: 0
}
*/
return {
...state,
clicks: state.clicks + 1,
count: state.count - 1
}
}
更新嵌套对象
function reducer(state, action) {
/*
State looks like:
state = {
house: {
name: "Ravenclaw",
points: 17
}
}
*/
// Two points for Ravenclaw
return {
...state, // copy the state (level 0)
house: {
...state.house, // copy the nested object (level 1)
points: state.house.points + 2
}
}
更新对象中指定key的值
function reducer(state, action) {
/*
State looks like:
const state = {
houses: {
gryffindor: { points: 15 },
ravenclaw: { points: 18 },
hufflepuff: { points: 7 },
slytherin: { points: 5 }
}
}
*/
// Add 3 points to Ravenclaw,
// when the name is stored in a variable
const key = "ravenclaw";
return {
...state, // copy state
houses: {
...state.houses, // copy houses
[key]: { // update one specific house (using Computed Property syntax)
...state.houses[key], // copy that specific house's properties
points: state.houses[key].points + 3 // update its `points` property
}
}
}
更新数组
function reducer(state, action) {
const newItem = 0;
return [ // a new array
newItem, // add the new item first
...state // then explode the old state at the end
];
// 或在后面添加:
// return [ // a new array
// newItem, // add the new item first
// ...state // then explode the old state at the end
// ];
Immer
不可变的思想虽然很好,但上面这些不可变更新的代码实在是太操蛋了,难读又难写,让人怀疑人生。
所幸的是,世界上有其他大佬也是这么认为的,所以创造了Immer
库,让我们在进行不可变更新的时候,在写法上可以直接使用那些不纯的函数(push(), pop()
等),但最终的结果却是纯的。
immer
提供了一个叫produce
的函数,用这个函数将原先的函数包裹起来,就可以不用费心费力的自己构造新对象了:
const oldOne = (arr) => {
return [...arr, 114514]
}
const arr1 = oldOne([1, 2, 3]) // arr1 = [1, 2, 3, 114514]
const newOne = produce((arr) => {
arr.push(1919810) // 不用return
})
const arr2 = newOne([1, 2, 3]) // arr2 = [1, 2, 3, 1919810]
就这么简单。
通过Immer
,我们可以轻松愉快地改造上面的写法:
// 旧写法
function plainJsReducer(state, action) {
const key = "ravenclaw";
return {
...state,
houses: {
...state.houses,
[key]: {
...state.houses[key],
points: state.houses[key].points + 3
}
}
}
}
// 新写法
function immerifiedReducer(state, action) {
const key = "ravenclaw";
// 注意这块的produce
return produce(state, draft => {
draft.houses[key].points += 3;
});
}
由于React Hooks中的setState
也要求不可变,所以我们也可以在其上运用immer
,比原先省事不少:
const Counter = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(produce(value => {
value = value + 1;
}))
}
return <div>
<p>{ count }</p>
<button onClick={handleClick}> +1s </button>
</div>
}
redux中的immer
在目前(2022年),我们想在React中使用redux的话,一般用的是redux-toolkit,其中的reducer已经贴心的内置了immer
的特性,所以在所有的reducer中我们都可以直接使用push等方法。
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded(state, action) {
state.push(action.payload) // 这里直接push没问题
},
},
})