React 之不可变性(ImmutableJS)

613 阅读9分钟

JavaScript 如何比较对象的变化

如何有效的比较对象的变化,一直是 JavaScript 编程的一个问题

JavaScript对象是一个非常复杂的数据结构,它的键可以指向任意类型的值,包括 objectJavaScript 创建的对象将存储在计算机内存中(对应一个物理地址),然后它返回一个引用,JavaScript 引擎通过该引用可以访问该对象,该引用赋值给某个变量后,我们便可以通过该变量以引用的方式操作该对象。

相等性检查将包括两个部分

  • 值检查
  • 引用检查

值检查
层层循环检查对象各属性值是否相同

引用检查
即检查两个对象的引用地址是否相同

React 渲染机制

React Component 类组件中,通过实现生命周期,对组件 props 和状态 state 进行检查,以决定是否更新并重新渲染,若组件状态太多,组件性能就会下降,对象越复杂,其相等性检查就会越慢。

对于嵌套对象,必须迭代层层进行检查判断,耗费时间过长。若仅修改对象的属性,其引用保持不变,相等性检查中的引用检查结果不变

Immutable 做的,就是以简单快捷的方式判断对象是否变更,对 React 组件更新和渲染性能有很大帮助

Immutable 的数据

Immutable 对象和原生 JS 对象的主要差异可以概括为以下两点:

  • 持久化数据结构(Persistent data structures)
  • 结构共享(Structures sharing Trie )

持久化数据结构

Never mutated, instead copy it and then make change.

持久数据结构主张所有操作都返回该数据结构的更新副本,并保持原有结构不变,而不改变原来的结构。通常利用 Trie 构建它不可变的持久性数据结构,它的整体结构可以看作一棵树,一个树节点可以对应代表对象某一个属性,节点值即属性值。

结构共享

一旦创建一个 Immutable Trie 型对象,我们可以把该对象想象成如下一棵树,在之后变更尽可能的是对树节点上的属性值发生改变。当我们要更新一个 Immutable 对象的属性值时,就是对应着重构该树结构中的某一个节点,对于树,我们修改某一节点只需要重构该节点及受其影响的节点,即其祖先节点

为什么需要 Immutable

不可变性,副作用及突变

时间旅行

不鼓励突然变更对象,因为那通常会打断时间旅行及bug相关调试,并且在 react-reduxconnect 方法中组件状态突变将导致组件性能低下:

时间旅行:Redux DevTools 开发工具期望应用在重新发起某个历史 action 时将仅仅返回一个状态值,而不改变任何东西,即无副作用。突变和异步操作将导致时间旅行混乱,行为不可预测

react-redux: connect 方法将检查 mapStateToProps 方法返回的 props 对象是否变更以决定是否需要更新组件。为了提高这个检查变更的性能, connect 方法基于 Immutable 状态对象进行改进,使用浅引用相等性检查来探测变更。这意味着对对象或数组的直接变更将无法被探测,导致组件无法更新。

reducer 函数中的诸如生成唯一 ID 或时间戳的其他副作用也会导致应用状态不可预测,难以调试和测试。

Redux 的某一 reducer 函数返回一个可以突变的状态对象,意味着我们不能追踪,预测状态(对象结构持久化,有利于防止对象被任意修改,同时通过树的概念,更好的预测状态),这可能导致组件发生多余的更新,重新渲染或者在需要更新时没有响应,也会导致难以跟踪调试 bugImmutable.js 能提供一种 Immutable 方案解决如上提到的问题,同时其丰富的 API 也足够支撑我们复杂的开发

如何使用 Immutable

Immutable 的概念能给我们的应用提供较大的性能提升,但是我们必须正确的使用它,否则得不偿失。目前关于 Immutable 已经有一些类库,对于 React 应用,首选的是 Immutable.js

Immutable.js 与 Redux 实践

当使用 Immutable.jsRedux 协作开发时,可以从如下几方面思考我们的实践。

JavaScript对象转换为Immutable对象
不要在 Immutable 对象中混用原生 JavaScript 对象;

当在 Immutable 对象内添加 JavaScript 对象时,首先使用 fromJS() 方法将 JavaScript 对象转换为 Immutable 对象,然后使用 update() , merge() , set() 等更新 APIImmutable 对象进行更新操作;

// avoid
const newObj = { key: value }
const newState = state.setIn(['prop1'], newObj)
// newObj has been added as a plain JavaScript object, NOT as an Immutable.JS Map

// recommended
const newObj = { key: value }
const newState = state.setIn(['prop1'], fromJS(newObj))

使用 Immutable 对象表示完整的 Redux 状态树

对于一个 Redux 应用,完整的状态树应该由一个 Immutable 对象表示,而没有原生 JavaScript 对象。

使用 fromJS() 方法创建状态树,使用 toJS() 还原树

Immutable 对象转换为 JavaScript 对象

toJS() 方法功能就是把一个 Immutable 对象转换为一个 JavaScript 对象,而我们通常尽可能将 Immutable 对象转换为 JavaScript 对象这一操作放在容器组件中,这也与容器组件的宗旨吻合。另外 toJS 方法性能极低,应该尽量限制该方法的使用,如在 mapStateToProps 方法和展示型组件内。

绝对不要在 mapStateToProps 方法内使用 toJS() 方法

mapStateToProps 方法内使用 toJS() 方法违背了我们使用 Immutable 的初衷,尤其组件状态为对象且较复杂时 toJS() 方法每次会调用时都是返回一个原生 JavaScript 对象,如果在 mapStateToProps 方法内使用 toJS() 方法,则每次状态树(对象)变更时,无论该 toJS() 方法返回的 JavaScript 对象是否实际发生改变,组件都会认为该对象发生变更,从而导致不必要的重新渲染。

绝对不要在展示型组件内使用 toJS() 方法

如果传递给某组件一个 Immuatble 对象类型的 prop ,则该组件的渲染取决于该 Immutable 对象,这将给组件的重用,测试和重构带来更多困难。

当容器组件将 Immutable 类型的属性(props)传入展示型组件时,需使用高阶组件(HOC)将其转换为原生JavaScript对象。

该高阶组件定义如下:

import React from 'react'
import { Iterable } from 'immutable'
 
export const toJS = WrappedComponent => wrappedComponentProps => {
    const KEY = 0
    const VALUE = 1
    const propsJS = Object.entries(wrappedComponentProps)
       .reduce((newProps, wrappedComponentProp) => {
            newProps[wrappedComponentProp[KEY]] =   Iterable.isIterable(wrappedComponentProp[VALUE]) ? wrappedComponentProp[VALUE].toJS() : wrappedComponentProp[VALUE]
            return newProps
    }, {})
 
    return <WrappedComponent {...propsJS} />
}

该高阶组件内,首先使用 Object.entries 方法遍历传入组件的props,然后使用 toJS() 方法将该组件内Immutable类型的prop转换为JavaScript对象,该高阶组件通常可以在容器组件内使用,使用方式如下:

import { connect } from 'react-redux'
import { toJS } from './to-js'
import DumbComponent from './dumb.component'
 
const mapStateToProps = state => {
return {
    // obj is an Immutable object in Smart Component, but it’s converted to a plain
    // JavaScript object by toJS, and so passed to DumbComponent as a pure JavaScript
    // object. Because it’s still an Immutable.JS object here in mapStateToProps, though,
    // there is no issue with errant re-renderings.
    obj:getImmutableObjectFromStateTree(state)
  }
}
 
export default connect(mapStateToProps)(toJS(DumbComponent))

这类高阶组件不会造成过多的性能下降,因为高阶组件只在被连接组件(通常即展示型组件)属性变更时才会被再次调用。你也许会问既然在高阶组件内使用 toJS() 方法必然会造成一定的性能下降,为什么不在展示型组件内也保持使用Immutable对象呢?事实上,相对于高阶组件内使用 toJS() 方法的这一点性能损失而言,避免Immutable渗透入展示型组件带来的可维护性,可重用性及可测试性是我们更应该看重的。

Immutable实践中的问题

无论什么情况,都不存在绝对完美的事物或者技术,使用Immutable.js也必然会带来一些问题,我们能做的则是尽量避免或者尽最大可能的分化这些问题,而可以更多的去发扬该技术带来的优势,使用Immutable.js最常见的问题如下。

很难进行内部协作

Immutable对象和JavaScript对象之间存在的巨大差异,使得两者之间的协作通常较麻烦,而这也正是许多问题的源头。

使用Immutable.js后我们不再能使用点号和中括号的方式访问对象属性,而只能使用其提供的 get , getIn 等API方式; 不再能使用ES6提供的解构和展开操作符; 和第三方库协作困难,如lodash和JQuery等。 渗透整个代码库

Immutable代码将渗透入整个项目,这种对于外部类库的强依赖会给项目的后期带来很大约束,之后如果想移除或者替换Immutable是很困难的。

不适合经常变更的简单状态对象

Immutable和复杂的数据使用时有很大的性能提升,但是对于简单的经常变更的数据,它的表现并不好。

切断对象引用将导致性能低下

Immutable最大的优势是它的浅比较可以极大提高性能,当我们多次使用 toJS 方法时,尽管对象实际没有变更,但是它们之间的等值检查不能通过,将导致重新渲染。更重要的是如果我们在 mapStateToProps 方法内使用 toJS 将极大破坏组件性能,如果真的需要,我们应该使用前面介绍的高阶组件方式转换。

难以调试

当我们审查一个Immutable对象时,浏览器会打印出Immutable.js的整个嵌套结构,而我们实际需要的只是其中小一部分,这导致我们调试较困难,可以使用Immutable.js Object Formatter浏览器插件解决。