React 栈(六):Immutable
Immutability
- ImmutableJS 作者 Lee Byron
- Redux Documentation:
- Immutable Data: http://redux.js.org/docs/faq/ImmutableData.html
- Using ImmutableJS: http://redux.js.org/docs/recipes/UsingImmutableJS.html
- React.js Conf 2015 - Immutable Data and React with Lee Byron: https://www.youtube.com/watch?v=I7IdS-PbEgI
- Immutable User Interfaces (Lee Byron) - Full Stack Fest 2016: https://www.youtube.com/watch?v=pLvrZPSzHxo
- Devchat 243 JSJ Immutable.js with Lee Byron: https://overcast.fm/+B1TFAefyQ
- 综述型文章:讲 Immutability 的一些优点和缺点:http://reactkungfu.com/2015/08/pros-and-cons-of-using-immutability-with-react-js/
- React 有关 immutable 的一些 anti-pattern: https://medium.com/@esamatti/react-js-pure-render-performance-anti-pattern-fb88c101332f
- https://auth0.com/blog/intro-to-immutable-js/
- https://www.reddit.com/r/javascript/comments/4rcqpx/dan_abramov_redux_is_not_an_architecture_or/d5rw0p9/?context=3
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」这个事情,更具体地说,是发生在 redux
的 combineReducers()
这个方法中的:
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 三方库
原生 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.henry 与 teacher.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 互操作了,该用谁,什么地方用 |
|
toJS() 违反 Immutable 定律第一条 |
|
难以调试 |
综上,诸多缺点中,多数都可以用(复杂的)最佳实践和项目规范来避免,尽管有一定的学习成本。基本无法解决的问题只有一个:一旦引入,去除掉可能就需要巨大的代价。
我的结论
总而言之,我得出的几点结论:
- 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 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论