跳转至

15 不可变数据:为什么对React这么重要?

你好,我是宋一玮,欢迎回到React应用开发的学习。

上节课我们不再依赖CRA,从零开始用 Vite 搭建了一个新的React项目,并把 oh-my-kanban 的代码迁移了过来,熟悉了与 React 应用代码直接相关的工程化概念和工具。其中,我们也重点介绍了代码静态检查工具的用法和部分规则。结合从第3节课以来学到的知识,到现在你已经基本可以独立开发小型React项目了。

从这节课开始,我们将进入新的模块,学习一些大中型React项目中会用到的技术和最佳实践,尤其会重点讲解当你融入一个前端开发团队时,需要的开发工作思路和方式的转变,这会帮你更从容应对团队协作。

这节课的主要内容是不可变数据。

没能以正确方式变更数据,是React开发中产生Bug的重要原因之一。请你回忆一下在第3节课末尾,在更新 todoList state时留下的伏笔: setTodoList(currentTodoList => [newCard, ...currentTodoList]) 为什么不能写成 todoList.unshift(newCard) 呢?当学习了不可变数据的原理和实现,你将对React的渲染与数据之间的关系更有把握。

下面开始这节课的内容。

什么是不可变数据?

不可变数据(Immutable Data)在创建以后,就不可以再被改变。这种数据在编程和调试时更容易预测,有利于降低复杂性。同时在Web领域,类似监听数据变化这样的功能非常有用,但运行起来可能会比较重,而不可变数据可以简化实现,降低成本。

目前为止我用过的最方便的不可变数据是基于JVM的Groovy语言,以下是一段来自Groovy官方文档的例子(有修改):

@groovy.transform.Immutable(copyWith = true)
class Customer {
    String name
    int age
    Date since
    Collection favItems
}
def d = new Date()
def c1 = new Customer(name: '张三', age: 21, since: d, favItems: ['读书', '电影'])

c1.age = 25 // 抛错
def c1Mutated = c1.copyWith(age: 25)
assert c1 != c1Mutated // true
assert c1.age == 21 // true
assert c1Mutated.age == 25 // true
c1.favItems << '烫头' // 抛错

def c2 = new Customer('张三', 21, d, ['读书', '电影'])
assert c1 == c2 // true
assert c1 !== c2 // true

当然,我们这节课并不需要你对Groovy语言有多少了解,这段代码放在这里,只是为了能用一目了然的方式给你展示不可变数据的特征:

  • 不可变数据对象只能在创建时为属性赋值,创建后就不能修改;
  • 不可变数据对象的属性也应该是不可变数据,即整个对象树都不可变;
  • 变更(Mutate)不可变数据只能通过创建新对象、同时显式地指定需要变更的属性的方式,创建出的新对象依旧不可变;
  • 用相同属性创建出来的两个同类型的不可变对象,它们逻辑上相等但对象引用是不相等的。

这里我们稍微做一些扩展,回来看JavaScript,JS里有没有不可变数据?有。所有原始数据类型(Primitive Types)都是不可变数据类型,包括:undefined、null、boolean、number、BigInt、string、Symbol。但对引用类型,如Object、Function、Array、Map、Set、Date等,就不是不可变类型了。

即便JavaScript没有Groovy那么方便的装饰器,我们还是值得在JS里探索不可变数据。这主要考虑到它带来的好处:

  • 编写纯函数(Pure Function)更容易;
  • 可以避免函数对传入参数的一些副作用;
  • 检测数据变化更轻量更快;
  • 缓存不可变数据更安全;
  • 保存一份数据的多个版本变得可行。

React为什么需要不可变数据?

我们的专栏重点依旧在React应用开发上。为什么说React需要不可变数据?

这主要还是因为React是声明式的框架,为了更新用户看到的页面,我们需要让开发出来的React组件响应数据流的变化。这就是说无论开发者,还是React框架本身都关注props、state、context的数据是否有变化。而前面也讲到了,对React框架,不可变数据可以简化比对数据的实现,降低成本;对开发者,不可变数据在开发和调试过程中更容易被预测

接下来看一下React在哪些环节会检查数据的变化。

协调过程中的数据对比

首先是最核心的Fiber协调引擎,常提到的Diffing对比算法就在引擎里,这些对比绝大部分都是在渲染阶段发生的。

我们曾在第12节课提到过,React是用Object.is()方法来判断两个值是否相等的。在以下过程中,React会调用 is(oldValue, newValue) 来对比新旧值:

  • 更新state时,只有新旧state值不相等,才把Fiber标记为收到更新;
  • 更新Context.Provider中的value值;
  • 检查useEffect、useMemo、useCallback的依赖值数组,只有每个值的新老值都检查过,其中有不同时,才执行它们的回调;
  • useSyncExternalStore中检查来自外部的应用状态(比如Redux)是否有变化,才把Fiber标记为收到更新。

还有一种情况是对新旧两个对象做浅对比(Shallow Compare),具体实现方式依然是基于Object.is()。当两个对象属性数量相同,且其中一个对象的每个属性都与另一个对象的同名属性相等时,这两个对象才算相等。在下面的过程中,React会调用 shallowEqual(oldObj, newObj) 来对比新旧对象(主要是props):

  • React.memo进入更新阶段,如果属性均相同,则跳过该组件继续执行下一个工作;
  • PureComponent进入更新阶段,如果属性均相同,则跳过该组件继续执行下一个工作。

以上这两个过程都属于纯组件,我们马上会学习到。

合成事件中的数据对比

除了协调引擎,还有一处数据对比发生在合成事件中:在触发onSelect合成事件前,React用浅对比判断选中项是否真的有变化,真有变化才会触发事件,否则不会触发。

如果你还知道其他数据对比的地方,欢迎在留言区讨论。

React纯组件

前面的第9节课介绍纯函数与React组件的关系时,新造了一个名词“纯函数组件”。当时提到:

“纯函数组件”等同于“纯组件”。因为在React里,纯组件PureComponent是一个主要用于性能优化的独立API:当组件的props和state没有变化时,将跳过这次渲染,直接沿用上次渲染的结果。

到这里,我们终于要介绍纯组件了。首先要注意,纯组件只应该作为性能优化的手段,开发者不应该将任何业务逻辑建立在到纯组件的行为上。有两个API可以创建纯组件,这里只介绍适合函数组件使用的React.memo,至于React.PureComponent,它是类组件专用,你可以参考官方文档

React.memo

const MyPureComponent = React.memo(MyComponent);
//    ---------------              -----------
//           ^                          ^
//           |                          |
//         纯组件                       组件

const MyPureComponent = React.memo(MyComponent, compare);
//    ---------------              -----------  -------
//           ^                          ^          ^
//           |                          |          |
//         纯组件                       组件      自定义对比函数

这个API第一个参数是一个组件,函数组件或类组件都可以。它会返回一个作为高阶组件的纯组件,这个纯组件接受的props与原组件相同。每次渲染时纯组件会把props记录下来,下次渲染时会用新的props与老的props做浅对比,如果判断相等则跳过这次原组件的渲染。

但要注意,原组件内部不应该有state和context操作,否则就算props没变,原组件还是有可能因为props之外的原因重新渲染。

当你不满足于浅对比时,你还可以给这个API传入第二个可选参数,一个compare函数,compare函数被调用时会接受oldProps和newProps两个参数,如果返回true,则视为相等,反之则视为不等。

不可变数据的实现

刚才提到JS中的引用类型并不是不可变的。那如果想用它们,该怎么为它们加入不可变特性呢?

手工实现

其实你在前面oh-my-kanban项目中已经有经验了,这里再罗列部分:

// 数组
const itemAdded = [...oldArray, newItem];
const itemRemoved = oldArray.filter(item => item !== newItem);

// 对象
const propertyUpdated = { ...oldObj, property1: 'newValue' };

// Map
const keyUpdated = new Map(oldMap).set('key1', 'newValue');

要领就是“别.改.原.对.象”。

借助Helper库

上面的手工实现在处理复杂对象时,很容易写错,有第三方库对此做了抽象,但目前基本已经不再维护了。

import update from 'immutability-helper';

const state1 = ['x'];
const state2 = update(state1, {$push: ['y']}); // ['x', 'y']

可持久化数据结构和Immutable.js

到这里,我们就不得不提一个概念:可持久化数据结构(Persistent data structure),它可谓是不可变对象的挚友亲朋。

在计算机编程中,可持久化数据结构(Persistent data structure)是一种能够在修改之后其保留历史版本(即可以在保留原来数据的基础上进行修改——比如增添、删除、赋值)的数据结构。这种数据结构实际上是不可变对象,因为相关操作不会直接修改被保存的数据,而是会在原版本上产生一个新分支。
——维基百科(可持久化数据结构

在JS中,可持久化数据结构的代表性实现,就是FB开源的immutable.js。这个库提供了List、Stack、Map、OrderedMap、Set、OrderedSet 和Record这些不可变数据类型。用这些类型API创建的数据,就是基于可持久化数据结构的不可变数据,可以直接用在React中。

这里贴两段官方样例代码。首先是神似JS Array的List,你可以看到对List对象每个操作都会创建新的List:

const { List } = require('immutable');
const list1 = List([1, 2]);
const list2 = list1.push(3, 4, 5);
const list3 = list2.unshift(0);
const list4 = list1.concat(list2, list3);
assert.equal(list1.size, 2);
assert.equal(list2.size, 5);
assert.equal(list3.size, 6);
assert.equal(list4.size, 13);
assert.equal(list4.get(0), 1);

还有这个库的强项嵌套结构,在对象树深处的更新也会返回新的不可变对象:

const { fromJS } = require('immutable');
const nested = fromJS({ a: { b: { c: [3, 4, 5] } } });

const nested2 = nested.mergeDeep({ a: { b: { d: 6 } } });
// Map { a: Map { b: Map { c: List [ 3, 4, 5 ], d: 6 } } }

console.log(nested2.getIn(['a', 'b', 'd'])); // 6

const nested3 = nested2.updateIn(['a', 'b', 'd'], value => value + 1);
console.log(nested3);
// Map { a: Map { b: Map { c: List [ 3, 4, 5 ], d: 7 } } }

const nested4 = nested3.updateIn(['a', 'b', 'c'], list => list.push(6));
// Map { a: Map { b: Map { c: List [ 3, 4, 5, 6 ], d: 7 } } }

如何解决原理和直觉的矛盾?

Immutable.js很强大,在React技术社区也受到过追捧。然而,不知道你平时是怎么使用的,我反正在React项目中使用这个框架时,总是要时刻提醒自己,什么时候可以使用JS原生的数据类型,什么时候就必须切换到不可变数据类型,这增加了我在开发过程中的认知负荷。

在认知心理学中,认知负荷(Cognitive Load)是指工作记忆资源的使用量。

这虽然会提高程序运行效率,但同时也会降低开发者的开发效率。

那么有没有一种方式,既可以沿用熟悉的JS数据类型和方法,又可以类似这节课开头Groovy那样,优雅地加入不可变性?

有的,Immer(官网)就是这样一款框架,它可以让JS开发者使用原生的JS数据结构,和本来不具有不可变性的JS API,创建和操作不可变数据。

以下是来自Immer官网的一段样例代码,它的produce API接受原数据和数据变更回调函数两个参数,在回调函数中发生的变更,并不会修改原数据本身,而是会返回一个等同于变更结果的新数据:

import produce from "immer"

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

在React中使用Immer

在函数组件中,可以直接使用Immer提供的Hooks来替代useState。

安装Immer:

npm install immer use-immer

在组件中使用Immer:

import React from "react";
import { useImmer } from "use-immer";

function App() {
  const [showAdd, setShowAdd] = useState(false);
  const [todoList, setTodoList] = useImmer([
    { title: '开发任务-1', status: '22-05-22 18:15' },
    { title: '开发任务-3', status: '22-05-22 18:15' },
    { title: '开发任务-5', status: '22-05-22 18:15' },
    { title: '测试任务-3', status: '22-05-22 18:15' }
  ]);
  // ...
  const handleSubmit = (title) => {
    setTodoList(draft => {
      draft.unshift({ title, status: new Date().toDateString() });
    });
  };
  // ...

在后面的课程中,我们还会有一些样例代码中会用到Immer,届时会介绍更多实用技巧。

小结

这节课我们学习了不可变数据,了解了不可变数据在创建后就不能被改变,这样的特性有助于提升React对比新旧props、state、context数据的效率,而且更容易被预测,有助于开发调试。

然后我们学习了用React.memo创建具有更佳性能的纯组件,有关纯组件我也给你分享了一些注意点,要是你记不太清了,可以回去巩固一下。最后介绍了在JS中实现不可变数据的几种方式,除了我们在oh-my-kanban中的手工实现,还有Immutable.js和Immer这些开源框架。

接下来我们会用两节课的时间,介绍React的应用状态管理,学习什么情况下使用React的state,什么情况下使用外部的应用状态管理框架。

思考题

  1. 其实在JavaScript里有一个方法 Object.freeze() ,它可以用于不可变数据吗?
  2. 在React相关的技术社区也有不少关于ES6 Proxy的讨论,你了解或使用过Proxy吗?如果有过的话,请你设想一下,如果在React项目中使用Proxy,你会怎样使用它?

好的,这节课内容就到这里。我们下节课再见。

精选留言(3)
  • 相望于江湖 👍(1) 💬(1)

    我有个疑问,不可变其实就是把对象、数组深度克隆一遍。 深度克隆这个操作应该比深度比较更耗时吧,而且对象克隆来克隆去,难道不会造成内存暴涨,甚至泄露吗?

    2023-09-08

  • 01 👍(0) 💬(3)

    可能需要deepFreeze。 本身存在冻结不应该冻结对象的风险 preact给自己乃至react 提供了 signals。 用到了proxy

    2022-10-11

  • thomas 👍(0) 💬(0)

    保存一份数据的多个版本变得可行。 --------------------------------> 没理解这句话,即使是可变的数据,也能实现一份数据多个版本。麻烦老师具体解释下

    2023-12-29