更简单,更快速的建立不可变数据类型前端
做者:张钊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 能够知足简单的需求,可是真正在生产工做中,咱们须要考虑很是多的因素。
举例来讲:
由于有太多不肯定因素,因此在真正的工程实践中,仍是推荐你们使用大型开源项目里面的工具函数。比较经常使用的为你们所熟知的就是 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
这里分别举例一下每一个横坐标所表明的含义:
经过上图的观察,基本能够得出如下对比结果:
总结
从上面的例子中咱们也能够总结一下对比 immutable-js 和 immer 之间的优势和不足:
字节跳动商业化前端团队招人啦!能够每日与技术网红畅谈理想、参加技术大牛交流分享、享受一日四餐、顶级极客装备、免费健身房,与优秀的人作有挑战的事情,这种地方哪里找?快来加入咱们吧!
简历投递邮箱: sunyunchao@bytedance.com