了解一下高性能版深拷贝

5,184 阅读7分钟

前言

由于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作者)对于深拷贝的看法:

screenshot-20220719-170950.png

大佬觉得深拷贝不是一个好的推荐,所以推荐了imeer,甚至在字节的内部分享中,Dan也推荐了immer。

screenshot-20220719-172049.png

整个访谈录可以查看链接:Dan Abramov 访谈实录

理解immer的思想

immer是基于copy-on-write 机制。它的基本思想是,所有的更改都应用于临时的draft数据中,它是current(原始)数据的代理。当完成所有的变更后,才基于draft数据生成最后的next数据。这意味着可以通过简单的修改数据而与数据进行交互,并且同时保留不可变数据的引用的优点。

screenshot-20220719-172915.png

附上immer的官网。

这个思想的思路就是:我们在做深拷贝的时候,只有当属性发生变化,才对这部分数据进行深拷贝,对于没有发生变化的属性,直接拿引用即可。

那么,我们有没有什么方法知道用户修改了哪些属性呢,答案是proxy。

实现

首先,我们理一下思路:

  1. 拦截get,判断属性是否修改,如果属性修改了,则从draft数据中取值,否则从原数据中取值。
  2. 拦截set,所有的赋值操作都在draft数据中操作,阻止对原数据进行操作。
  3. 遍历原数据,判断属性是否修改,如果修改则把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
  }
  1. 首先,我们判断传入的数据是不是一个draft对象,如果是的话就直接返回。这里判断的核心是通过这个对象是否存在value[DRAFTABLE],因为只有draft对象才会触发我们自定义的拦截get函数(这个稍后会将,其实就拦截函数中会判断如果keyDRAFTABLE就返回target,也就是他的原生数据)。
  2. 然后,我们判断是否是一个正常的Object对象或者数组,isPlainObject方法就是比较普遍的判断原生对象的方法(我就直接拷贝了源码里的这个方法)。
  3. 如果是原生对象或者数组,我们先判断这个数据对应的draft对象是否创建过,创建过就直接从draft数据池中取就好了。
  4. 最后,如果这个原生对象对应的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
  }
  1. 拦截get的时候,我们判断key是不是DRAFTABLE(draft的标识),如果是的话就说明被访问的对象是一个draft,我们需要把正确的target(原生数据)返回出去,否则拿到的会是一个proxy。如果不是,那就从浅拷贝(copies)数据池中取,然后就正常返回了。
  2. 拦截set的时候,首先我们生成一个浅拷贝对象,因为赋值操作我们需要在浅拷贝对象中操作,否则会影响原数据。然后组对新赋予的值进行一次getDraft操作(防止新值也是个对象或数组)。最后把新值赋给copy,需要注意的是,copy[key]的时候需要判断新值最后是不是一个draft,如果是draft,我们需要返回原对象,否则拿到的就是一个proxy了。
  3. 浅拷贝就很简单,就不赘述了。

接下来,就是在完成所有操作后,生成最后的拷贝对象了。

生成拷贝对象

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
  }
  1. 首先,判断是不是对象或者数组,如果不是直接返回就好了。
  2. 如果是数组或者对象,那么我们判断这个数据是否修改过,如果没修改过,也就不需要递归了,直接把整个数据返回。
  3. 如果修改过,那么我们就从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');

})

可以看到两者耗时对比如下:

screenshot-20220719-184704.png

最后

当然,immer里面的代码远不止于此,其中还有很多的数据检测,兼容性判断,优化等等。上面的实现只是基于immer的一个极简版本。