2

更简单,更快速的创建不可变数据类型

作者:张钊


在 JS 中对象的使用需要格外注意引用问题,断绝引用的方式常见有深拷贝。但是深拷贝相比较而言比较消耗性能。本文主要简介 immutable-js 和 immer 两个处理「不可变数据」的库,同时简单分析了 immer 的实现方式,最后通过测试数据,对比总结了 immutable-js 和 immer 的优劣。

JS 里面的变量类型可以分为 基本类型引用类型

在使用过程中,引用类型经常会产生一些无法意识到的副作用,所以在现代 JS 开发过程中,有经验的开发者都会在特定位置有意识的写下断开引用的不可变数据类型。

// 因为引用所带来的副作用:

var a = [{ val: 1 }]

var b = a.map(item => item.val = 2

// 期望:b 的每一个元素的 val 值变为 2,但最终 a 里面每个元素的 val 也变为了 2

console.log(a[0].val) // 2

从上述例子我们可以发现,本意是只想让 ​b​ 中的每一个元素的值变为 2 ,但却无意中改掉了 ​a​ 中每一个元素的结果,这当然是不符合预期的。接下来如果某个地方使用到了 ​a​ ,很容易发生一些我们难以预料并且难以 debug 的 bug (因为它的值在不经意间被改变了)。

在发现这样的问题之后,解决方案也很简单。一般来说当需要传递一个引用类型的变量(例如对象)进一个函数时,我们可以使用 ​Object.assign​ 或者 ​...​ 对对象进行解构,成功断掉一层的引用。

例如上面的问题我们可以改用下面的这种写法:

var a = [{ val: 1 }]

var b = a.map(item => ({ ...item, val: 2 }))

console.log(a[0].val) // 1

console.log(b[0].val) // 2

但是这样做会有另外一个问题,无论是 ​Object.assign​ 还是 ​...​ 的解构操作,断掉的引用也只是一层,如果对象嵌套超过一层,这样做还是有一定的风险。

// 深层次的对象嵌套,这里 a 里面的元素对象下又嵌套了一个 desc 对象`

var a = [{

  val: 1,

  desc: { text: 'a' }

}]

var b = a.map(item => ({ ...item, val: 2 }))

console.log(a === b) // false

console.log(a[0].desc === b[0].desc) // true

b[0].desc.text = 'b';          // 改变 b 中对象元素对象下的内容

console.log(a[0].desc.text); // b (a 中元素的值无意中被改变了)

​a[0].desc === b[0].desc​ 表达式的结果仍为 true,这说明在程序内部 ​a[0].desc​ 和 ​b[0].desc​ 仍然指向相同的引用。如果后面的代码一不小心在一个函数内部直接通过 ​b[0].desc​ 进行赋值,就一定会改变具有相同引用的 ​a[0].desc​ 部分的结果。例如上面的例子中,通过「点」直接操作 ​b​ 中的嵌套对象,最终也改变了 ​a​ 里面的结果。

所以在这之后,大多数情况下我们会考虑 深拷贝 这样的操作来完全避免上面遇到的所有问题。深拷贝,顾名思义就是在遍历过程中,如果遇到了可能出现引用的数据类型(例如前文中举例的对象 Object),就会递归的完全创建一个新的类型。

// 一个简单的深拷贝函数,只做了简单的判断

// 用户态这里输入的 obj 一定是一个 Plain Object,并且所有 value 也是 Plain Object

function  deepClone(obj) {

  const keys = Object.keys(obj)

  return keys.reduce((memo, current) => {

    const value = obj[current]

    if (typeof value === 'object') {

      // 如果当前结果是一个对象,那我们就继续递归这个结果

      return {

        ...memo,

        [current]: deepClone(value),

      }
    }
    return {

      ...memo,

      [current]: value,

    }

  }, {})
}

用上面的 deepClone 函数进行简单测试

var a = {

  val: 1,

  desc: {

    text: 'a'

  },
};

var b = deepClone(a)

b.val = 2

console.log(a.val) // 1

console.log(b.val) // 2

b.desc.text = 'b'

console.log(a.desc.text) // 'a'

console.log(b.desc.text) // 'b'

上面的这个 ​deepClone​ 可以满足简单的需求,但是真正在生产工作中,我们需要考虑非常多的因素。

举例来说:

  • key 里面 getter,setter 以及原型链上的内容如何处理?
  • value 是一个 Symbol 如何处理?
  • value 是其他非 Plain Object 如何处理?
  • value 内部出现了一些循环引用如何处理?

因为有太多不确定因素,所以在真正的工程实践中,还是推荐大家使用大型开源项目里面的工具函数。比较常用的为大家所熟知的就是 ​lodash.cloneDeep​,无论是安全性还是效果都有所保障。

其实,这种去除引用数据类型副作用的数据的概念我们称作 immutable,意为不可变的数据,其实理解为不可变关系更为恰当。每当我们创建一个被 ​deepClone​ 过的数据,新的数据进行有副作用 (side effect) 的操作都不会影响到之前的数据,这也就是 immutable 的精髓和本质。

这里的副作用不只局限于通过「点」操作对属性赋值。例如 array 里面的 ​push​, ​pop​ , ​splice​ 等方法操作都是会改变原来的数组结果,这些操作都算是非 immutable。相比较而言,​slice​ , ​map​ 这类返回操作结果为一个新数组的形式,就是 immutable 的操作。

然而 deepClone 这种函数虽然断绝了引用关系实现了 immutable,但是相对来说开销太大(因为他相当于完全创建了一个新的对象出来,有时候有些 value 我们不会进行赋值操作,所以即使保持引用也没关系)。

所以在 2014 年,facebook 的 immutable-js 横空出世,即保证了数据间的 immutable ,在运行时判断数据间的引用情况,又兼顾了性能。

immutable-js 简介

immutable-js 使用了另一套数据结构的 API ,与我们的常见操作有些许不同,它将所有的原生数据类型(Object, Array等)都会转化成 immutable-js 的内部对象(Map,List 等),并且任何操作最终都会返回一个新的 immutable 的值。

上面的例子使用 immutable-js 就需要这样改造一下:

const { fromJS } = require('immutable')

const data = {

  val: 1,

  desc: {

    text: 'a',

  },

}

// 这里使用 fromJS 将 data 转变为 immutable 内部对象

const a = fromJS(data)

// 之后我们就可以调用内部对象上的方法如 get getIn set setIn 等,来操作原对象上的值

const b = a.set('val', 2)

console.log(a.get('val')) // 1

console.log(b.get('val')) // 2

const pathToText = ['desc', 'text']

const c = a.setIn([...pathToText], 'c')

console.log(a.getIn([...pathToText])) // 'a'

console.log(c.getIn([...pathToText])) // 'c'

对于性能方面,immutable-js 也有它的优势,举个简单的例子:

const { fromJS } = require('immutable')

const data = {

  content: {

    time: '2018-02-01',

    val: 'Hello World',

  },

  desc: {

    text: 'a',

  },

}

// 把 data 转化为 immutable-js 中的内置对象

const a = fromJS(data)

const b = a.setIn(['desc', 'text'], 'b')

console.log(b.get('desc') === a.get('desc')) // false

// content 的值没有改动过,所以 a 和 b 的 content 还保持着引用

console.log(b.get('content') === a.get('content')) // true

// 将 immutable-js 的内置对象又转化为 JS 原生的内容

const c = a.toJS()

const d = b.toJS()

// 这时我们发现所有的引用都断开了

console.log(c.desc === d.desc) // false

console.log(c.content === d.content) // false

从上面的例子可以看出来,我们操作 immutable-js 的内置对象过程中,改变了 ​desc​ 对象下的内容。但实际上 ​content​ 的结果我们并没有改动。我们通过 ​===​ 进行比较的过程中,就能发现 ​desc​ 的引用已经断开了,但是 ​content​ 的引用还保持着连接。

在 immutable-js 的数据结构中,深层次的对象 在没有修改的情况下仍然能够保证严格相等,这也是 immutable-js 的另一个特点 「深层嵌套对象的结构共享」。即嵌套对象在没有改动前仍然在内部保持着之前的引用,修改后断开引用,但是却不会影响之前的结果。

经常使用 React 的同学肯定也对 immutable-js 不陌生,这也就是为什么 immutable-js 会极大提高 React 页面性能的原因之一了。

当然能够达到 immutable 效果的当然不只这几个个例,这篇文章我主要想介绍实现 immutable 的库其实是 immer。

immer 简介

immer 的作者同时也是 mobx 的作者。mobx 又像是把 Vue 的一套东西融合进了 React,已经在社区取得了不错的反响。immer 则是他在 immutable 方面所做的另一个实践。

与 immutable-js 最大的不同,immer 是使用原生数据结构的 API 而不是像 immutable-js 那样转化为内置对象之后使用内置的 API,举个简单例子:

const produce = require('immer')

const state = {

  done: false,

  val: 'string',

}

// 所有具有副作用的操作,都可以放入 produce 函数的第二个参数内进行

// 最终返回的结果并不影响原来的数据

const newState = produce(state, (draft) => {

  draft.done = true

})

console.log(state.done)    // false

console.log(newState.done) // true

通过上面的例子我们能发现,所有具有副作用的逻辑都可以放进 produce 的第二个参数的函数内部进行处理。在这个函数内部对原来的数据进行任何操作,都不会对原对象产生任何影响。

这里我们可以在函数中进行任何操作,例如 ​push​ ​splice ​ 等非 immutable 的 API,最终结果与原来的数据互不影响。

Immer 最大的好处就在这里,我们的学习没有太多成本,因为它的 API 很少,无非就是把我们之前的操作放置到 ​produce​ 函数的第二参数函数中去执行。

immer 原理解析

Immer 源码中,使用了一个 ES6 的新特性 Proxy 对象。Proxy 对象允许拦截某些操作并实现自定义行为,但大多数 JS 同学在日常业务中可能并不经常使用这种元编程模式,所以这里简单且快速的介绍一下它的使用。

Proxy 对象接受两个参数,第一个参数是需要操作的对象,第二个参数是设置对应拦截的属性,这里的属性同样也支持 get,set 等等,也就是劫持了对应元素的读和写,能够在其中进行一些操作,最终返回一个 Proxy 对象实例。

const proxy = new  Proxy({}, {

  get(target, key) {

    // 这里的 target 就是 Proxy 的第一个参数对象

    console.log('proxy get key', key)

  },

  set(target, key, value) {

    console.log('value', value)

  }

})

// 所有读取操作都被转发到了 get 方法内部

proxy.info  // 'proxy get key info'

// 所有设置操作都被转发到了 set 方法内部

proxy.info = 1  // 'value 1'

上面这个例子中传入的第一个参数是一个空对象,当然我们可以用其他已有内容的对象代替它,也就是函数参数中的 target。

immer 的做法就是维护一份 state 在内部,劫持所有操作,内部来判断是否有变化从而最终决定如何返回。下面这个例子就是一个构造函数,如果将它的实例传入 Proxy 对象作为第一个参数,就能够后面的处理对象中使用其中的方法:

class  Store  {

  constructor(state) {

    this.modified = false

    this.source = state

    this.copy = null

  }

  get(key) {

    if (!this.modified) return  this.source[key]

    return  this.copy[key]

  }

  set(key, value) {

    if (!this.modified) this.modifing()

    return  this.copy[key] = value

  }

  modifing() {

    if (this.modified) return

    this.modified = true

    // 这里使用原生的 API 实现一层 immutable,

    // 数组使用 slice 则会创建一个新数组。对象则使用解构

    this.copy = Array.isArray(this.source)

      ? this.source.slice()

      : { ...this.source }

  }

}

上面这个 Store 构造函数相比源代码省略了很多判断的部分。实例上面有 ​modified​,​source​,​copy​ 三个属性,有 ​get​,​set​,​modifing​ 三个方法。​modified​ 作为内置的 flag,判断如何进行设置和返回。

里面最关键的就应该是 ​modifing​ 这个函数,如果触发了 setter 并且之前没有改动过的话,就会手动将 ​modified​ 这个 flag 设置为 ​true​,并且手动通过原生的 API 实现一层 immutable。

对于 Proxy 的第二个参数,在简版的实现中,我们只是简单做一层转发,任何对元素的读取和写入都转发到 store 实例内部方法去处理。

const PROXY_FLAG = '@@SYMBOL_PROXY_FLAG'

const handler = {

  get(target, key) {

    // 如果遇到了这个 flag 我们直接返回我们操作的 target

    if (key === PROXY_FLAG) return target

    return target.get(key)

  },

 set(target, key, value) {

    return target.set(key, value)

  },

}

这里在 getter 里面加一个 flag 的目的就在于将来从 proxy 对象中获取 store 实例更加方便。

最终我们能够完成这个 ​produce​ 函数,创建 ​store​ 实例后创建 ​proxy​ 实例。然后将创建的 ​proxy​ 实例传入第二个函数中去。这样无论在内部做怎样有副作用的事情,最终都会在 ​store​ 实例内部将它解决。最终得到了修改之后的 ​proxy​ 对象,而 ​proxy​ 对象内部已经维护了两份 ​state​ ,通过判断 ​modified​ 的值来确定究竟返回哪一份。

function  produce(state, producer) {

  const store = new Store(state)

  const proxy = new  Proxy(store, handler)

  // 执行我们传入的 producer 函数,我们实际操作的都是 proxy 实例,所有有副作用的操作都会在 proxy 内部进行判断,是否最终要对 store 进行改动

  producer(proxy)

  // 处理完成之后,通过 flag 拿到 store 实例

  const newState = proxy[PROXY_FLAG]
 
  if (newState.modified) return newState.copy

  return newState.source

}

这样,一个分割成 Store 构造函数,handler 处理对象和 produce 处理 state 这三个模块的最简版就完成了,将它们组合起来就是一个最最最 tiny 版的 immer ,里面去除了很多不必要的校验和冗余的变量。但真正的 immer 内部也有其他的功能,例如上面提到的深层嵌套对象的结构化共享等等。

当然,Proxy 作为一个新的 API,并不是所有环境都支持,Proxy 也无法 polyfill,所以 immer 在不支持 Proxy 的环境中,使用 ​Object.defineProperty​ 来进行一个兼容。

性能

我们用一个简单测试来测试一下 immer 在实际中的性能。这个测试使用一个具有 100k 状态的状态树,我们记录了一下操作 10k 个数据的时间来进行对比。

freeze 表示状态树在生成之后就被冻结不可继续操作。对于普通 JS 对象,我们可以使用 ​Object.freeze​ 来冻结我们生成的状态树对象,当然像 immer / immutable-js 内部自己有冻结的方法和逻辑。

具体测试文件可以点击查看:https://github.com/immerjs/immer/blob/master/__performance_tests__/add-data.js

这里分别举例一下每个横坐标所代表的含义:

  • just mutate:直接通过原生操作进行操作,freeze 就直接调用 Object.freeze 冻结整个对象。
  • deepclone:通过深拷贝来复制原来的数据,freeze 后的时间指冻结这个深拷贝对象的时间。
  • reducer:指我们手动通过 ​...​ 或者 ​Object.assign​ 这类原生 immutable API 来处理我们的数据,freeze 后的时间代表我们冻结这个我们创建出来的新内容的时间。
  • Immutable js: 指我们通过 immutable-js 来操作数据。toJS 指将内置的 immutable-js 对象转化为原生 js 内容。
  • Immer: 分别测试了在支持 Proxy 的环境和在不支持 Proxy 使用 defineProperty 环境下的数据。

通过上图的观察,基本可以得出以下对比结果:

  • 从 mutate 和 deepclone 来看,mutate 基准确定了数据更改费用的基线,deepclone 深拷贝因为没有结构共享,所以效率会差很多。
  • 使用 Proxy 的 immer 大概是手写 reducer 的两倍,当然这在实践中可以忽略不计。
  • immer 大致和 immutable-js 一样快。但是,immutable-js 最后经常需要 toJS 操作,这里的性能的开销是很大的。例如将不可变的 JS 对象转换回普通的对象,将它们传递给组件中,或着通过网络传输等等(还有将从例如服务器接收到的数据转换为 immutable-js 内置对象的前期成本)。
  • immer 的 ES5 版本中使用 ​defineProperty​ 来实现,它的测试速度明显较慢。所以尽量在支持 ​Proxy​ 的环境中使用 immer。
  • 在 freeze 的版本中,只有 mutate,deepclone 和原生 reducer 才能够递归地冻结全状态树,而其他测试用例只冻结树的修改部分。

总结

从上面的例子中我们也可以总结一下对比 immutable-js 和 immer 之间的优点和不足:

  • Immer 的 API 非常简单,上手几乎没有难度,同时项目迁移改造也比较容易。immutable-js 上手就复杂的多,使用 immutable-js 的项目迁移或者改造起来会稍微复杂一些。
  • Immer 需要环境支持 ​Proxy​ 和 ​defineProperty​,否则无法使用。但 immutable-js 支持编译为 ES3 代码,适合所有 JS 环境。
  • Immer 的运行效率受到环境因素影响较大。immutable-js 的效率整体来说比较平稳,但是在转化过程中要先执行 ​fromJS​ 和 ​toJS​,所以需要一部分前期效率的成本。

字节跳动商业化前端团队招人啦!可以每日与技术网红畅谈理想、参加技术大牛交流分享、享受一日四餐、顶级极客装备、免费健身房,与优秀的人做有挑战的事情,这种地方哪里找?快来加入我们吧!

简历投递邮箱: sunyunchao@bytedance.com


前端魔法师
82 声望7 粉丝