在本文中,我们将从浅拷贝(shallow copy)和深拷贝(deep copy)两个方面,介绍多种 JS 中复制对象的方法。

在开始之前,有一些基础知识值得一提:Javascript 中的对象只是对内存地址的引用。创建引用的副本会导致2个引用指向同一个的内存地址。

var foo = {
  a: "abc"
}
console.log(foo.a); // abc

var bar = foo;
console.log(bar.a); // abc

foo.a = "yo foo";
console.log(foo.a); // yo foo
console.log(bar.a); // yo foo

bar.a = "whatup bar?";
console.log(foo.a); // whatup bar?
console.log(bar.a); // whatup bar?
Js

如上所示,对 foo 和 bar 两个对象中的任一个做修改,另一个都会发生相应变化。因此,在 JS 中复制对象要格外小心。

浅拷贝

如果对象比较简单、只具有值类型的属性,可以使用扩展运算符(spread)或 Object.assign(...)

var obj = { foo: "foo", bar: "bar" };

var copy = { ...obj }; // Object {foo: "foo", bar: "bar"}
Js
var obj = { foo: "foo", bar: "bar" };

var copy = Object.assign({}, obj); // Object {foo: "foo", bar: "bar"}
Js

注:上述两种方法都可以将属性值从多个源对象复制到目标对象:

var obj1 = { foo: "foo" };
var obj2 = { bar: "bar" };

var copySpread = { ...obj1, ...obj2 }; // Object {foo: "foo", bar: "bar"}
var copyAssign = Object.assign({}, obj1, obj2); // Object {foo: "foo", bar: "bar"}
Js

不过,如果对象的属性值也是一个对象,那么用上述方法拷贝就会有问题了:那样做只是创建了一个对象属性值引用的副本(但共享的还是一个内存),和本文开篇第一段代码示例中 var bar = foo; 的效果一样:

var foo = { a: 0 , b: { c: 0 } };
var copy = { ...foo };

copy.a = 1;
copy.b.c = 2;

// 修改副本 copy 中的属性 c,原对象 foo 中的属性也跟着变化
console.dir(foo); // { a: 0, b: { c: 2 } }
console.dir(copy); // { a: 1, b: { c: 2 } }

Js

深拷贝(有注意事项)

深拷贝对象,一种解决方案是将对象序列化为字符串,然后再将其反序列化:

var obj = { a: 0, b: { c: 0 } };
var copy = JSON.parse(JSON.stringify(obj));
Js

然而,此方法仅在原对象包含可序列化值类型且没有任何循环引用时才有效。不可序列化值类型的一个例子是 Date 对象 - JSON.parse 只能将其解析为字符串而无法解析回其原始的 Date 对象 :(。

复杂对象的深拷贝

对于更复杂的对象,可以使用新的由 HTML5 规范定义的 "结构化克隆(structured clone)" 算法。不过,虽然这种方法支持的内容类型多于 JSON.parse,但在实际运用中,仍局限于某些内置类型,如:Date,RegExp,Map,Set,Blob,FileList,ImageData,稀疏和类型化数组(typed Array)。它还保留了克隆数据中的引用,支持循环和递归结构。

目前,没有直接的方法来调用结构化克隆算法,但是有一些较新的浏览器功能使用这种算法。因此,有一些可用的深拷贝对象的方法。

通过 MessageChannels:利用通信功能使用的序列化算法。由于此功能是基于事件的,因此生成的克隆也是异步操作。

class StructuredCloner {
  constructor() {
    this.pendingClones_ = new Map();
    this.nextKey_ = 0;

    const channel = new MessageChannel();
    this.inPort_ = channel.port1;
    this.outPort_ = channel.port2;

    this.outPort_.onmessage = ({data: {key, value}}) => {
      const resolve = this.pendingClones_.get(key);
      resolve(value);
      this.pendingClones_.delete(key);
    };
    this.outPort_.start();
  }

  cloneAsync(value) {
    return new Promise(resolve => {
      const key = this.nextKey_++;
      this.pendingClones_.set(key, resolve);
      this.inPort_.postMessage({key, value});
    });
  }
}

const structuredCloneAsync = window.structuredCloneAsync =
  StructuredCloner.prototype.cloneAsync.bind(new StructuredCloner);


const main = async () => {
  const original = { date: new Date(), number: Math.random() };
  original.self = original;

  const clone = await structuredCloneAsync(original);

  // different objects:
  console.assert(original !== clone);
  console.assert(original.date !== clone.date);

  // cyclical:
  console.assert(original.self === original);
  console.assert(clone.self === clone);

  // equivalent values:
  console.assert(original.number === clone.number);
  console.assert(Number(original.date) === Number(clone.date));

  console.log("Assertions complete.");
};

main();
Js

通过 history APIhistory.pushState() 和 history.replaceState() 创建里面第一个参数 obj 的结构化克隆!注意,虽然此方法是同步的,但操纵浏览器的历史记录并不是一项快速的操作,也需要一定的过程和时间,反复调用此方法可能会导致浏览器无响应。

const structuredClone = obj => {
  const oldState = history.state;
  history.replaceState(obj, null);
  const clonedObj = history.state;
  history.replaceState(oldState, null);
  return clonedObj;
};
Js

通过 notification API:在创建新通知时,构造函数会创建其关联数据的结构化克隆。请注意,它还会尝试向用户显示浏览器通知,但除非应用程序已申请显示通知的权限,否则将以静默方式失败。在授予权限的情况下,通知会立即关闭。

const structuredClone = obj => {
  const n = new Notification("", {data: obj, silent: true});
  n.onshow = n.close.bind(n);
  return n.data;
};
Js

NODE.JS 中的深拷贝

从 8.0.0 版开始,Node.js 提供了与结构化克隆兼容的 serialization API。请注意,在撰写本文时,此 API 已标记为实验性:

const v8 = require('v8');
const buf = v8.serialize({a: 'foo', b: new Date()});
const cloned = v8.deserialize(buf);
cloned.b.getMonth();
Js

对于低于 8.0.0 的版本或为了更稳定地实现克隆,可以使用 lodash 的 cloneDeep 方法,该方法也基于结构化克隆算法。

总结

总而言之,在 JS 中用哪种方式复制对象,在很大程度上取决于你要复制的对象的上下文和类型。虽然 lodash 是通用深拷贝最安全的选择,但如果自己动手写,可能会有更具针对性、更高效的实现办法,以下是一个适用于深度克隆日期的简单示例:

function deepClone(obj) {
  var copy;

  // 如果 obj 是 null、undefined 或 不是对象,直接返回 obj
  // Handle the 3 simple types, and null or undefined
  if (null == obj || "object" != typeof obj) return obj;

  // Handle Date
  if (obj instanceof Date) {
    copy = new Date();
    copy.setTime(obj.getTime());
    return copy;
  }

  // Handle Array
  if (obj instanceof Array) {
    copy = [];
    for (var i = 0, len = obj.length; i < len; i++) {
        copy[i] = clone(obj[i]);
    }
    return copy;
  }

  // Handle Function
  if (obj instanceof Function) {
    copy = function() {
      return obj.apply(this, arguments);
    }
    return copy;
  }

  // Handle Object
  if (obj instanceof Object) {
      copy = {};
      for (var attr in obj) {
          if (obj.hasOwnProperty(attr)) copy[attr] = clone(obj[attr]);
      }
      return copy;
  }

  throw new Error("Unable to copy obj as type isn't supported " + obj.constructor.name);
}

Js

就个人而言,我期待能够在任何地方使用结构化克隆。