合拾

深拷贝探秘

2018-11-06

本文总结了一些解决深拷贝的常用方法,没有银弹,每种方法都有其优劣,使用时要区分其场景。

image

在 JavaScript 中,深拷贝已经是一个老生常谈的问题,也无数次被面试官用来考核一个前端的 JS 水平,同时,在开发中,如果对深拷贝理解不够深刻,也会出现很难发现的 BUG。此前,我已经写过一篇文章《深入理解 JavaScript 之克隆》,其中分析了深拷贝的概念,但是其解决方法还很粗浅,这篇文章,记录了一些解决深拷贝的常用方法,没有银弹,每种方法都有其优劣,使用时要区分其场景。

递归拷贝

递归拷贝应该是最开始了解深拷贝时遇到的解决办法,其原理是对引用类型进行遍历,判断其值是否为引用类型,如果为引用类型则递归调用该函数并传入该值,否则为简单数据类型则执行常规赋值拷贝。

function deepClone(obj) {
var result;
if (!(obj instanceof Object)) {
return obj;
} else if (obj instanceof Function) {
result = eval(obj.toString());
} else {
result = Array.isArray(obj) ? [] : {};
}
for (let key in obj) {
result[key] = deepClone(obj[key]);
}
return result;
}

这是一个简单的深拷贝方法,处理了对象、数组和函数,但是 Date、Regexp、Map 等引用类型都没有处理,而且无法解决对象循环引用的场景。

JSON.parse(JSON.stringify(object))

深拷贝一般用 JSON.parse(JSON.stringify(object))就可以解决了,

这种方法的局限性:

  • 会忽略 undefined
  • 不能序列化函数
  • 不能解决循环引用的对象
    undefined 和函数会被忽略,而尝试拷贝循环引用的对象则会报错:
Uncaught TypeError:Converting circular structure to JSON

另外,诸如 Map, Set, RegExp, Date, ArrayBuffer 和其他内置类型在进行序列化时会丢失。

但是在通常情况下,复杂数据都是可以序列化的,所以这个函数可以解决大部分问题,并且该函数是内置函数中处理深拷贝性能最快的。

Structured Clone 结构化克隆算法

Structured cloning 是一种现有的算法,用于将值从一个地方转移到另一地方。例如,每当您调用 postMessage 将消息发送到另一个窗口或 WebWorker 时,都会使用它。关于结构化克隆的好处在于它处理循环对象并 支持大量的内置类型。结构化克隆包含使用 MessageChannel 和 History API。

MessageChannel

MessageChannel API 允许我们创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据。
我们可以创建一个 MessageChannel 并发送消息。在接收端,消息包含我们原始数据对象的结构化克隆。
MessageChannel 的 postMessage 传递的数据也是深拷贝的,这和 web worker 的 postMessage 一样。而且还可以拷贝 undefined 和循环引用的对象。

// 有undefined + 循环引用
let obj = {
a: 1,
b: {
c: 2,
d: 3
},
f: undefined
};
obj.c = obj.b;
obj.e = obj.a;
obj.b.c = obj.c;
obj.b.d = obj.b;
obj.b.e = obj.b.c;

function deepCopy(obj) {
return new Promise(resolve => {
const { port1, port2 } = new MessageChannel();
port2.onmessage = ev => resolve(ev.data);
port1.postMessage(obj);
});
}

// MessageChannel是异步的
deepCopy(obj).then(copy => {
let copyObj = copy;
console.log(copyObj, obj);
console.log(copyObj == obj);
});

但拷贝有函数的对象时,还是会报错:

Uncaught (in promise) DOMException:Failed to execute 'postMessage' on 'MessagePort': function() {} could not be cloned.

这种方法的缺点是它是异步的,但是你可以使用 await 来解决。

const clone = await structuralClone(obj);

History

如果你曾经使用 history.pushState()写过 SPA,你可以提供一个状态对象来保存 URL。事实证明,这个状态对象使用结构化克隆,而且是同步的。我们必须小心使用,不要把原有的路由状态搞乱了,所以我们需要在完成克隆之后恢复原始状态。为了防止发生任何意外,请使用 history.replaceState()而不是 history.pushState()。

const structuralClone = obj => {
const oldState = history.state;
history.replaceState(obj, document.title);
const copy = history.state;
history.replaceState(oldState, document.title);
return copy;
};

此方法的优点是:能解决循环对象的问题,并且支持许多内置类型的克隆,也是同步的。缺点是有的浏览器对调用频率有限制。比如 Safari 30 秒内只允许调用 100 次

Notification

Notification 是用于桌面通知的。也可以用来实现 JS 对象的深拷贝。

function structuralClone(obj) {
return new Notification('', {data: obj, silent: true}).data;
}
const obj = /* ... */;
const clone = structuralClone(obj);

此方法的优点就是可以解决循环对象问题,也支持许多内置类型的克隆,并且是同步的。缺点就是这个 api 的使用需要向用户请求权限,但是用在这里克隆数据的时候,不经用户授权也可以使用。在 http 协议的情况下会提示你再 https 的场景下使用。
由于某种原因,Safari 总是返回 undefined。

lodash 的深拷贝函数

lodash 的_.cloneDeep()支持循环对象,和大量的内置类型,对很多细节都处理的比较不错,推荐使用。

解决循环引用

通过分析上述的方法,发现只有结构化克隆算法才能解决循环引用的问题,那么使用原生 JS 如何解决呢?这里尝试使用闭包来解决循环问题。

function deepClone(object) {
const memo = {};
function clone(obj) {
var result;
if (!(obj instanceof Object)) {
return obj;
} else if (obj instanceof Function) {
result = eval(obj.toString());
} else {
result = Array.isArray(obj) ? [] : {};
}
for (let key in obj) {
if (memo[obj[key]]) {
result[key] = memo[obj[key]];
} else {
memo[obj[key]] = obj[key];
result[key] = clone(obj[key]);
}
}
return result;
}
return clone(object);
}

var obj = {};
var b = { obj };
obj.b = b;

var obj2 = deepClone(obj);

在上述代码中,对递归拷贝进行了优化,使用闭包来解决循环拷贝的问题,使用了 memo 这个变量来记录被拷贝的引用地址,在每次递归前,记录递归的参数值,这样在递归内部就可以跳出递归,解决循环引用问题。

总结

  • 如果您没有循环对象,并且不需要保留内置类型,则可以使用跨浏览器的 JSON.parse(JSON.stringify())获得最快的克隆性能。
  • 如果你想要一个适当的结构化克隆,MessageChannel 是你唯一可靠的跨浏览器的选择。
使用支付宝打赏
使用微信打赏

若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏

扫描二维码,分享此文章