Anshiii/blog

摸索 JS 内深拷贝的最佳实践

Opened this issue · 0 comments

问题

由于 js 的传参方式有时会遇到这样的场景:

function setTime(data) {
  let result = {};
  result.obj = data.obj || {};
  result.obj.time = Date.now();
  return result
}

let data = {
  title:'loooook!',
  obj: {
	name: 'keo',
	age: '12'
  }
}

let res = setTime(data);

console.log('res',res);
//res { obj: { name: 'keo', age: '12', time: 1533625350183 } }
console.log('data',data);
//data { title: 'loooook!', obj: { name: 'keo', age: '12', time: 1533625350183 } }

我只是想继承参数的部分数据,并在此基础添加一些东西,但是参数 data 的源数据也被我改动了,如果之后有其他人想要从data获取数据,他可能还需要注意是否有像 setTime 这样的函数调用它。

一点修改

function setTime(data) {
  let result = {};
  result.obj =  {};
  Object.assign(result.obj,data.obj)
  result.obj.time = Date.now();
  return result
}

嗯,或者你也可以用 for...in,注意下二者的不同。
我们知道 Object.assign 只是浅拷贝,如果 data.obj 的属性值仍然有引用类型的话,那么还是会遇见同样的问题。
那要怎么办?难道要遍历data下每个属性的值?一个个复制过来?我们看看 lodash 是怎么做的
lodash 的深拷贝
你猜的没错,的确是要深度遍历的。
baseClone方法内,拿到要拷贝的对象 value 后,先检查其类型,然后由对应的 handler 来处理,比如value是数组类型,则使 result 为同样长度的数据,然后对每一项都递归调用 baseClone,直到 value 是非引用类型,返回 value的值;如果是普通对象类型,则使 result 为空数组,然后拿取valuekey,对每个key的赋值也是递归调用baseClone

想要简单点

难道我深拷贝一个变量还要引入 lodash 这么麻烦吗 ?没有简单点的办法吗?

JSON.parse(JSON.stringify(param))

嗯,可能有点不是那么酷炫,但是他确实可以满足要求,而且也无须引入其他的库。但如果它真的这么完美,为什么 lodash 不这么写呢?
的确,它的缺点还挺多的,这里取几个我觉得比较重要的:

  1. Set 类型、Map 类型以及 Buffer 类型会被转换成 {}
  2. undefined、任意的函数以及 symbol 值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或者被转换成 null(出现在数组中时)
  3. 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误
  4. 所有以 symbol 为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们

是啊,毕竟JSON的两个方法本身就只是用来转换 js 内的对象为 JSON 格式的,上述几点甚至都不是缺点,是我们想借用其他方法做深拷贝时遇到的问题。

既然是问题那应该可以解决吧,比如第一条和第二条,在 stringify 时判断类型,转化成 带类型标识符的对象字符串如:Set [1,2,3,4,5],然后在parse的时候对字符串进行解析,特别的类型调用对应的构造函数... 听起来变得更麻烦了,没关系,忍忍把各个类型的处理都写了;针对第三条,抛错了?没关系,我 try catch 包起来...,什么?循环引用?

循环引用?

function parse (param){
  return JSON.parse(JSON.stringify(param))
}

var a = {}
var b = {}
a['b'] = b
b['a'] = a

console.log(parse(a))
//TypeError: Converting circular structure to JSON at JSON.stringify

如上代码, 变量ab 互相引用对方,此时如果借用 JSON 的方法来进行深拷贝的话,会报循环结构转换转换 JSON 错误。这个问题怎么解决呢?我们再翻出 lodash 的源码看看...

      // Check for circular references and return its corresponding clone.
      stack || (stack = new Stack);
      var stacked = stack.get(value);
      if (stacked) {
        return stacked;
      }
      stack.set(value, result);

这里的 valueresult 分别是是一次遍历中 要拷贝的值 和 拷贝的结果。stack 是一个用来储存每次对应的 valueresult 的对象, stack下有一块用于储存的数组结构,该数组的每一项记录了单次遍历中的 valueresult,后二者再次以数组的形式存储,以 value 做为下标 0 的项,result 为下标 1 的项(这里不用对象的 key-value 形式可能是因为循环引用的变量无法使用 JSON.stringify 转换成字符串,只能 toString 转成 object Object);stack 是做为参数贯穿整个遍历过程的,每次遍历时都会以当前的 value 值进行查找(这里的查找直接是判断内存地址相等),如果能在 stack 中查到到对应的结果,则直接返回记录中的result,不再继续递归。
好了,循环引用的问题我们解决了,鼓掌!但是我也放弃使用 JSON 方法了...还有没有其他直接点的方法呢?

其他方法

结构化克隆算法是由HTML5规范定义的用于复制复杂JavaScript对象的算法,它通过递归输入对象来构建克隆,同时保持先前访问过的引用的映射,以避免无限遍历循环。

怎么用?
emmm... 它还不能直接使用,你得依靠一些其他的 API ,间接的使用它。

  • postMessage()
function StructuredClone(param) {
  return new Promise(function (res, rej) {
	const {port1, port2} = new MessageChannel();
	port2.onmessage = ev => res(ev.data);
	port1.postMessage(param);
  })
}

StructuredClone(objects).then(result => console.log(result))

什么??还是异步的... 不,我希望能使用同步的方法使用它。

  • history()
function structuralClone(obj) {
  const oldState = history.state;
  history.replaceState(obj, document.title);
  const copy = history.state;
  history.replaceState(oldState, document.title);
  return copy;
}
const clone = structuralClone(objects);

如你所见,我们要借用一下 history.replaceState 这个方法,但是我们不能改变 history 原有的状态,所以用完就要恢复原状,当无事发生过。
至少,这是个同步的方法...,如果是同步的场景可以考虑一下...

性能展示

这里的测试代码是使用的 [Deep-copying in JavaScript] (https://dassur.ma/things/deep-copy/) 一文中的,并再次基础做了一些修改。

结果! (很懒就不画图表了)

单位 μs (缪斯),计算时间的用的接口是 performance.now()结果精确到5微秒。

  • chrome
    chrome

  • safari
    ...em...Safari浏览器在调用完 postMessage 方法后就...没有然后了...表格都没刷出来...等了 40 s 终于刷出第一栏...
    注释完 postMessage 又发现不能频繁的调用 history 。
    调用 history 的 api 抛异常

safari 结果

  • firefox
    ...em.. 调用 history 相关 api 对 firefox 好像压力很大,以至于循环都有些错乱...于是注释了相关代码

 firefox 结果

就结果而言好像看不出什么区别,可能是我的数据不好,大家可以去看看原文,有展示阅读性更好的图表,尽管没有 lodash 就是了。

结果

回到我们最初的问题,我们只是想深拷贝一个 js 对象,如果只是一个比较"普通"的对象,用JSON的方法简单又快捷,但是如果这个对象有些“复杂”,似乎使用 lodash 的方法是比较好的选择,而且 lodash 连 Structured Clone 算法忽视的 symbol 类型 和 Function 也考虑其中,兼容性也没问题,也不会在不同的浏览器发生意外的状况...
lodash **!lol!!

参考阅读:
Deep-copying in JavaScript