Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
Anshiii opened this issue Aug 8, 2018 · 0 comments
Open

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

Anshiii opened this issue Aug 8, 2018 · 0 comments
Labels

Comments

@Anshiii
Copy link
Owner

Anshiii commented Aug 8, 2018

问题

由于 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

@Anshiii Anshiii added 源码阅读 源码学习笔记 Javascript 基础 lodash lodash 深拷贝 copy and removed 深拷贝 copy labels Aug 8, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant