immer.js 简介及源码简析 -- 更简单,更快速的建立不可变数据类型

更简单,更快速的建立不可变数据类型前端

做者:张钊git


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

JS 里面的变量类型能够分为 基本类型引用类型github

在使用过程当中,引用类型常常会产生一些没法意识到的反作用,因此在现代 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),就会递归的彻底建立一个新的类型。ide

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

// 用户态这里输入的 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

相关文章
相关标签/搜索