【面试官系列】请讲讲你知道的关于对象和数组深、浅拷贝的一些技巧 ~

407 阅读12分钟

这个题目主要是希望考察面试者的 JavaScript 基本功(ES5 + ES6,甚至是一些新的JS API,比如结构化克隆:structuredClone),需要对对象和数组的常用方法有一定的认知和熟悉度。如果能有条理地口头说出一些自己在实际项目中常用的深浅拷贝手段,并且能自己手写一个具有特定使用场景的深拷贝方法,当然是更好的了。

一、浅拷贝

按照 MDN 的说法,在JavaScript中,内置对象复制操作(扩展运算符(...)Object.assign()Object.create()Array.prototype.concat()Array.prototype.slice()Array.from())创建的是浅拷贝。

浅拷贝指的是只复制一层,这对于只包含原始值的数组或对象来说很适用。

1、对象

1.1 Object.assign(obj1, obj2)

在 ES6 之前,Object.assign() 可能是最流行的深拷贝对象的方法了,它跟上一个 JSON.parse(JSON.stringify(object)) 相比,能拷贝包含函数元素的对象。

const user = {
    name: "iankevin",
    age: 28,
    job: "Web Developer",
    incrementAge: function () {
        this.age++;
    }
};

let clone = Object.assign({}, user);

这个方法也有一个问题的,它在对象存在嵌套的场景不适用,比如:

const user = {
  name: "iankevin",
  age: 28,
  job: "Web Developer",
  location: {
    city: "beijing",
  },
  incrementAge: function () {
    this.age++;
  },
};

const clone = Object.assign({}, user);

clone.incrementAge();
console.log(user.age); // 28
console.log(clone.age); // 29

clone.location.city = "shanghai";

console.log(clone.location.city); // shanghai
console.log(user.location.city); // shanghai

1.2 Object.create(object)

其实这个方法的效果跟上一个类似,也是只能深拷一层,深层次的嵌套不适用:

const user = {
  name: "iankevin",
  age: 28,
  job: "Web Developer",
  location: {
    city: "beijing",
  },
  incrementAge: function () {
    this.age++;
  },
};

const clone = Object.create(user);

clone.incrementAge();
console.log(user.age); // 28
console.log(clone.age); // 29

clone.location.city = "shanghai";

console.log(clone.location.city); // shanghai
console.log(user.location.city); // shanghai

1.3 ES6 扩展运算符(...)

ES6 语法中的扩展运算符(...)能最简便实现常见场景的深拷贝,但它的缺陷跟 Object.assign() 类似,对嵌套的对象深拷贝无能为力。

const user = {
  name: "iankevin",
  age: 28,
  job: "Web Developer",
  location: {
    city: "beijing",
  },
  incrementAge: function () {
    this.age++;
  },
};

const clone = { ...user };

clone.incrementAge();
console.log(user.age); // 28
console.log(clone.age); // 29

clone.location.city = "shanghai";

console.log(clone.location.city); // shanghai
console.log(user.location.city); // shanghai

2、数组

2.1 Array.prototype.concat()

这个方法是数组的内置方法,对一维数组是”深拷贝“:

let originalData = ["noodles", "eggs", "flour", "water"];

let cloneData = [].concat(originalData);

cloneData[0] = "tomato";

console.log(JSON.stringify(originalData)); // ["noodles","eggs","flour","water"]
console.log(JSON.stringify(cloneData)); // ["tomato","eggs","flour","water"]

但是对二维及更多维的数组,则不太适用,比如:

let originalData = ["noodles", { list: ["eggs", "flour", "water"] }];

let cloneData = [].concat(originalData);
console.log(JSON.stringify(cloneData)); // ["noodles",{"list":["eggs","flour","water"]}]

cloneData[1].list = ["rice flour", "water"];
cloneData[0] = "tomato";

console.log(originalData[1].list); // [ "rice flour", "water" ]
console.log(JSON.stringify(originalData)); // ["noodles",{"list":["rice flour","water"]}]
console.log(JSON.stringify(cloneData)); // ["tomato",{"list":["rice flour","water"]}]

2.2 Array.prototype.slice()

这个方法跟 Array.prototype.concat() 类似,是数组的内置方法,对一维数组是”深拷贝“:

let originalData = ["noodles", "eggs", "flour", "water"];

let cloneData = originalData.slice(0);

cloneData[0] = "tomato";

console.log(JSON.stringify(originalData)); // ["noodles","eggs","flour","water"]
console.log(JSON.stringify(cloneData)); // ["tomato","eggs","flour","water"]

同样的,对二维及更多维的数组,则不太适用,比如:

let originalData = ["noodles", { list: ["eggs", "flour", "water"] }];

let cloneData = originalData.slice(0);
console.log(JSON.stringify(cloneData)); // ["noodles",{"list":["eggs","flour","water"]}]

cloneData[1].list = ["rice flour", "water"];
cloneData[0] = "tomato";

console.log(originalData[1].list); // [ "rice flour", "water" ]
console.log(JSON.stringify(originalData)); // ["noodles",{"list":["rice flour","water"]}]
console.log(JSON.stringify(cloneData)); // ["tomato",{"list":["rice flour","water"]}]

2.3 Array.from()

这个方法跟 Array.prototype.concat() 类似,是数组的内置方法,对一维数组是”深拷贝“:

let originalData = ["noodles", "eggs", "flour", "water"];

let cloneData = originalData.slice(0);

cloneData[0] = "tomato";

console.log(JSON.stringify(originalData)); // ["noodles","eggs","flour","water"]
console.log(JSON.stringify(cloneData)); // ["tomato","eggs","flour","water"]

同样的,对二维及更多维的数组,则不太适用,比如:

let originalData = ["noodles", { list: ["eggs", "flour", "water"] }];

let cloneData = Array.from(originalData);
console.log(JSON.stringify(cloneData)); // ["noodles",{"list":["eggs","flour","water"]}]

cloneData[1].list = ["rice flour", "water"];
cloneData[0] = "tomato";

console.log(originalData[1].list); // [ "rice flour", "water" ]
console.log(JSON.stringify(originalData)); // ["noodles",{"list":["rice flour","water"]}]
console.log(JSON.stringify(cloneData)); // ["tomato",{"list":["rice flour","water"]}]

三、深拷贝

对于包含其他对象或数组的对象和数组,拷贝这些对象就需要深拷贝。否则,对嵌套引用所做的更改将更改嵌套在原始对象或数组中的数据。

1、JSON.parse(JSON.stringify(object))

这个大概是最被大家常用的深拷贝方法了。

const originalData = {
  name: "iankevin",
  age: 28,
  location: {
    city: "beijing",
    info: {
      address: "haidian",
    },
  },
};

const cloneData = JSON.parse(JSON.stringify(originalData));

cloneData.age = 18;
cloneData.location.city = "shanghai";
cloneData.location.info.address = "baoshan";

console.log("originalData", originalData);
console.log("cloneData", cloneData);

image.png

好用是好用,但是需要注意的是,当你的对象中含有如下类型的元素时,这个方法将不再适用:

  • Date
  • functions
  • undefined
  • Infinity
  • RegExps
  • Maps
  • Sets
  • Blobs
  • FileLists
  • ImageDatas
  • sparse Arrays(稀疏数组)
  • Typed Arrays
  • complex types

来看一个例子:

const originalData = {
  undefined: undefined, // 会连同 key 一起消失
  notANumber: NaN, // 转成null
  infinity: Infinity, // 转成null
  regExp: /.*/, // 会被转为空对象
  date: new Date("1999-12-31T23:59:59"), // 日期会被字符串化
  func: function () {}, // 连同 key 一起消失
  num: Number, // 连同 key 一起消失
};
const faultyClonedData = JSON.parse(JSON.stringify(originalData));

console.log(faultyClonedData.undefined); // undefined
console.log(faultyClonedData.notANumber); // null
console.log(faultyClonedData.infinity); // null
console.log(faultyClonedData.regExp); // {}
console.log(faultyClonedData.date); // "1999-12-31T15:59:59.000Z"
console.log(faultyClonedData.func); // undefined
console.log(faultyClonedData.num); // undefined

2、Lodash - cloneDeep

官方文档:lodash.cloneDeep

Lodash 是非常常用的工具函数库了,它的深拷贝不仅可以应对常见的复杂类型的数据,还可以对嵌套对象进行深拷贝。

image.png

import { cloneDeep } from "lodash";

const user = {
  name: "iankevin",
  age: 28,
  location: {
    city: "beijing",
  },
  incrementAge: function () {
    this.age++;
  },
  birthday: new Date(),
  regex: /1-9/gi,
  money: Infinity,
  girlfriend: null,
  future: undefined,
  no: NaN,
};

const clone = cloneDeep(user);
console.log("clone: ", clone);

clone.incrementAge();
console.log(user.age); // 28
console.log(clone.age); // 29

clone.location.city = "shanghai";
console.log(user.location.city); // beijing
console.log(clone.location.city); // shanghai

image.png

可能唯一不好的地方就是会在项目中引入一个依赖了。

类似的通过库引入的方式,还有 Ramda.clone()underscorejs:_.clone()

3、rfdc.clone

rfdc 什么意思呢?:Really Fast Deep Clone,项目地址:rfdc,如果您正在处理一个大的、复杂的对象,例如从 3MB-15MB 大小的 JSON 文件加载的对象,这样的库将很有用。它号称速度提高了大约 400%。

语法:

require('rfdc')(opts = { proto: false, circles: false }) => clone(obj) => obj2

简单用法:

const clone = require('rfdc')()
clone({a: 1, b: {c: 2}}) // => {a: 1, b: {c: 2}}

3.1 proto 选项

将原型属性以及自己的属性复制到新对象中。允许将原型上的可枚举属性复制到克隆的对象中(不是复制到它的原型上,而是直接复制到对象上),这样会稍微快一点。

用代码来解释:

require('rfdc')({ proto: false })(Object.create({a: 1})) // => {}
require('rfdc')({ proto: true })(Object.create({a: 1})) // => {a: 1}

将proto设置为true将提供额外的2%的性能提升。

3.2 circles 选项

跟踪循环引用将以额外 25% 的开销降低性能。即使一个对象没有任何循环引用,跟踪的开销也是有代价的。默认情况下,如果一个带有循环引用的对象被传递给 rfdc,它将会抛出(类似于 JSON.stringify 抛出)。

使用 circles 选项来检测并保留对象中的循环引用。如果性能很重要,可以尝试从对象中移除循环引用(设置为未定义),然后在克隆后手动添加回来,而不是使用这个选项。

3.3 支持的数据类型

所有的 JSON types:

  • Object
  • Array
  • Number
  • String
  • null

还支持:

  • Date (copied)
  • undefined (copied)
  • Buffer (copied)
  • TypedArray (copied)
  • Map (copied)
  • Set (copied)
  • Function (referenced)
  • AsyncFunction (referenced)
  • GeneratorFunction (referenced)
  • arguments (copied to a normal object)

所有其他类型的输出值都与JSON.parse(JSON.stringify(o))的输出相同。

还有两个比较推荐的:

但是按照作者的测试,fastest-json-copy 可能更快点,但它有很大的局限性,以至于它很少有用。例如,它对 DateMap 实例的处理与空 {} 相同。它不能处理循环引用。 plain-object-clone 的能力也非常有限。

4、jQuery

虽然jQuery的时代已逐渐离我们远去,但是它留下的一些工具方法仍然是值得我们学习的瑰宝,毕竟是经历过那么多年、那么多实际项目检验过的!

基本用法:

$(function () {
  const cloneData = {};
  const originalData = {
    name: "iankevin",
    age: 28,
    location: {
      city: "beijing",
      info: {
        address: "haidian",
      },
    },
    incrementAge: function () {
      this.age++;
    },
  };
  /* originalData 合并到 cloneData 中 */
  $.extend(cloneData, originalData);
  cloneData.incrementAge();
  console.log("cloneData", cloneData);
  console.log("originalData", originalData);
});

image.png

这个 API 的用法:

  • 非深度克隆:extend(object_dest, object_source);
  • 深度克隆:extend(true, object_dest, object_source);

可以来看看它的实现:

/**
 * This is a quasi clone of jQuery's extend() function.
 * by Romain WEEGER for wJs library - www.wexample.com
 * @returns {*|{}}
 */
function extend() {
    // Make a copy of arguments to avoid JavaScript inspector hints.
    var to_add, name, copy_is_array, clone,

    // The target object who receive parameters
    // form other objects.
    target = arguments[0] || {},

    // Index of first argument to mix to target.
    i = 1,

    // Mix target with all function arguments.
    length = arguments.length,

    // Define if we merge object recursively.
    deep = false;

    // Handle a deep copy situation.
    if (typeof target === 'boolean') {
        deep = target;

        // Skip the boolean and the target.
        target = arguments[ i ] || {};

        // Use next object as first added.
        i++;
    }

    // Handle case when target is a string or something (possible in deep copy)
    if (typeof target !== 'object' && typeof target !== 'function') {
        target = {};
    }

    // Loop trough arguments.
    for (false; i < length; i += 1) {

        // Only deal with non-null/undefined values
        if ((to_add = arguments[ i ]) !== null) {

            // Extend the base object.
            for (name in to_add) {

                // We do not wrap for loop into hasOwnProperty,
                // to access to all values of object.
                // Prevent never-ending loop.
                if (target === to_add[name]) {
                    continue;
                }

                // Recurse if we're merging plain objects or arrays.
                if (deep && to_add[name] && (is_plain_object(to_add[name]) || (copy_is_array = Array.isArray(to_add[name])))) {
                    if (copy_is_array) {
                        copy_is_array = false;
                        clone = target[name] && Array.isArray(target[name]) ? target[name] : [];
                    }
                    else {
                        clone = target[name] && is_plain_object(target[name]) ? target[name] : {};
                    }

                    // Never move original objects, clone them.
                    target[name] = extend(deep, clone, to_add[name]);
                }

                // Don't bring in undefined values.
                else if (to_add[name] !== undefined) {
                    target[name] = to_add[name];
                }
            }
        }
    }
    return target;
}

/**
 * Check to see if an object is a plain object
 * (created using "{}" or "new Object").
 * Forked from jQuery.
 * @param obj
 * @returns {boolean}
 */
function is_plain_object(obj) {
    // Not plain objects:
    // - Any object or value whose internal [[Class]] property is not "[object Object]"
    // - DOM nodes
    // - window
    if (obj === null || typeof obj !== "object" || obj.nodeType || (obj !== null && obj === obj.window)) {
        return false;
    }
    // Support: Firefox <20
    // The try/catch suppresses exceptions thrown when attempting to access
    // the "constructor" property of certain host objects, i.e. |window.location|
    // https://bugzilla.mozilla.org/show_bug.cgi?id=814622
    try {
        if (obj.constructor && !this.hasOwnProperty.call(obj.constructor.prototype, "isPrototypeOf")) {
            return false;
        }
    }
    catch (e) {
        return false;
    }

    // If the function hasn't returned already, we're confident that
    // |obj| is a plain object, created by {} or constructed with new Object
    return true;
}

5、结构化克隆

注意: 全局 structuredClone 已经适用于 FF 94+Chrome 98+Safari 15.4+Edge 98+Node 17Deno 1.14,因此适用于所有主流浏览器的当前版本。

HTML 标准包括一个内部结构化克隆/序列化算法,可以创建对象的深度克隆。它仍然限于某些内置类型,但除了 JSON 支持的少数类型外,它还支持 Dates、RegExps、Maps、Sets、Blobs、FileLists、ImageDatas、sparse Arrays、Typed Arrays,将来可能还会支持更多类型。它还保留了克隆数据中的引用,允许它支持会导致 JSON 错误的循环和递归结构。

5.1 Node.js 中使用

全局structuredClone 函数Node 17.0 + 可以使用:

const clone = structuredClone(original);

以前的版本:v8Node.js 中的模块(自 Node 11 起)直接公开了结构化序列化 API,但此功能仍标记为“实验性”,并可能在未来版本中更改或删除。如果您使用的是兼容版本,克隆对象非常简单:

const v8 = require('v8');

const structuredClone = obj => {
  return v8.deserialize(v8.serialize(obj));
};

5.2 在浏览器中使用(chrome)

structuredClone()不仅性能卓越,而且还受到所有主要浏览器的支持。

image.png

const originalData = {
  name: "iankevin",
  birthday: new Date(),
  socials: [
    {
      name: "weixin",
      url: "kevinliao222xxx",
    },
    {
      name: "weibo",
      url: "weibo-212112",
    },
  ],
};

const cloneData = structuredClone(originalData);
cloneData.name = "tom";

console.log("originalData", originalData);

image.png

来自:caniuse

5.3 局限性structuredClone()

虽然structuredClone()解决了该方法的大部分(不是全部)JSON.stringify()缺陷。但它目前也有一些值得关注的局限性。

  • 不可拷贝函数:如果要复制包含函数的对象,DataCloneError将抛出异常。
//  Error!
structuredClone({ fn: () => { } })
  • 无法拷贝 DOM 节点:它还会DataCloneError在您尝试克隆 DOM 节点时抛出。
//  Error!
structuredClone({ element: document.body })
  • 无法拷贝属性描述符、settergetter

  • 无法拷贝原型:结构化克隆不会复制原型链。如果您复制 a 的实例Class,则复制的对象将不再是 this 的实例Class。返回一个普通对象代替原来的Class.

class mainClass { 
  greet = 'hello' 
  Method() { /* ... */ }
}
const instanceClass = new mainClass()

const copied = structuredClone(instanceClass)
//  { greet: 'hello' }

copied instanceof instanceClass // false

查看可在MDN上复制的受支持类型的完整列表;不在此列表中的任何内容都无法复制。

6、手写深拷贝

如果不想引入一个依赖包来增加产物体积,可以自己手写一个符合自己业务需求的深拷贝方法。

先来个乞丐版:

function deepClone(from, to) {
    if (from == null || typeof from != "object") return from;
    if (from.constructor != Object && from.constructor != Array) return from;
    if (from.constructor == Date || from.constructor == RegExp || from.constructor == Function ||
        from.constructor == String || from.constructor == Number || from.constructor == Boolean)
        return new from.constructor(from);

    to = to || new from.constructor();

    for (var name in from) {
        to[name] = typeof to[name] == "undefined" ? deepClone(from[name], null) : to[name];
    }

    return to;
}

测试:

var obj = {
    date: new Date(),
    func: function(q) { return 1 + q; },
    num: 123,
    text: "asdasd",
    array: [1, "asd"],
    regex: new RegExp(/aaa/i),
    subobj: {
        num: 234,
        text: "asdsaD"
    }
}

var clone = deepClone(obj);

image.png

有时候需要考虑循环引用的场景,比如:

const a = {}; 
a['selfref'] = a; 
a['text'] = 'something'; 
alert(a.selfref.text); 

更健壮的版本(处理了循环引用):

// 定义一个深拷贝函数,参数为源对象,已访问过的对象数组 _visited 和 已拷贝过的对象数组 _copiesVisited
function deepCopy(src, _visited, _copiesVisited) {
    // 如果源对象是 null 或者不是 object 类型,则直接返回源对象
    if (src === null || typeof(src) !== 'object'){
        return src;
    }

    // 如果源对象有 clone 方法,调用该方法进行深拷贝并返回结果
    if (typeof src.clone == 'function'){
        return src.clone(true);
    }

    // 如果源对象是 Date 类型,返回该对象的副本
    if (src instanceof Date){
        return new Date(src.getTime());
    }
    
    // 如果源对象是 RegExp 类型,返回该对象的副本
    if (src instanceof RegExp){
        return new RegExp(src);
    }
    
    // 如果源对象是 DOM Element 类型,返回该对象的副本
    if (src.nodeType && typeof src.cloneNode == 'function'){
        return src.cloneNode(true);
    }

    // 初始化被访问对象的数组。这里用来检测循环引用
    if (_visited === undefined){
        _visited = [];
        _copiesVisited = [];
    }

    // 检测这个对象是否已经被访问过
    var i, len = _visited.length;
    for (i = 0; i < len; i++) {
        if (src === _visited[i]) {
            return _copiesVisited[i];
        }
    }

    // 如果源对象是数组类型
    if (Object.prototype.toString.call(src) == '[object Array]') {
        // 切片拷贝数组
        var ret = src.slice();

        // 将该源对象推入已访问过的对象数组
        _visited.push(src);
        // 将该源对象的副本推入已拷贝过的对象数组
        _copiesVisited.push(ret);

        // 对数组中每个元素进行深拷贝,并将拷贝结果赋值给对应位置的元素
        var i = ret.length;
        while (i--) {
            ret[i] = deepCopy(ret[i], _visited, _copiesVisited);
        }
        return ret; // 返回拷贝的结果
    }

    // 获取源对象的原型对象
    var proto = (Object.getPrototypeOf ? Object.getPrototypeOf(src) : src.__proto__);
    if (!proto) {
        proto = src.constructor.prototype; 
    }
    // 创建一个继承该原型对象的新对象
    var dest = object_create(proto);

    // 将该源对象推入已访问过的对象数组
    _visited.push(src);
    // 将该新对象推入已拷贝过的对象数组
    _copiesVisited.push(dest);

    // 遍历源对象上的所有属性,对每个属性进行深拷贝,并把拷贝结果赋值给目标对象的相应属性
    for (var key in src) {
        dest[key] = deepCopy(src[key], _visited, _copiesVisited);
    }
    return dest; // 返回拷贝的结果
}

// 如果当前环境没有 Object.create 方法,则定义一个兼容的版本
var object_create = Object.create;
if (typeof object_create !== 'function') {
    object_create = function(o) {
        function F() {}
        F.prototype = o;
        return new F();
    };
}

四、开发中可能会遇到过的一个关于拷贝的bug

在一个非常常见的电商订单场景,当时封装了一个自定义 hooks;

const initialOrderData = {
  totalPayment: 0, 
  orderList: [], // 这里是数组,如果不注意可能会造成bug,要特別注意
};

const useOrderDataHandler = () => {
  const [orderData, setOrderData] = useState(initialOrderData);

  const addOrder = (newOrder) => {
    setOrderData((prev) => {
      const newTotalPayment = prev.totalPayment + newOrder.DiscountedTotalPrice;
      // 这里的操作会造成下面 resetOrderData 的 bug
      const newOrderList = prev.orderList;
      newOrderList.unshift(newOrder);

      return {
        totalPayment: newTotalPayment,
        orderList: newOrderList,
      };
    });
  };

  const resetOrderData = () => {
    setOrderData(initialOrderData);
    // 这里出现 Bug!!!
    // 因为 initialOrderData 中的 orderList
    // 已经被 addOrder 的 newOrderList.unshift(newOrder) 操作改变了
    // 导致 orderList 无法被 reset 回 []
  };
  // ......

  return {
    addOrder,
    resetOrderData,
    // ...
  };
};

怎么改变呢?

const initialOrderData = {
  totalPayment: 0,
  orderList: [],
};

const useOrderDataHandler = () => {
  // 深拷贝,确保 initialOrderData 中的 orderList 被完全复制,不会与原始值互相影响
  const clonedInitialOrderData = JSON.parse(JSON.stringify(initialOrderData));
  const [orderData, setOrderData] = useState(clonedInitialOrderData);
  // ...
};

忠告:笔者以前还在一个非常复杂的表单场景遇到过类似的问题,实际上在使用 JavaScript 这门语言的时候,对对象的处理很多时候会出现一些我们意想不到的问题,当两个对象之间存在某些关联的时候,最好还是做一次深拷贝,在对新对象进行操作,以确保数据之间不会互相影响。

五、性能测试

社区甚至有人对这些方法做了性能测试,鉴于篇幅长度和文章的重点,我就不在此做过多的讨论了,链接已放出,各位有兴趣的同学可以自行测试。

按性能进行深拷贝: 根据基准从最佳到最差排名

image.png

数据来源:deep-copy-comparison

end ~

欢迎关注之前的一些文章: