image
react redux 不可变性指南
2022年1月18日13分钟

讲解了一些不可变性的概念,以此来帮助理解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的话,我们需要对它进行一些改变,将其变为纯函数

纯函数

一个函数是纯函数,则它必须满足以下的原则:

  1. 对于纯函数而言,只要输入相同,则无论何时调用都必须返回同一个返回值;
  2. 一个纯函数不能有任何的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没问题
    },
  },
})

observer@王博伟