前言
由于JavaScript中引用类型的存在,使得我们在使用的过程中,很容易发生一些可预知的副作用,比如下面的例子:
let a = {
name: 'zhangsan'
}
let b = a
b.name = 'lisi'
显然,上面的例子中a的属性name也被修改了。对于上面的数据结构来说,也许我们浅拷贝就能够解决这个问题:
let b = {...a}
b.name = 'lisi'
但是浅拷贝只能断开一层引用,如果数据嵌套多层,那么我们就需要使用深拷贝。
深拷贝
深拷贝我们往往有两种方式:JSON.parse(JSON.stringify())和递归深拷贝。
第一种做法存在例如function、Date数据、正则无法拷贝,无法处理循环对象等等一系列问题。
递归深拷贝一般是工具库中的深拷贝实现方式,例如lodash。这种做法虽然能解决第一种方式的局限性,但是对于庞大的数据来说性能并不好,因为需要把整个对象从头到晚全部遍历一遍。
假设我们需要频繁的去操作一个复杂对象,如果每次都这样深拷贝,那效率就实在是太低了。因为大部分场景下,我们只是更新了这个对象的一两个字段,其他字段不变。对那些不变的字段在进行递归拷贝,就显得非常多余。
immer的思想
大佬的看法
首先我们来看看大佬Dan Abramov(react核心开发,redux作者)对于深拷贝的看法:
大佬觉得深拷贝不是一个好的推荐,所以推荐了imeer,甚至在字节的内部分享中,Dan也推荐了immer。
整个访谈录可以查看链接:Dan Abramov 访谈实录
理解immer的思想
immer是基于copy-on-write 机制。它的基本思想是,所有的更改都应用于临时的draft数据中,它是current(原始)数据的代理。当完成所有的变更后,才基于draft数据生成最后的next数据。这意味着可以通过简单的修改数据而与数据进行交互,并且同时保留不可变数据的引用的优点。
这个思想的思路就是:我们在做深拷贝的时候,只有当属性发生变化,才对这部分数据进行深拷贝,对于没有发生变化的属性,直接拿引用即可。
那么,我们有没有什么方法知道用户修改了哪些属性呢,答案是proxy。
实现
首先,我们理一下思路:
- 拦截get,判断属性是否修改,如果属性修改了,则从draft数据中取值,否则从原数据中取值。
- 拦截set,所有的赋值操作都在draft数据中操作,阻止对原数据进行操作。
- 遍历原数据,判断属性是否修改,如果修改则把draft数据的属性赋值给新对象,并且递归这部分变化的数据。否则直接返回原属性,并且不再遍历该属性的子属性,提高性能。
总共步骤其实就这三步。那么,现在一步步来实现它,gogogo!!!
实现生成draft函数
// draft的标识符
const DRAFTABLE = Symbol.for('immer-draftable');
// 是否是原生对象
const isPlainObject = value => {
const objectCtorString = Object.prototype.constructor.toString()
if (!value || typeof value !== "object") return false
const proto = Object.getPrototypeOf(value)
if (proto === null) {
return true
}
const Ctor =
Object.hasOwnProperty.call(proto, "constructor") && proto.constructor
if (Ctor === Object) return true
return (
typeof Ctor == "function" &&
Function.toString.call(Ctor) === objectCtorString
)
}
// 判断是否是一个draft(一个代理对象)
const isDraftable = value => !!value && !!value[DRAFTABLE]
const drafts = new Map()
// 获取draft
const getDraft = data => {
if (isDraftable(data)) {
return data
}
if (isPlainObject(data) || Array.isArray(data)) {
if (drafts.has(data)) {
return drafts.get(data)
}
const draft = new Proxy(data, objectTraps)
drafts.set(data, draft)
return draft
}
return data
}
- 首先,我们判断传入的数据是不是一个draft对象,如果是的话就直接返回。这里判断的核心是通过这个对象是否存在
value[DRAFTABLE],因为只有draft对象才会触发我们自定义的拦截get函数(这个稍后会将,其实就拦截函数中会判断如果key是DRAFTABLE就返回target,也就是他的原生数据)。 - 然后,我们判断是否是一个正常的Object对象或者数组,
isPlainObject方法就是比较普遍的判断原生对象的方法(我就直接拷贝了源码里的这个方法)。 - 如果是原生对象或者数组,我们先判断这个数据对应的draft对象是否创建过,创建过就直接从draft数据池中取就好了。
- 最后,如果这个原生对象对应的draft没有被创建过,我们就新建一个draft对象,并且把它存放在draft数据池中就好了。
下面,来看看怎么实现draft的拦截函数。
实现draft的拦截
const copies = new Map()
const objectTraps = {
get(target, key) {
if (key === DRAFTABLE) return target
const data = copies.get(target) || target
return getDraft(data[key])
},
set(target, key, val) {
const copy = shallowCopy(target)
const newValue = getDraft(val)
copy[key] = isDraftable(newValue) ? newValue[DRAFTABLE] : newValue
return true
}
}
const shallowCopy = data => {
if (copies.has(data)) {
return copies.get(data)
}
const copy = Array.isArray(data) ? data.slice() : { ...data }
copies.set(data, copy)
return copy
}
- 拦截
get的时候,我们判断key是不是DRAFTABLE(draft的标识),如果是的话就说明被访问的对象是一个draft,我们需要把正确的target(原生数据)返回出去,否则拿到的会是一个proxy。如果不是,那就从浅拷贝(copies)数据池中取,然后就正常返回了。 - 拦截set的时候,首先我们生成一个浅拷贝对象,因为赋值操作我们需要在浅拷贝对象中操作,否则会影响原数据。然后组对新赋予的值进行一次getDraft操作(防止新值也是个对象或数组)。最后把新值赋给copy,需要注意的是,copy[key]的时候需要判断新值最后是不是一个draft,如果是draft,我们需要返回原对象,否则拿到的就是一个proxy了。
- 浅拷贝就很简单,就不赘述了。
接下来,就是在完成所有操作后,生成最后的拷贝对象了。
生成拷贝对象
const isChange = data => {
if (drafts.has(data) || copies.has(data)) return true
}
const finalize = data => {
if (isPlainObject(data) || Array.isArray(data)) {
if (!isChange(data)) {
return data
}
const copy = shallowCopy(data)
Object.keys(copy).forEach(key => {
copy[key] = finalize(copy[key])
})
return copy
}
return data
}
- 首先,判断是不是对象或者数组,如果不是直接返回就好了。
- 如果是数组或者对象,那么我们判断这个数据是否修改过,如果没修改过,也就不需要递归了,直接把整个数据返回。
- 如果修改过,那么我们就从copy数据池中取值,并且把整个copy的整个属性递归调用。
最后,我们就需要把这些函数整合在一起。
整合输出
function produce(baseState, fn) {
// ...
const draft = getProxy(baseState)
fn(draft)
return finalize(baseState)
}
完整实现
附上完整代码
// draft的标识符
const DRAFTABLE = Symbol.for('immer-draftable');
// 是否是原生对象
const isPlainObject = value => {
const objectCtorString = Object.prototype.constructor.toString()
if (!value || typeof value !== "object") return false
const proto = Object.getPrototypeOf(value)
if (proto === null) {
return true
}
const Ctor =
Object.hasOwnProperty.call(proto, "constructor") && proto.constructor
if (Ctor === Object) return true
return (
typeof Ctor == "function" &&
Function.toString.call(Ctor) === objectCtorString
)
}
// 判断是否是一个draft(一个代理对象)
const isDraftable = value => !!value && !!value[DRAFTABLE]
function produce(baseState, fn) {
const drafts = new Map()
const copies = new Map()
const objectTraps = {
get(target, key) {
if (key === DRAFTABLE) return target
const data = copies.get(target) || target
return getDraft(data[key])
},
set(target, key, val) {
const copy = shallowCopy(target)
const newValue = getDraft(val)
copy[key] = isDraftable(newValue) ? newValue[DRAFTABLE] : newValue
return true
}
}
const getDraft = data => {
if (isDraftable(data)) {
return data
}
if (isPlainObject(data) || Array.isArray(data)) {
if (drafts.has(data)) {
return drafts.get(data)
}
const draft = new Proxy(data, objectTraps)
drafts.set(data, draft)
return draft
}
return data
}
const shallowCopy = data => {
if (copies.has(data)) {
return copies.get(data)
}
const copy = Array.isArray(data) ? data.slice() : { ...data }
copies.set(data, copy)
return copy
}
const isChange = data => {
if (drafts.has(data) || copies.has(data)) return true
}
const finalize = data => {
if (isPlainObject(data) || Array.isArray(data)) {
if (!isChange(data)) {
return data
}
const copy = shallowCopy(data)
Object.keys(copy).forEach(key => {
copy[key] = finalize(copy[key])
})
return copy
}
return data
}
const draft = getDraft(baseState)
fn(draft)
return finalize(baseState)
}
性能测试
以个人博客的一个接口进行了测试(接口数据其实并不复杂),附上代码:
fetch('http://www.yuanwill.cn/wordpress/wp-json/wp/v2/posts?page=1').then(res => res.json()).then(data => {
const list = data;
console.time('immer');
const copyList = produce(list, draftState => {
draftState[0].title.rendered = '123';
});
console.timeEnd('immer');
console.time('cloneDeep');
const copyList1 = _.cloneDeep(list);
copyList1[0].title.rendered = '456';
console.timeEnd('cloneDeep');
})
可以看到两者耗时对比如下:
最后
当然,immer里面的代码远不止于此,其中还有很多的数据检测,兼容性判断,优化等等。上面的实现只是基于immer的一个极简版本。