Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React 栈(六):Immutable #185

Open
13 of 18 tasks
EthanLin-TWer opened this issue Aug 8, 2017 · 4 comments
Open
13 of 18 tasks

React 栈(六):Immutable #185

EthanLin-TWer opened this issue Aug 8, 2017 · 4 comments
Assignees

Comments

@EthanLin-TWer
Copy link
Owner

EthanLin-TWer commented Aug 8, 2017

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 就是这样的一条规则:

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

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

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


          @EthanLin-TWer
          Copy link
          Owner Author

          React.js Conf 2015 - Immutable Data and React with Lee Byron: https://www.youtube.com/watch?v=I7IdS-PbEgI

          how - persistent(remain previous input immutable) immutable(never changed once created) data structures - performance - interfaces & implmentations - dag(Directed Acyclic Graph) algorithm - Trie for arrays and objects that're not primitives

          j.c.r licklider - trie, alan kay - smalltalk, phil bagwell - basic hash array, rich hickey - simple made easy simplicity is hard work

          why -

          @EthanLin-TWer
          Copy link
          Owner Author

          EthanLin-TWer commented May 29, 2018

          实践了一段时间以后,目前觉得,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 等库不是合理选择。它应该被以简单的方式实现。

          @JimmyLv
          Copy link

          JimmyLv commented May 30, 2018

          咦? 为啥只搜到了 React 栈(六)和(二),其他部分还没建issue吗🤣

          @Kevin170113664
          Copy link

          学习了,感觉用了Immutable就想用了一种另外的语言……它给人带来的不适感真是值得斟酌。
          一旦大规模使用就难以根除,其实如果有在immutable使用上不畏艰险写满测试的话,还是能有信心完全根除的。如果不是的话,恐怕只会越改越心虚。

          Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
          Projects
          None yet
          Development

          No branches or pull requests

          3 participants