JavaScript浅拷贝和深拷贝

Clloz · · 113次浏览 ·

前言

JavaScript 中最重要的就是对象,除了 Number, String, Null, Undefined, Boolean, Symbol, BigInt 等基本数据类型,剩下的就 Object 对象。JavaScript 也给我们提供了一系列内置对象,比如 Function,Array,Math,Date,RegExp 等等,他们都是用 function Object() 构造的。我们使用 JavaScript 大多是时候都是在操作对象。本文就讲一讲复制对象涉及到的浅拷贝和深拷贝。

内存堆栈

在讲对象的复制之前我们先来了解一下 JavaScript 中的数据类型在内存中是如何存放的。

每一个数据都需要分配一块内存空间,内存空间分为两种:栈 stack 和 堆 heapstack 为自动分配的内存空间,它由系统自动释放;而 heap 则是动态分配的内存,大小不定也不会自动释。基本类型值是存储在栈中的简单数据段,也就是说,他们的值直接存储在变量访问的位置。堆是存放数据的基于散列算法的数据结构,在 javascript 中,引用值是存放在堆中的。

let a = 10;
let b = 20;
let obj = {
    name: 'clloz'
}
let obj2 = obj

比如这段代码在内存中的结构应该为:

shallowcoppy

所以访问基本类型的变量时,是直接访问到栈内存中其真正的值;而访问引用类型的变量时,是通过栈内存中保存的引用地址去访问。

栈的优势就是存取速度比堆要快,仅次于直接位于 CPU 中的寄存器,但缺点是,存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,垃圾收集器会自动地收走这些不再使用的数据,但是缺点是由于在运行时动态分配内存,所以存取速度较慢。所以对于基本数据类型,他们占用内存比较小,如果放在堆中,查找会浪费很多时间,而把堆中的数据放入栈中也会影响栈的效率。比如对象和数组是可以无限拓展的,正好放在可以动态分配大小的堆中。

从上面的例子中我们可以看到,我们将一个对象赋值给一个变量的时候,系统会在栈中为我们分配一块空间,里面存入对象在堆中的地址。objobj1 指向的是堆中的同一块内存,不管我们用哪个标识符来操作对象中的数据,都会影响到另一个,因为他们本质就是同一个对象的不同名字。而如果是基本数据类型的复制,则直接在栈中将值写入,新变量的改变不会影响到原来的变量。

这就是值传递和地址传递的主要区别,也就是深拷贝和浅拷贝的产生的原因。当我们想要真正地复制一个对象,希望开辟一块新的内存空间,新对象的操作不会影响到原来的对象,就需要用深拷贝的方式。

概念

有了上面对数据类型和内存对战的概念,我们可以来说一说浅拷贝 shallow copy 和深拷贝 deep copy 的概念了。

  • 浅拷贝:地址传递,本质是指向同一对象的不同标识符。对对象进行改动会影响到所有浅拷贝对象。
  • 深拷贝:真正的复制对象,在堆中单独开辟一块内存空间,复制对象的所有属性,包括嵌套的对象也会复制。当我们不希望新的对象和之前的对象关联时,我们应该使用深拷贝。

浅拷贝实现

赋值

最简单的浅拷贝方式就是我们最常用的赋值。

Object.assingn()

Object.assign() 方法也是常用的浅拷贝方法,该方法只会拷贝源对象自身的并且可枚举的属性到目标对象,String 类型和 Symbol 类型的属性都会被拷贝。不过需要注意的是 Object.assign() 是一层深拷贝,看下面的代码:

let a = {
    p1: 10,
    p2: 20,
    p3: {
        m: 100,
        n: 200
    }
}
let b = Object.assign({}, a);
console.log(b) //{ p1: 10, p2: 20, p3: { m: 100, n: 200 } }
b.p1 = 'teste';
console.log(a) //{ p1: 10, p2: 20, p3: { m: 100, n: 200 } } a中的p1没有改变
b.p3.m = 'test'
console.log(a) //{ p1: 10, p2: 20, p3: { m: 'test', n: 200 } } a.m是一个嵌套对象,浅拷贝

我们看到拷贝后的 b 对象将 p1 改变后,a 中的 p1 并没有改变,所以是一层深拷贝。


关于 Object.assign() 还有需要注意的点就是,该方法只能拷贝源对象的可枚举的自身属性,同时拷贝时无法拷贝属性的特性们,而且访问器属性会被转换成数据属性(值为访问器属性的 getter 的返回值,也无法拷贝源对象的原型。看下面的例子。

//'use strict'
let a_p = {
    fun: () => console.log('a.[[prototype]]')
}
let a = Object.create(a_p);
let out_var = 'out variable'

Object.defineProperty(a, Symbol('symbol'), {
    value: 'symbol',
    enumerable: true
})

Object.defineProperty(a, 'val', { //不可枚举属性
    value: 100,
    configurable: false,
    enumerable: false,
    writable: true
})

Object.defineProperty(a, 'enum', {//可枚举属性
    value: 'enumerable',
    configurable: false,
    enumerable: true,
    writable: false
})


Object.defineProperty(a, 'm', { //不可枚举的访问器属性
    enumerable: false,
    set(val) {
        this.val = val;
    },
    get() {
        return this.val;
    }
})

Object.defineProperty(a, 'n', { //可枚举的访问器属性
    enumerable: true,
    set(val) {
        a.val = val
    },
    get() {
        return a.val
    }
})


let b = Object.assign({}, a)
console.log(b) //{ enum: 'enumerable', n: 100, [Symbol(symbol)]: 'symbol' } 只有可枚举的数据属性和访问器属性是会被复制的。访问器属性被转换成数据属性,值是调用访问器属性getter的返回值
console.log(Object.getOwnPropertyDescriptor(b, 'enum')) //属性描述符全部变为 true
//{
//  value: 'enumerable',
//  writable: true,
//  enumerable: true,
//  configurable: true
//}
console.log(Object.getPrototypeOf(b) === Object.prototype) //true 没有复制原型

如果想要实现复制属性的特性,访问器属性以及链接原型,可用如下的方法:

let c = Object.create(
    Object.getPrototypeOf(a),
    Object.getOwnPropertyDescriptors(a)
);

Object.create()

Object.create() 也可以实现对象的一层深拷贝。主要是结合对象的属性类型和赋值特性,可以参考另一篇文章:JavaScript对象属性类型和赋值细节

深拷贝实现

对象字面量

最简单的做法就是用对象字面量重新定义一个对象。这种方法很笨拙也不通用。

JSON 序列化和反序列化

JSON.stringify()JSON.parse() 进行序列化反序列化。先将一个 JavaScript 对象转为一个 JSON 字符串,然后再将字符串转为对象。这种方法不能复制非枚举属性,也不能复制属性特性,也不能复制访问器属性。而且JSON.parse()JSON.stringify() 能正确处理的对象只有 NumberStringArrayBoolean 等能够被 json 表示的数据结构,因此函数,RegExp这种不能被 json 表示的类型将不能被正确处理。

还有一点存在循环引用的对象,例如 let a = {m:a} 这样的对象序列化会报错 TypeError: Converting circular structure to JSON

这个方法根据自己的需求使用。

for…in 递归

这个方法是最好理解的,代码如下。注意 for...in 会遍历所有能访问到的属性,包括原型链上的。

let testObj = {
    num: 0,
    str: 'clloz',
    boolean: true,
    unf: undefined,
    nul: null,
    obj: {
        name: 'clloz',
        id: 1
    },
    arr: [0, 1, 2],
    func: function() {
        console.log('clloz')
    },
    date: new Date(0),
    reg: new RegExp('/clloz/ig'),
    err: new Error('clloz')
}

function isObject(obj) {
    return (typeof obj === 'function' || typeof obj === 'object') && obj !== null;
}

function deepClone(obj) {
    if (!isObject(obj)) {
        throw new Error('obj is not a Object!');
    }

    let isArray = Array.isArray(obj);

    let newObj = isArray ? [] : {};

    for (let prop in obj) {
        if (obj.hasOwnProperty(prop)) {
            newObj[prop] = isObject(obj[prop]) ? deepClone(obj[prop]) : obj[prop];
        }
    }
    return newObj;
}

let a = deepClone(testObj)
console.log(a)
//{
//  num: 0,
//  str: '',
//  boolean: true,
//  unf: undefined,
//  nul: null,
//  obj: { name: '我是一个对象', id: 1 },
//  arr: [ 0, 1, 2 ],
//  func: {},
//  date: {},
//  reg: {},
//  err: {}
//}

console.log(a.obj === testObj.obj) //false
console.log(a.arr === testObj.arr) //false

最终成功拷贝了 objarrfuncdateregerr 没有拷贝成功,因为他们不是普通的 Object 结构。

第三方库

jQuery.extend, baseClone -lodash,还有 lodash自定义深拷贝方法 cloneDeepWith。有兴趣可以去阅读以下源码。

深入

循环引用

我们在深拷贝的 JSON 序列化部分说到了循环引用会引起序列化报错的问题。但时期我们的 for...in 方法遇到循环引用一样会出问题。

比如上面的例子,我们为 testObj 添加一个属性 loop: testObj,在执行会发现栈溢出了 RangeError: Maximum call stack size exceeded

如果你用了 lodashbaseClone 会发现它的执行不会报错,因为它用栈来保存克隆的对象,用来检测循环引用。

  // Check for circular references and return its corresponding clone.
  stack || (stack = new Stack)
  const stacked = stack.get(value)
  if (stacked) {
    return stacked
  }

Symbol

如果属性的 key 是一个 Symbol,那么 for...in 无法遍历到该属性。这里我们可以用 Reflect.ownKeys() 方法。也可以使用 Object.getOwnPropertySymbols() 方法。

function deepClone(obj) {
    if (!isObject(obj)) {
        throw new Error('obj 不是一个对象!')
    }

    let isArray = Array.isArray(obj)
    let newObj = isArray ? [...obj] : { ...obj }
    Reflect.ownKeys(newObj).forEach(key => {
        newObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
    })

    return newObj
}

不可枚举属性

关于不可枚举属性的深拷贝,我想到的是用 Object.getOwnPropertyNames 来遍历添加。如果遇到 value 是对象则递归。不过很多内置对象需要单独处理,比如 FunctionRegExpDate 等。

let testObj = {
    num: 0,
    str: 'clloz',
    boolean: true,
    unf: undefined,
    nul: null,
    obj: {
        name: 'clloz',
        id: 1
    },
    arr: [0, 1, 2],
    func: function() {
        console.log('clloz')
    },
    date: new Date(0),
    reg: new RegExp('/clloz/ig'),
    err: new Error('clloz')
}
Object.defineProperty(testObj, 'test', {
    value: {
        name: 'clloz',
        age: 28
    },
    writable: true,
    enumerable: false,
    configurable: true,
})

function isObject(obj) {
    return (typeof obj === 'object') && obj !== null && obj instanceof RegExp !== true && obj instanceof Date !== true;
}

function deepClone(obj) {
    if (!isObject(obj)) {
        throw new Error('obj is not a Object!');
    }

    let isArray = Array.isArray(obj);

    let newObj = isArray ? [] : {};

    let keys = Object.getOwnPropertyNames(obj);

    for (let key of keys) {
        if (isObject(Object.getOwnPropertyDescriptor(obj, key).value)) {
            Object.defineProperty(newObj, key, {
                value: deepClone(obj[key]),
                configurable: Object.getOwnPropertyDescriptor(obj, key).configurable,
                enumerable: Object.getOwnPropertyDescriptor(obj, key).enumerable,
                writable: Object.getOwnPropertyDescriptor(obj, key).writable,
            })
        } else {
            Object.defineProperty(newObj, key, Object.getOwnPropertyDescriptor(obj, key))
        }
    }
    return newObj;
}

let a = deepClone(testObj)
console.log(Object.getOwnPropertyDescriptors(a))
console.log(a.obj === testObj.obj) //false
console.log(a.arr === testObj.arr) //false

这个实现在加上 Symbol的处理,循环引用的处理基本就可以应对大多数情况了。如果你还想增加对原型的支持,那么可以在创建对象的时候用 Object.create()

总结

如果要实现一个能应对各种对象各种情况的深拷贝函数还是非常不容易的,本文处理的情况包括:

  • Symbol:用 Reflect.ownKeys() 或者 Object.getOwnPropertySymbols()
  • for-in:遍历原型链上的属性,如果不需要原型上的方法,可以添加判断。
  • Object.getOwnPropertyNames: 实现非枚举属性的拷贝
  • 循环引用的处理

在日常的编码中我们不太会遇到这么复杂的情况,大多数情况下我们要深拷贝的对象用 for...in 或者序列化就可以处理了。扩展这么多主要是为了加深对 JavaScript 对象的理解。

参考文章

  1. JS数据类型和内存堆栈
  2. JS的栈与堆的讲解
  3. Object.getOwnPropertyDescriptors() – MDN
  4. Object.assign() – MDN
  5. 深入深入在深入JS深拷贝对象

Clloz

Clloz

人生をやり直す

发表评论

电子邮件地址不会被公开。 必填项已用*标注

我不是机器人*

 

00:00/00:00