immer.js:更适合你的处理「不可变数据」的库

发表于 3年以前  | 总阅读数:280 次

在 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

这种去除引用数据类型副作用的数据的概念我们称作 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

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 中的proxy

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.jsgithub.com[1]

img

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

- 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,所以需要一部分前期效率的成本。

参考资料

[1]https://github.com/immerjs/immer/blob/master/performance_tests/add-data.jsgithub.com: https://link.zhihu.com/?target=https%3A//github.com/immerjs/immer/blob/master/performance_tests/add-data.js

本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/OakYGm_UH__sGIaIwfaJpQ

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237231次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8065次阅读
 目录