手柄君的小阁

个人私货聚集地

JavaScript对象拷贝遇到的坑和解决方法

本文最后更新于 2018 年 3 月 1 日,其中的内容可能有所发展或发生改变,敬请注意。

如果本文对您有任何帮助或者您有任何想要提出的意见或问题,请在本文下方回复,诚挚欢迎各位参与讨论,望各位不吝指教。
本文载于https://www.bysb.net/3113.html,请遵循 署名-非商业性使用-禁止演绎 4.0 国际许可协议

题图来源 João Silas on Unsplash

近期参与某集训,讲 JavaScript,遇到一对象拷贝问题,得到需求:
给一个对象,请编写一个函数,使其可以拷贝一个对象,返回这个拷贝得到的新对象:
举例如下:

1
2
3
4
function clone(obj){
    //DO SOMETHING
    return newObject; //返回拷贝得到的新对象
}

首先想到解法如下:
> ES6解构赋值(浅拷贝):

1
2
3
function clone(obj){
    return {...obj};
}

得到新对象为原始对象浅拷贝,即属性Key一致,值如果是数或者字符串则值传递,否则为地址传递,即Value引用和源对象一致,可根据下方运行测试:

1
2
3
4
5
6
var a = {a:1, b:2, c:3, d:[0, 1, 2]}
var b = clone(a);
console.log(b.d[1]); //1
b.d[1] = 2;
console.log(b.d[1]); //2
console.log(a.d[1]); //2

对复制后的对象中包含的数组或者对象进行编辑,影响了源对象,这显然不是我们想要的结果,但是在对象内不包含数组或对象时,该方法不失为一个快速创建对象拷贝的实用方法。
在ES6中,Object提供了一个 assign() 方法,也可以实现相同效果
> ES6 Object.assign()(浅拷贝):

1
2
3
function clone(obj){
    return Object.assign({},obj);
}

运行效果和前一种方式基本一致,根据MDN描述,Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象,允许至少两个参数,第一个参数为拷贝的目标对象,在方法执行结束后会被返回,其余参数将作为拷贝来源。
前面两种方法均为浅拷贝,那么对于对象内包含对象或数组的对象,我们该怎样拷贝呢?
我们的老师提供了一种方法如下,缺陷稍后再谈
> For...in遍历并递归(深拷贝):

1
2
3
4
5
6
7
8
9
10
11
function clone(obj) {
    var newobj = obj.constructor === Array ? [] : {};
    if (typeof obj !== "object") {
        return obj;
    } else {
        for (var i in obj) {
            newobj[i] = typeof obj[i] === "object" ? clone(obj[i]) : obj[i];
        }
    }
    return newobj;
}

同样使用前文中的测试数据:

1
2
3
4
5
6
var a = {a:1, b:2, c:3, d:[0, 1, 2]}
var b = clone(a);
console.log(b.d[1]); //1
b.d[1] = 2;
console.log(b.d[1]); //2
console.log(a.d[1]); //1

可见该方法可以正确地对对象进行深拷贝,并根据参数类型为数组或对象进行进行判断并分别处理,但是该方法有一定缺陷:

1,在存在Symbol类型属性key时,无法正确拷贝,可以尝试以下测试数据:

1
2
3
4
5
6
7
8
var sym = Symbol();
var a = {a:1, b:2, c:3, d:[0, 1, 2], [sym]:"symValue"}
var b = clone(a);
b.d[1] = 2;
console.log(b.d[1]); //2
console.log(a.d[1]); //1
console.log(a[sym]); //"symValue"
console.log(b[sym]); //undefined

可以发现拷贝得到的对象b,不存在Symbol类型对象为属性名的属性。
那么可以发现,问题主要出在For...in遍历属性无法获得Symbol类型Key导致,那么有什么方法可以遍历到这些呢?
在ES6中Reflect包含的静态方法ownKeys() 可以获取到这些key,根据MDN描述,这个方法获取到的返回值等同于Object.getOwnPropertyNames(target).concat(Object.getOwnPropertySymbols(target))。
那么使用ES6解构赋值和Reflect.ownKeys() 组合使用,改写上文函数,得到:
> ES6解构赋值 & Reflect.ownKeys() 遍历并递归(深拷贝):

1
2
3
4
5
6
7
8
9
10
11
function clone(obj) {
    var newobj = obj.constructor === Array ? [...obj] : {...obj};
    if (typeof obj !== "object") {
        return obj;
    } else {
        Reflect.ownKeys(newobj).forEach(i => {
            newobj[i] = typeof obj[i] === "object" ? clone(obj[i]) : obj[i];
        });
    }
    return newobj;
}

运行相同的测试语句:

1
2
3
4
5
6
7
8
9
10
11
var sym = Symbol();
var a = {a:1, b:2, c:3, d:[0, 1, 2], [sym]:"symValue"}
var b = clone(a);
b.d[1] = 2;
console.log(b.d[1]); //2
console.log(a.d[1]); //1
console.log(a[sym]); //"symValue"
console.log(b[sym]); //"symValue"
b[sym] = "newValue";
console.log(a[sym]); //"symValue"
console.log(b[sym]); //"newValue"

可以发现Symbol类型的key也被正确拷贝并赋值了,但是该方法依然有一定问题,如下:

2,在对象内部存在环时,堆栈溢出,尝试运行以下测试语句:

1
2
3
4
var a = { info: "a", arr: [0, 1, 2] };
var b = { data: a, info: "b", arr: [3, 4, 5] };
a.data = b;
var c = clone(a); //Error: Maximum call stack size exceeded. 报错:堆栈溢出

解决这个的方法稍后再讲,但目前来看已有的两种深拷贝方法足够平时使用,接下来正好提一下,ES5.1中包含的JSON对象,使用该对象亦可对对象进行深拷贝,会遇到的问题和第一种深拷贝方式一样,无法记录Symbol为属性名的属性,另外只能包含能用JSON字符串表示的数据类型,实现代码如下:
> JSON对象转义(深拷贝):

1
2
3
function clone(obj) {
    return JSON.parse(JSON.stringify(obj);
}

JSON.stringify() 首先将对象序列化为字符串,再由JSON.parse() 反序列化为对象,形成新的对象。
回到前面提到的问题2,如果对象内包含环,怎么办,我的实现思路为使用两个对象作为类似HashMap,记录源对象的结构,并在每层遍历前检查对象是否已经被拷贝过,如果是则重新指向到拷贝好的对象,防止无限递归。实现代码如下(配有注释):
> Map记录并递归(深拷贝):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
 * 深拷贝(包括Symbol)
 * @param {Object} obj
 */
function clone(obj) {
    const map = {}; //空对象,记录源对象
    const mapCopy = {}; //空对象,记录拷贝对象
    /**
     * 在theThis对象中,查找e对象的key,如果找不到,返回false
     * @param {Object} e 要查找的对象
     * @param {Object} theThis 在该对象内查找
     * @returns {symbol | boolean}
     */
    function indexOfFun(e, theThis) {
        let re = false;
        for (const key of Reflect.ownKeys(theThis)) {
            if (e === theThis[key]) {
                re = key;
                break;
            }
        }
        return re;
    }
    /**
     * 在Map对象中,查找e对象的key
     * @param {Object} e 
     */
    const indexOfMap = e => indexOfFun(e, map);
    /**
     * 在Map中记录obj对象内所有对象的地址
     * @param {Object} obj 要被记录的对象
     */
    function bindMap(obj) {
        map[Symbol()] = obj;
        Reflect.ownKeys(obj).forEach(key => {
            //当属性类型为Object且还没被记录过
            if (typeof obj[key] === "object" && !indexOfMap(obj[key])) {
                bindMap(obj[key]); //记录这个对象
            }
        });
    }
    bindMap(obj);
    /**
     * 拷贝对象
     * @param {Object} obj 要被拷贝的对象
     */
    function copyObj(obj) {
        let re;//用作返回
        if (Array.isArray(obj)) {
            re = [...obj]; //当obj为数组
        } else {
            re = { ...obj }; //当obj为对象
        }
        mapCopy[indexOfMap(obj)] = re; //记录新对象的地址
        Reflect.ownKeys(re).forEach(key => { //遍历新对象属性
            if (typeof re[key] === "object") { //当属性类型为Object
                if (mapCopy[indexOfMap(re[key])]) { //当属性已经被拷贝过
                    re[key] = mapCopy[indexOfMap(re[key])]; //修改属性指向到先前拷贝好的对象
                } else {//当属性还没有被拷贝
                    re[key] = copyObj(re[key]); //拷贝这个对象,并将属性指向新对象
                }
            }
        });
        return re; //返回拷贝的新对象
    }
    return copyObj(obj); //执行拷贝并返回
}

运行前面的测试语句:

1
2
3
4
5
6
7
8
9
10
11
var a = { info: "a", arr: [0, 1, 2] };
var b = { data: a, info: "b", arr: [3, 4, 5] };
a.data = b;
 
var c = clone(a);
c.info = "c";
c.data.info = "d";
console.log(a.info); //"a"
console.log(a.data.info); //"b"
console.log(c.info); //"c"
console.log(c.data.info); //"d"

得到该函数可以正确地拷贝带环对象。

在以上讨论和研究结束后,同学向我推荐了一个库 lodash,测试了一下该库存在 _.cloneDeep() 方法,实现深拷贝更为完整和精致,前文问题均没有在该方法内被发现,在这里提一波。

如果本文对您有任何帮助或者您有任何想要提出的意见或问题,请在本文下方回复,诚挚欢迎各位参与讨论,望各位不吝指教。
本文载于https://www.bysb.net/3113.html,请遵循 署名-非商业性使用-禁止演绎 4.0 国际许可协议

来一发吐槽