摸索 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
是怎么做的
你猜的没错,的确是要深度遍历的。
在 baseClone
方法内,拿到要拷贝的对象 value
后,先检查其类型,然后由对应的 handler 来处理,比如value
是数组类型,则使 result
为同样长度的数据,然后对每一项都递归调用 baseClone
,直到 value
是非引用类型,返回 value
的值;如果是普通对象类型,则使 result
为空数组,然后拿取value
的key
,对每个key
的赋值也是递归调用baseClone
。
想要简单点
难道我深拷贝一个变量还要引入 lodash 这么麻烦吗 ?没有简单点的办法吗?
JSON.parse(JSON.stringify(param))
嗯,可能有点不是那么酷炫,但是他确实可以满足要求,而且也无须引入其他的库。但如果它真的这么完美,为什么 lodash 不这么写呢?
的确,它的缺点还挺多的,这里取几个我觉得比较重要的:
- Set 类型、Map 类型以及 Buffer 类型会被转换成
{}
- undefined、任意的函数以及 symbol 值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或者被转换成 null(出现在数组中时)
- 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误
- 所有以 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
如上代码, 变量a
和 b
互相引用对方,此时如果借用 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);
这里的 value
和 result
分别是是一次遍历中 要拷贝的值 和 拷贝的结果。stack
是一个用来储存每次对应的 value
和 result
的对象, stack
下有一块用于储存的数组结构,该数组的每一项记录了单次遍历中的 value
和 result
,后二者再次以数组的形式存储,以 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微秒。
-
safari
...em...Safari浏览器在调用完 postMessage 方法后就...没有然后了...表格都没刷出来...等了 40 s 终于刷出第一栏...
注释完postMessage
又发现不能频繁的调用 history 。
- firefox
...em.. 调用 history 相关 api 对 firefox 好像压力很大,以至于循环都有些错乱...于是注释了相关代码
就结果而言好像看不出什么区别,可能是我的数据不好,大家可以去看看原文,有展示阅读性更好的图表,尽管没有 lodash 就是了。
结果
回到我们最初的问题,我们只是想深拷贝一个 js 对象,如果只是一个比较"普通"的对象,用JSON的方法简单又快捷,但是如果这个对象有些“复杂”,似乎使用 lodash 的方法是比较好的选择,而且 lodash 连 Structured Clone 算法忽视的 symbol 类型 和 Function 也考虑其中,兼容性也没问题,也不会在不同的浏览器发生意外的状况...
lodash **!lol!!