React 栈(六):Immutable

发布于 2022-04-24 12:51:16 字数 17313 浏览 1296 评论 0

Immutability


Immutability 并不局限于 JavaScript,只不过 Redux 的一个基石就是 immutability。Redux 的程序必须同时具备 Immutability,否则就是逻辑错误的。 文档已经回答了很多问题,基本上只有下面「为什么没有 immutable 的 redux 是逻辑错误的」一节是自己的思考过程,其余可悉数参考官方文档。非常完整的文档,非常专业。

  • Immutable 是什么?
  • 为什么没有 immutable 的 redux 是逻辑错误的?
  • 如何实现 immutable?不同方案的对比?
    • 使用 ImmutableJS 的优点
    • 使用 ImmutableJS 的缺点
  • 其他实现 immutable 的方案?
  • 使用 ImmutableJS + Redux 的最佳实践?
  • 我的结论
  • Immutable 在其他语言中
    • 意义作用
    • 语言支持

Immutable 是什么?

原始定义可能比较难找了。大概的意思是两点:

  • 对象一旦创建就不能被修改了。「修改」意思是,基本类型值不能被修改,对象引用不能被指向其他对象,对象中的值也不能被「修改」(循环定义)
  • 每次「修改」都会返回一个新的对象。「新」的对象,意味着这个对象会有唯一的标识,以用于对象对比

遵循 immutable 的设计,数据之间会呈现一个关系:引用的对比结果等同于值的对比结果。我们下面会看到,为什么这个推论对于 redux 来说是必须要有的。

为什么没有 immutable 的 redux 是逻辑错误的?

先废几句话再进正题:redux 设计过程使用了浅对比是正确的,这个决定恰如其分。如果用了深对比,大数据量下一定出性能问题,这样库也不可能被广泛采用了。

再来问,为什么说 Redux 一定需要数据的 immutability,没有就一定是逻辑错误呢?因为 Redux 出于性能考虑使用了浅对比算法(shallow equality check)。浅对比进行的是引用对比,不比对值变化,因此需要 Redux 的用户自行建立「引用对比」和「值对比」之间的对应关系。这种关系事实上就是 immutability 的表述

* 如果数据的值改变了,那么它的引用也必须改变,dataEqualityCheck() 和 referenceEqualityCheck() 的比对结果都应该是 true
* 如果数据的值没有改变,那么它的引用也必须保持不变,dataEqualityCheck() 和 referenceEqualityCheck() 的比对结果都应该是 false

也就是说,immutability 是一种关系,它保证了 dataEqualityCheck()(deepEqualityCheck()) 和 referenceEqualityCheck()(shallowEqualityCheck()) 的结果总是相同的。Redux 需要这种关系,而这是需要开发者去保证的。

为什么说浅对比没有性能问题

与浅对比(引用对比)相对应的是深对比(值对比)。深对比能检测到对象是否深度值「相等」,但算法复杂度更高。这两种算法并没有必然说要使用哪一种,Redux 选择了使用浅对比,这就是框架本身的取舍与选择。这个取舍是美的,我认为恰如其分。如果使用了深对比,处理起真实项目上的大型对象一定会有性能问题。

为什么 Redux 一定要进行这样的「对比」

因为 Redux 需要在「数据发生变化」的时候,通知其监听者进行相应的动作。数据发生变化,既包含数据结构的变化,也包含数据中任何一层对象(或基本类型)值的变化。也即是说,要了解「数据是否发生变化」,Redux 必须进行精确的值对比

然而如上所说,直接进行值对比的代价太高,因此 Redux 做了这样的妥协:只进行引用对比,通过强制 引用变化 和 值变化 建立一一联系的方式,间接地进行值对比。

引用比对结果 与 值比对结果 必须等价,这个关系就是 immutability 的表述。这也是我为什么讲,Immutability 是 Redux 的必然推论。否则整个 Redux 就是逻辑错误的一个系统。

这个推论,其实也是被官方论证了的,见 这里

有兴趣的同学,可以看下面这段代码。「Redux 需要预设 Immutability」这个事情,更具体地说,是发生在 reduxcombineReducers() 这个方法中的:

function combineReducers(reducers) {
  const reducerKeys = Object.keys(reducers)
  const finalReducers = {}
  for (let i = 0; i < reducerKeys.length; i++) {
    const key = reducerKeys[i]

    if (process.env.NODE_ENV !== 'production') {
      if (typeof reducers[key] === 'undefined') {
        warning(`No reducer provided for key "${key}"`)
      }
    }

    if (typeof reducers[key] === 'function') {
      finalReducers[key] = reducers[key]
    }
  }
  const finalReducerKeys = Object.keys(finalReducers)

  let unexpectedKeyCache
  if (process.env.NODE_ENV !== 'production') {
    unexpectedKeyCache = {}
  }

  let shapeAssertionError
  try {
    assertReducerShape(finalReducers)
  } catch (e) {
    shapeAssertionError = e
  }

  return function combination(state = {}, action) {
    if (shapeAssertionError) {
      throw shapeAssertionError
    }

    if (process.env.NODE_ENV !== 'production') {
      const warningMessage = getUnexpectedStateShapeWarningMessage(state, finalReducers, action, unexpectedKeyCache)
      if (warningMessage) {
        warning(warningMessage)
      }
    }

    let hasChanged = false
    const nextState = {}
    for (let i = 0; i < finalReducerKeys.length; i++) {
      const key = finalReducerKeys[i]
      const reducer = finalReducers[key]
      const previousStateForKey = state[key]
      const nextStateForKey = reducer(previousStateForKey, action)
      if (typeof nextStateForKey === 'undefined') {
        const errorMessage = getUndefinedStateErrorMessage(key, action)
        throw new Error(errorMessage)
      }
      nextState[key] = nextStateForKey
      hasChanged = hasChanged || nextStateForKey !== previousStateForKey
    }
    return hasChanged ? nextState : state
  }
}

节选自 redux/src/combineReducers.js。2017-08-07 执行 npm install -g redux 取得的 redux 版本。

如何使用 Immutable?不同方案的对比?

综上所述,Immutable 其实是一种「关系」,一个「引用比对结果与值比对结果必须一致」的关系,即这个等式必须恒成立:dataEqualityCheck() === referenceEqualityCheck()。只要你的 Redux reducer 中返回的新 state 都满足这个关系,那么就等于说你实现了 Immutability。至于如果做到这点,大概有两种方式:

  • 使用原生 JS
  • 使用 Immutable 三方库

http://redux.js.org/docs/faq/ImmutableData.html#what-approaches-are-there-for-handling-data-immutably-do-i-have-to-use-immutablejs

原生 JS 优点是轻量、无需任何依赖,但缺点也突出:容易遗漏、容易产生冗余代码、大数量级数据下会有性能问题。

使用库则解决了原生 JS 的缺憾:引入后自动获得 Immutable 能力(由库�保证)、丰富强大的 API、大数据量下性能优秀、使得数据版本历史等变得简单。但毛病同样突出,以 ImmutableJS 这个库为例(我也不知道为什么官方要举这个库为例子)。以下部分缺憾可能是针对这个库而特有的。

ImmutableJS 的缺点

缺点解决方案Severity
会散得满地都是,调用点和使用点,弃用起来异常困难。引入一时爽,清除火葬场将应用的业务逻辑与数据结构解耦开🚨🚨🚨🚨🚨
toJS() 方法每次都会创建新对象,即便传入的值对象是相同的,两次调用生成的对象引用还是会不一样。这违反了 Immutable 定律的第一条:值不改变时引用也不能变。因此,在任何涉及 redux 的地方不恰当使用都可能造成逻辑错误,最典型例子就是在 react-redux 库的 mapStateToProps() 中使用了 toJS() 但又没改变对象值,此时会导致组件的非必要渲染别在涉及 redux 的场合使用 toJS() 方法🚨🚨🚨🚨
无法与正常的 JS 对象互操作了,同样也无法与接受原生 JS 对象的其他库(如 lodash、ramda 等)一起玩了。toJS() 方法能转成普通 JS 对象,但性能稍慢,而且反过来也无法跟 Immutable 对象一起玩了见一系列最佳实践🚨🚨🚨
调试困难。因为 值对象都被包裹在 Immutable 对象里,调试看到的都是 Immutable 对象的属性和方法装一个 Chrome 插件,把 Immutable 对象还原成原生 JS 对象🚨🚨🚨
对于频繁修改的小对象,Immutable 反而是性能负担。但官方文档认为,redux 应用的 redux tree 一般都是复杂的集合数据,这是 ImmutableJS 擅长的领域大项目无此问题,小项目无性能困扰🚨🚨
其取值 API 极其愚蠢,对比 teacher.students.henryteacher.getIn(['students', 'henry'])降低你的审美,实在想不开就别用🚨🚨
无法使用一些对象和数组的 ES6/7 语法糖了,语法可能变冗余别想太多,实在想不开就别用🚨

综上,ImmutableJS 用不用呢?官方认为,值得,因为「在 redux reducer 中修改了原对象可能导致的 bug,发现、调试成本极高」,而 Immutable 解决了这个问题(然后带出了其他3、4个问题)。这个成本我是同意的,组件不该渲染时可能重复渲染,可能该渲染时不渲染,而且你还不能确定是不是 mutated 数据带来的问题。

其他实现 immutable 的方案?

https://github.com/facebook/immutable-js: 所有章节都在讨论这个选择的可行性,应该是目前最主流的选择了。优点是性能好,API 强大;缺点是 API 不够友好,侵入性强,需要团队约束与背景知识,潜在移除成本高。

https://github.com/gajus/redux-immutable: 让 ImmutableJS 与 redux 一起工作的库。

https://github.com/rtfeldman/seamless-immutable: 解决了 ImmutableJS API 不够友好和侵入性强的问题。同时引入了两个小问题:在原生对象上加了扩展,以及仅支持较主流浏览器的高版本,因为依赖于浏览器实现。感觉上,这个库我会更倾向,因为既有丰富而友好的 API,又能享受性能上的优势,还对应用没有侵入性;那两个毛病稍小,在原生对象上加扩展这个事比较不能忍,但也没其他方法,能忍。

Redux + ImmutableJS 最佳实践

综上,作者认为,redux 的 immutability 必须有,虽然 ImmutableJS 诸多缺点,但还是要用滴。只不过用起来不易,于是给凡人们总结了一个最佳实践,主要就是解决上面提到能解决的大部分痛点,应慎读之:

痛点最佳实践
一旦使用,到处散播
  • 这篇文章 所说,将应用的业务逻辑和数据结构解耦开。相当于多做一个中间层
无法与原生 JS 互操作了,该用谁,什么地方用
  • 永远不要把原生 JS 和 ImmutableJS 混起来用
  • 建议整个 redux 树都用 ImmutableJS
  • 使用类似 redux-immutable 之类的库辅助
  • 除了 pure function(component),其他任何地方都用 ImmutableJS 而不用原生 JS。因为如果向展示型组件传入 ImmutableJS,props 值的获取就要依赖 ImmutableJS 的 get() 方法,这样就不 pure 了,维护、测试成本都变大
  • 所以当然,selector 和容器型组件中都要使用(返回)ImmtableJS 对象
  • 使用高阶组件(HOC)来把容器型组件中的 ImmutableJS 对象 map 成展示型组件中的原生 JS 对象作为 props
toJS() 违反 Immutable 定律第一条
  • 不要在mapStateToProps() 中用 toJS()
  • 在使用 ImmutableJS 的 updatemergeset 方法时,确保要更新的对象已经使用 fromJS() 包装过
难以调试

综上,诸多缺点中,多数都可以用(复杂的)最佳实践和项目规范来避免,尽管有一定的学习成本。基本无法解决的问题只有一个:一旦引入,去除掉可能就需要巨大的代价。

我的结论

总而言之,我得出的几点结论:

  • Immutable 是 redux reducer 所必须具备的属性。没有就逻辑错误
  • 使用 ImmutableJS 这个库来保证 immutability,除了 API 可读性审美方面的原因,主要的大问题有三点:
    • 侵入性很强,需要设计上的协调
    • 有大的移除成本
    • 团队约束与沟通成本
  • 考虑到以上的成本,结论是:小型项目能不用就不用 ImmutableJS;大型项目再看看有没有其他选择了

侵入性很强,主要是指很多地方需要「知道」ImmutableJS 的存在以及「如何使用它」,比如 reducer 的 initState、container component 的 mapStateToProps、HOC 的组件中要做个中间层把 Smart Component 中的 ImmutableJS toJS() 到 Dumb Component 中,等;推荐的解决方案是,把「如何使用」,操作具体数据结构的这部分从业务逻辑中解耦出去,让业务逻辑对数据的获取方式无感知。虽然解决了这个问题,但需要从设计上迁就这个库的引入,说是更强的侵入性也未可知。

团队约束与沟通成本,主要是指即便用了库,要保证最佳实践,团队依然需要掌握一些知识,比如不能在 mapStateToProps() 中使用 toJS()、Container Component -> HOC -> Pure function Component 的层次和数据流动方向(state -> toJS() -> JS)需要了解等。

Immutable 在其他语言中

以下内容尚未整理。

意义:

  • 不再有多线程问题
  • 持久化更简单了
  • 数据可以有版本追踪了。对于 history / redo / undo 这样的功能会很容易做
  • copy 只需要拷贝引用了,也变成常数级操作
  • 值对比其实就等同于引用对比了,复杂度变为常数
  • 可以有性能优化

缺点:

  • 出于性能和 API 考虑, 可能需要额外的库支持
  • 在小型数据上可能性能反而会下降
  • 需要团队纪律和沟通来保证实施 - Immutability 这个理念实施过程的约束 & 库使用过程的约束等

然而,这个来自函数式编程世界的概念,对于编程和解决业务问题又有什么意义呢?怎么映射过来呢?

所以,数据有两种属性:值和属性。数据又分为两种:基本类型和可嵌套类型(如数组、对象)。数据的「对比」,就有了两种对比方法:值对比和引用对比。

引用可以认为是一个复杂数据的「identity」,你内部的「数值」可以改变,但你的「标识」——就是这个引用——是不变的。而对于基本类型来说,只存在值对比,比如数字,3 和 4 不相等就是不相等,不存在数值不等,但「标识」相等的情况。这个「标识」是不存在的,基本类型只有值,只存在值对比。又比如布尔类型,真就是真,假就是假;比如字符串,这在不同语言中多也设计成为不可变的基本类型,因此只有值对比。

可嵌套类型的对比,就可以有不同的对比方式了。可以对比「标识」也即引用,也可以比对每一项属性「值」,看你的上下文在乎的是哪一个。对比「标识」的话,则没有办法反映到具体值的变化;对比「值」的话,比较彻底,性能一定会成问题。

于是,在「复杂类型对象对比」这个上下文中,如果你既想检测结果能反映具体值的变化,又想具有常数复杂度的优秀性能,你就需要其他规则的介入了。Immutability 就是这样的一条规则:

  • 如果对象值改变了,那么它的引用(也就是「标识」)一定也要改变
  • 如果对象值没有变化,那么它的引用一定也不能改变

这样一来,值变化 与 引用变化就建立了等价关系,你只需要比较引用,就可以以常数复杂度等价检测到「值是否变化」。

这个事情不知道怎么讲比较清楚呢。另外,所以是有什么意义呢?


实践了一段时间以后,目前觉得,seamless-immutable 是最佳方案。为什么呢?

先看一下我们要什么,我们要的是 redux reducer 的 immutability(不可变能力)。这种能力,无非就是满足以下两点:

  • 对象值没变时,引用也不能变
  • 对象值变了时,引用也要变

reducer 是个纯函数,相当于我们只是在 state = reducer(previousState, action) 这个出口,加一层 immutable 的保证,变成:state = ensureImmutable(reducer(previousState, action))。理想来说,除了 redux 的 reducer,应用的其他部分是不应该感知到这个变化的。

而 ImmutableJS 这样的库完全违反了这一点,一旦引入,应用处处都要知道和学习它的存在。就像 redux 一样,一旦引入,很难替换,是个很有风险的技术选型。而 seamless-immutable 做到了,只在 reducer 层加一层 immutable 的能力,而应用的其他部分无感知。

侵入性这个东西基本是不能忍的,不可能为了获得一个小的(虽然极为重要的) immutable 能力,耦合上整个应用的设计。综上,就凭这一点,可以断言重量级的 ImmutableJS 等库不是合理选择。它应该被以简单的方式实现。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

JSmiles

生命进入颠沛而奔忙的本质状态,并将以不断告别和相遇的陈旧方式继续下去。

0 文章
0 评论
84961 人气
更多

推荐作者

已经忘了多久

文章 0 评论 0

15867725375

文章 0 评论 0

LonelySnow

文章 0 评论 0

走过海棠暮

文章 0 评论 0

轻许诺言

文章 0 评论 0

信馬由缰

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文