chaussen

摘自Medium:在JavaScript中使用不可变数据(ImmutableJS),花这份功夫是否值得?——作者Alex Faunt

原文链接: medium.com

不可变JavaScript库(Immutable JS)值不值得用?

导语

我是一个前端开发人员,拥有四年工作经验,目前在一个大型软件团体里工作,制作一个以React框架和Redux库为基础建立起来的新单页程序

创作一个前所未有的网站,这对所有开发人员而言都有着令人兴奋的前景。我们会天真地眨着大眼睛,满满地抓起一把新技术,把它们全投入到这个node平台服务器上去,再抽身引退,对自己那领先时代的天赋惊叹不已。

选择的技术之中,有一个是Facebook公司的”不可变(Immutable)“软件库。我们准备利用这个库来实现数据的表现方式,加强数据的不可变性immutability,以此为开始,建立起面向功能的编程模式。这篇帖子就是要对其进行一次审视。

不可变数据与Redux库

不可变数据是面向功能编程(functional programming)的核心概念,这种概念在JavaScript中的应用已渐占优势。使用React框架和Redux库时,不可变数据能帮助巩固这两者的核心原则:如果程序状态(app state)没有发生改变,那网页的文档对象模型(DOM)也不用改变。

不少文章已经写到过使用不可变数据的优点,主要包括:

  • 简化贯穿程序的数据流

  • 不再需要数据复制的防御机制

  • 优化对数据变化的检测

  • 通过记忆化(memoization)技术提高程序性能

”不可变(Immutable)“库

”不可变(Immutable)“库是Facebook公司的一个开源软件库。我们使用redux-immutable模块将这个库整合进我们的程序,这样我们就能以”Immutable“库提供的数据类型来存储程序状态(app state)了。

要将程序状态(app state)渲染成网页,我们得把状态数据从Redux的存储对象(store)中转移到React组件里去。这是通过react-redux模块的”connect()“修饰函数来实现的。

在程序开发过程中,我们注意到了以下优点和缺点。

[优点]强化了不可变性

不管选用哪个库,使用不可变数据类型的头一条理由肯定是能够保证做项目的人不能违反不可变原则。

严格地说,”不可变(Immutable)“库有助于简化开发过程,因为大家不再需要在代码中追踪数据,寻找数据变更的位置。不可变数据类型取而代之,能始终精确表现当前存储对象(store)中存储的程序状态(app state)。

有了这个库,我们就能发挥上述不可变数据类型的优点,似乎没什么不好的。然而,缺点也确实存在,而且等到开发工作正式开始时,这些缺点才显露了出来。

[缺点]文档与调试

Facebook给前端开发人员提供的不仅仅是一个软件框架,而是整个程序制作的软件生态系统。然而,和React之类的框架比起来,”不可变(Immutable)“库的文档极其不完整。

不清楚”不可变(Immutable)“库句法,或者代码无法像预想的那样起作用时,开发人员都会求助于文档,不过常常是看了还不明白。代码为什么不对?既然看了还不明白,最终大家都会使用终端日志”console.log()“大法。不过很可惜,用日志审查数据时会发现自己一直在自定义数据类型的属性里翻来翻去。

终端日志打印出来的”不可变(Immutable)“库对象

要解决这个问题,可以在任何”不可变(Immutable)“库的对象上调用”toJS()“函数,把对象转换成一个纯JavaScript对象,再打印出来。但这类小问题会减缓开发速度,要是文档能再完善点,情况就会更好些。

不管怎么样,如果仅仅为了确定当前有什么数据就要看文档、作调试,那作为制作程序的基础来说真不怎么样。

[缺点]有反模式化的酸腐气息

我们可以通过”connect()“修饰函数,从程序的存储对象(store)中取得数据,以此访问”不可变(Immutable)“库的数据对象。但我们团队以前通常会用原生数据类型写组件。为了转换数据,我们开发了一个模式,在”connect()“修饰函数中用了”toJS()“函数,如下所示:

// 从存储对象(store)里获取数据 
[@connect](http://twitter.com/connect "Twitter profile for @connect")((state) => {
  // 将存储对象(store)数据转换成原生JavaScript对象
  user: state.get(”user“).toJS(), 
  wine: state.getIn([”drinks“, ”wines“]).toJS()
})
class HelloWine extends Component {
  render() {
    // 用ES6版本格式把属性(props)里的数据解构出来
    const { user, wines: { houseRed } } = this.props
    return <div>{`Hi ${ user }! Fancy some ${ houseRed }?`}</div>
  }
}

这个模式看起来很方便也很安全,但用在移动设备上时,我们发现启动Redux的行为(actions)功能慢得受不了。下面是在三星S5上打开程序侧边菜单时记录下来的JavaScript性能剖析。

低效渲染剖析

打开菜单用了2秒多,对一个用前沿技术做的网站程序来说可不怎么爽!

我们对程序运行进行了追踪,发现上面写的那个模式就是问题所在。在后台发生的情况是Redux把行为对象(action)发送到存储对象(store),然后用”reducer()“函数产生的新状态(state)更新存储对象(store)。

组件用”connect()“函数修饰以后,每次都会检查数据是否更新。数据有更新,组件才会通过React生命周期触发重渲染。这使Redux库能选择性地渲染React框架组件,提升性能。

每次运行”connect()“函数时,通过”toJS()“函数,程序状态(app state)都被转换成了一个原生JavaScript对象,每次都会产生一个新的对象。因此和之前的状态相比,即使当前的”不可变(Immutable)“库对象没有变化,产生的对象仍然是不同的。换句话说,任何行为(action)发动时,每个用”connect()“函数修饰的元素以及子元素都会被重新渲染过。

如果别的都记不住,那记住这点:toJS()函数绝对不要在”connect()“修饰函数中调用。

[缺点] 不怎么符合ES6版本的格式

如果程序状态(app state)存储在”不可变(Immutable)“库数据类型中的话,那我们的组件也应该运用同样的数据类型,就这样决定了。于是我们照此重组了代码,却产生了一个很大的缺陷,那就是原生功能的缺失。

比如,ES6的解构(de-structuring)功能现在就变成了几个”get()“函数和”getIn()“函数调用的结合。

const { wines: { houseRed: { name, year } } } = this.props

// 变成
const { wines } = this.props
const name = wines.getIn([”houseRed“, ”name“])
const year = wines.getIn([”houseRed“, ”year“])

代码变长了,没那么漂亮了。而且个人而言,我不喜欢用那么多字符串,因为如果打错一个字,本来程序会抛出JavaScript错误,提醒错误所在,现在能得到的只是一个“未定义”,而真正的问题可能无法发现。

另外,ES6版本的展开句法(spreading)功能也丢失了,这会使属性重新赋值的语句变得很冗长。

<AmazingComponent { …props } />
<AmazingComponent prop1={ props.prop1 } prop2={ props.prop2 } prop3={ props.prop3 } />

这些”不可变(Immutable)“库句法的缺点触及到了我们的痛处,又让我想起了为什么一开始要做那个转换模式。如果核心数据类型处理出了问题,即使都是些小问题,也会让人感到沮丧,又会浪费更多宝贵的开发时间。

结果

为了使用”不可变(Immutable)“库,我们重组了一些组件的代码。然后我们重新评估了目前所处的局面,讨论了上面讲的那些方面,结论就是”不可变(Immutable)“库唯一的好处就是能强化不可变性,但意义何在?面向功能编程真正的意思是大家不要尝试去修改状态,所以状态的具体数据类型是不是可变只是个技术问题,和思路没有关系。

在使用”不可变(Immutable)“库过程中我们考虑了所有的缺点,最终决定把它从项目里完全移除。只要遵循面向功能编程的原则,我们就有信心处理自己的数据。我们对开发人员的信任,加上相互之间的代码审查已经足够保证不犯低级错误了。

感谢阅读,希望有所帮助!