JavaScript 赋值、浅拷贝、深拷贝解析

508 阅读16分钟

JavaScript 赋值、浅拷贝、深拷贝解析

JavaScript 复制一条数据是常见的操作,而复制操作常用的方式包括以下三种:

  • 赋值

  • 浅拷贝

  • 深拷贝

本文的目的是为了搞清楚这三者的优缺点和适用场景,文章中如果存在不足之处,欢迎指正讨论。

数据类型与堆栈的关系

JavaScript 中数据类型

JavaScript 中的数据类型包括两类,基本数据类型和引用数据类型

基本数据类型

String :任意字符串
Number : 任意数字
Boolean : 布尔类型(true/false)
undefined :未赋值(undefined),定义了未赋值
null : 空值(null),定义并赋值了,只是赋值为null
Symbol : ES6新增的基本数据类型,每个从Symbol()返回的symbol的值都是唯一的

引用(对象)数据类型(Object 类型)

Object :任意对象
Function : 一种特别的对象(可以执行),判断其类型是不是Object返回的为true,即 Function 也是一种对象类型的数据
Array: 一种特别的对象(数值下标, 内部数据是有序的,一般的对象中的数据是无序的),判断其类型是不是Object返回的为true,即 Array 也是一种对象类型的数据
Date : 日期时间对象
RegExp :正则对象
......
  • 引用类型的值是对象,保存在堆内存中。而栈内存存储的是对象的变量标识符以及对象在堆内存中的存储地址(引用),引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。

  • 闭包中的变量并不保存中栈内存中,而是保存在堆内存中。如果闭包中的变量保存在了栈内存中,随着外层中的函数从调用栈中销毁,变量肯定也会被销毁,但是如果保存在了堆内存中,内存函数仍能访问外层已销毁函数中的变量。

function A() {
  let sss = 'sweetheart'
  function B() {
      console.log(sss)
  }
  return B
}
  • 函数 A 返回了一个函数 B,并且函数 B 中使用了函数 A 的变量,函数 B 就被称为闭包。
  • 函数 A 调用后,函数 A 中的变量这时候是存储在堆内存上的,所以函数 B 依旧能引用到函数 A 中的变量

赋值

基本数据类型赋值

let a ='sweetheart';
let b = a;
b='sweetheartjq.com';
console.log(a); // sweetheart
  • 结论:在栈内存中的数据发生变化时,系统会自动为新的变量分配一个新的值在栈内存中,两个变量相互独立,互不影响。

引用数据类型赋值

let a = {x:'哈哈哈哈', y:'嘿嘿嘿嘿'}
let b = a;
b.x = 'sweetheart';
console.log(a.x); // sweetheart
  • 结论:引用类型的赋值,同样为新的变量 b 分配一个新的值,保存在栈内存中,不同的是这个变量对应的具体的值不在栈中,栈中只是一个地址指针。两个变量地址指针相同,指向堆内存中的对象,因此 b.x 发生改变的时候,a.x 也发生了改变。(对象的赋值时赋的是该对象在栈中的地址值,而不是堆中的数据值,所以当其中一个对象改变时另外一个对象也会改变)

浅拷贝

理解

  • 浅拷贝是创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。

  • 一个新的对象直接拷贝已存在的对象的对象属性的引用,即浅拷贝。

  • 如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。

遍历实现[ES5]

  • 遍历的方式实现浅拷贝,不会修改原数组,只是返回了复制了原数组的元素的新的数组。

  • 示例

Array.prototype.clone = function () {
  let a = [];
  for (let i = 0, l = this.length; i < l; i++) {
    a.push(this[i]);
  }
  return a;
};
let arr = ["aaa", "bbb", "ccc", "ddd", { x: 0 }];
let arr2 = arr.clone();
arr2[0] = "qwe";
arr2[4].x = 10;
arr[0] = "123";
console.log(arr2); // ["qwe", "bbb", "ccc", "ddd",{x: 10}]
console.log(arr); // ["123", "bbb", "ccc", "ddd",{x: 10}]
console.log(arr2 === arr); // false
  • 结论:通过遍历实现的对象的浅拷贝,对于基础数据类型,拷贝的就是原基础数据类型的值。改变其中任意一个并不会对另外一个产生影响。对于引用数据类型,拷贝的就是其对象的地址值,两者栈内存的标识指向的是堆内存中的同一个地址值,所以两者任何一个发生改变,另一个也会随之发生改变。

Array.prototype.slice[ES5]

  • Array 的 slice()不修改原数组,只会返回一个浅复制了原数组中的元素的一个新数组。

  • 示例 1

let a = [ 1, 3, 5, { x: 1 } ];
let b = Array.prototype.slice.call(a);
b[0] = 2;
console.log(a); // [ 1, 3, 5, { x: 1 } ];
console.log(b); // [ 2, 3, 5, { x: 1 } ];

浅拷贝后,数组 a[0]并不会随着 b[0]改变而改变,说明 a 和 b 在栈内存中引用地址并不相同。

  • 示例 2
let a = [ 1, 3, 5, { x: 1 } ];
let b = Array.prototype.slice.call(a);
b[3].x = 2;
console.log(a); // [ 1, 3, 5, { x: 2 } ];
console.log(b); // [ 1, 3, 5, { x: 2 } ];

浅拷贝后,数组中对象的属性会根据修改而改变,说明对象 b 拷贝的是对象 a 中的对象属性的属性引用,

  • 结论:如果拷贝的属性是基本类型,拷贝的就是基本类型的值,如果拷贝的属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。

Array.prototype.concat[ES5]

  • Array.prototype.concat()方法用于合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组。

  • 语法

var new_array = old_array.concat(value1[, value2[, ...[, valueN]]])

valueN 可选
数组和/或值,将被合并到一个新的数组中。如果省略了所有 valueN 参数,则 concat 会返回调用此方法的现存数组的一个浅拷贝。

  • 示例 1
let array = [{ a: 1 }, { b: 2 }, 3, 4, 5, 6];
let array1 = [{ c: 3 }, { d: 4 }, 1, 2, 5];
let array2 = array.concat(array1);
array1[0].c = 123;
array1[3] = 123;
array2[3] = 44444;
console.log(array2); // [ { a: 1 }, { b: 2 }, 3, 44444, 5, 6, { c: 123 }, { d: 4 }, 1 , 2 , 5 ]
console.log(array1); // [ { c: 123 }, { d: 4 },1,123,5 ]
console.log(array);  // [{a: 1}, {b: 2}, 3, 4, 5, 6]
  • 示例 2
let array = [{a: 1}, {b: 2}];
let array1 = array.concat();
array1[0].a = 'qwe';
console.log(array1); // [ { a: qwe }, { b: 2 }]
console.log(array);// [{a: qwe}, {b: 2}]

  • 结论:Array.prototype.concat()也是一个浅拷贝,只是在根属性(对象的第一层级)创建了一个新的对象,但如果属性的值是对象的话只会拷贝一份相同的内存地址。对于基础数据类型,改变两者中的任意一个都不会对另一个产生影响,对于引用数据类型,复制之后的是数组指向的依旧是原数组的地址值,改变其中任意一个的对象的值,堆内存中对象的数据会发生变化,则另一个数组中的对象的值也会随之改变。

Object.assign[ES6]

  • Object.assign 不会改变原数组,只会返回一个浅拷贝了原数组中的元素的一个新数组。

  • 语法

Object.assign(target, ...sources)

该方法接受的第一个参数是拷贝的目标 target,剩下的参数是拷贝的源对象 sources(可以是多个)

  • 示例
let target = {};
let source = {a:'sweetheart',b:{name:'哈哈哈哈'}};
Object.assign(target ,source);
console.log(target); // { a: 'sweetheart', b: { name: '哈哈哈哈嘿嘿嘿嘿' } }
source.a = 'sweetheartqqqqqq';
source.b.name = '哈哈哈哈嘿嘿嘿嘿'
console.log(source); // { a: 'sweetheartqqqqqq', b: { name: '哈哈哈哈嘿嘿嘿嘿' } }
console.log(target); // { a: 'sweetheart', b: { name: '哈哈哈哈嘿嘿嘿嘿' } }
  • Object.assign 是一个浅拷贝,它只是在根属性(对象的第一层级)创建了一个新的对象,但是对于属性的值是对象的话只会拷贝一份相同的内存地址。
  • Object.assign 注意事项
    • 1.只拷贝源对象的自身属性(不拷贝继承属性)
    • 2.它不会拷贝对象不可枚举的属性
    • 3.undefined 和 null 无法转成对象,它们不能作为 Object.assign 参数,但是可以作为源对象
    • 4.属性名为 Symbol 值的属性,可以被 Object.assign 拷贝。
Object.assign(undefined) // 报错
Object.assign(null) // 报错
let obj = {a: 1};
Object.assign(obj, undefined) === obj // true
Object.assign(obj, null) === obj // true
  • 结论:对于对象中某一个属性值为基础数据类型的,改变两者中的任意一个都不会对另一个产生影响,对于对象中某一个属性值为引用数据类型的,复制之后的是数组指向的依旧是原数组的地址值,改变其中任意一个的对象的值,堆内存中对象的数据会发生变化,则另一个数组中的对象的值也会随之改变。

扩展运算符(...)[ES6]

展开运算符是一个 ES6 / ES2015 特性,它提供了一种非常方便的方式来执行浅拷贝,这与 Object.assign ()的功能相同。

  • 扩展运算符(...)不会改变原数组,只会返回一个浅拷贝了原数组中的元素的一个新数组。

  • 语法

var cloneObj = { ...obj };

cloneObj 为复制的新的数组,obj 为原数组

  • 示例
let obj = {a:1,b:{c:1}}
let obj2 = {...obj};
obj.a=2;
obj2.a =123
console.log(obj); //{a:2,b:{c:2}}
console.log(obj2); //{a:123,b:{c:2}}

obj.b.c = 2;
console.log(obj); //{a:2,b:{c:2}}
console.log(obj2); //{a:123,b:{c:2}}
  • 扩展运算符也是浅拷贝,对于值是对象的属性无法完全拷贝成 2 个不同对象,但是如果属性都是基本类型的值的话,使用扩展运算符也是优势方便的地方。

  • 结论:对于对象中属性值为基础数据类型的,改变两者中的任意一个都不会对另一个产生影响,对于对象中属性值为引用数据类型的,复制之后的是数组指向的依旧是原数组的地址值,改变其中任意一个的对象的值,堆内存中对象的数据会发生变化,则另一个数组中的对象的值也会随之改变。

使用 Lodash 工具库的 clone 方法

  • Lodash 是一个 JavaScript 实用工具库,使用它的 clone 方法可以实现对象、数组等的浅拷贝(浅克隆)

  • 注意:此时需要使用 lodash 这个第三方库,所以需要安装这个第三方库的依赖,并在控制台的 node 环境下运行

    [这个第三方库需要在 node 环境中运行,若在浏览器中直接运行会报错,若需要在浏览器中直接运行,则可以在 HTML 页面中引入 lodash 这个库,然后打开这个 HTML 页面]

    若没有安装这个依赖会报错,具体报错如下:

    此时就需要安装lodash这个依赖,执行安装lodash依赖的命令如下(若当前目录下不存在package.json文件,则请先执行npm init命令初始化创建package.json文件)

    npm install -D lodash
    
const _ = require("lodash");

let objects = [{ a: 1 }, { b: 2 }, { c: 3 }, 1, 2, 3, 4, 5, 6];
let shallows = _.clone(objects);

objects[0].a = 11111; // 修改第一个元素值
shallows[shallows.length - 1] = 123456;

console.log(objects); //[ { a: 11111 }, { b: 2 }, { c: 3 }, 1, 2, 3, 4, 5, 6 ]
console.log(shallows); //[ { a: 11111 }, { b: 2 }, { c: 3 }, 1, 2, 3, 4, 5, 123456 ]
  • 结 论:不会改变原数组,只会返回一个浅拷贝了原数组中的元素的一个新数组。对于对象中属性值为基础数据类型的,改变两者中的任意一个都不会对另一个产生影响,对于对象中属性值为引用数据类型的,复制之后的是数组指向的依旧是原数组的地址值,改变其中任意一个的对象的值,堆内存中对象的数据会发生变化,则另一个数组中的对象的值也会随之改变。

自己手动实现浅拷贝

实现原理:

  • 新的对象复制已有对象中非对象属性的值和对象属性的引用,也就是说对象属性并不复制到内存。

  • 创建一个新的对象,遍历需要克隆的对象,将需要克隆对象的属性依次添加到新对象上,返回。

function clone(target) {
    let cloneTarget = {};
    for (const key in target) {
        cloneTarget[key] = target[key];
    }
    return cloneTarget;
};
  • for...in 语句以任意顺序遍历一个对象自有的、继承的、可枚举的、非 Symbol 的属性。对于每个不同的属性,语句都会被执行。

  • hasOwnProperty(prop) prop 是要检测的属性字符串名称或者 Symbol, 函数返回值为布尔值,所有继承了 Object 的对象都会继承到 hasOwnProperty 方法,和 in 运算符不同,该函数会忽略掉那些从原型链上继承到的属性和自身属性。

深拷贝

深拷贝是将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象(深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存),且修改新对象不会影响原对象。

JSON.parse(JSON.stringify())

  • 原理是把一个对象序列化成为一个 JSON 字符串,将对象的内容转换成字符串的形式再保存在磁盘上,再用 JSON.parse()反序列化将 JSON 字符串变成一个新的对象。

  • 示例

let arr = [
  1,
  3,
  {
    username: " sweetheart",
  },
  function test() {
    console.log("哈哈哈哈");
  },
  RegExp(/(\w+)\s(\w+)/)
];
let arr1 = JSON.parse(JSON.stringify(arr));
arr1[2].username = "哈哈哈哈哈哈";
arr1[1] = 10;
console.log(arr1); // [ 1, 10, { username: '哈哈哈哈哈哈' } ,null,{}]
console.log(arr); // [ 1, 3, { username: ' sweetheart' } ,ƒ test(), /(\w+)\s(\w+)/]
  • 通过深拷贝实现对象的复制操作,当改变数组中对象的值时候,原数组中的内容并没有发生改变。

  • JSON.stringify()实现深拷贝注意点

  • 拷贝的对象的值中如果有函数,undefined,symbol 则经过 JSON.stringify()序列化后的 JSON 字符串中这个键值对会消失,在序列化和反序列化之后这个属性值会变为 null

  • 无法拷贝不可枚举的属性,无法拷贝对象的原型链

  • 拷贝 Date 引用类型会变成字符串

  • 拷贝 RegExp 引用类型会变成空对象{}

  • 对象中含有 NaN、Infinity 和-Infinity,则序列化的结果会变成 null

  • 无法拷贝对象的循环应用(即 obj[key] = obj)

使用 Lodash 工具库的 cloneDeep 方法

Lodash 工具库有一个 cloneDeep()方法用来做 深拷贝。

const _ = require("lodash");
let obj1 = {
  a: 1,
  b: { f: { g: 1 } },
  c: [1, 2, 3],
};
let obj2 = _.cloneDeep(obj1);
obj2.a = 123;
obj2.c = { x: 1, y: 0 };
console.log(obj1); //{ a: 1, b: { f: { g: 1 } }, c: [ 1, 2, 3 ] }
console.log(obj2); //{ a: 123, b: { f: { g: 1 } }, c: { x: 1, y: 0 } }
console.log(obj1.b.f === obj2.b.f); // false
  • 结论:当改变数组中对象的值时候,原数组中的内容并没有发生改变。

注意,此代码需要在 node 环境中运行,若运行代码报错,请参考上文浅拷贝中使用 Lodash 工具库的 clone 方法的使用进行操作修改

jQuery.extend()方法

jQuery 提供了$.extend()方法来做深拷贝。

$.extend(deepCopy, target, object1, [objectN])//第一个参数为true,就是深拷贝
//let $ = require("jquery");
let obj1 = {
  a: 1,
  b: { f: { g: 1 } },
  c: [1, 2, 3],
};

let obj2 = $.extend(true, {}, obj1);
obj2.a = 123;
obj2.c = { x: 1, y: 0 };
console.log(obj1); //{ a: 1, b: { f: { g: 1 } }, c: [ 1, 2, 3 ] }
console.log(obj2); //{ a: 123, b: { f: { g: 1 } }, c: { x: 1, y: 0 } }
console.log(obj1.b.f === obj2.b.f); // false

注意,此代码若想直接在 HTML 中执行,在浏览器中看到结果,则需要在 HTML 页面中引入 jQuery 这个库

自己实现深拷贝

实现一个深拷贝,考虑到我们要拷贝的对象是不知道有多少层深度的,我们可以用递归来解决问题,稍微改写上面的代码:

  • 如果是原始类型,无需继续拷贝,直接返回
  • 如果是引用类型,创建一个新的对象,遍历需要克隆的对象,将需要克隆对象的属性执行深拷贝后依次添加到新对象上。

很容易理解,如果有更深层次的对象可以继续递归直到属性为原始类型,这样我们就完成了一个最简单的深拷贝

  • 简单的示例(在循环引用的时候会进入死循环导致内存溢出)
function clone(target) {
    if (typeof target === 'object') {
        let cloneTarget = Array.isArray(target) ? [] : {};
        for (const key in target) {
            cloneTarget[key] = clone(target[key]);
        }
        return cloneTarget;
    } else {
        return target;
    }
};

上面的代码存在循环引用的时候会进入死循环导致内存溢出的问题,因为上面的对象存在循环引用的情况,即对象的属性间接或直接的引用了自身的情况。

  • 更好的解决方案(解决了因为循环引用导致的内存溢出问题)

解决循环引用问题,我们可以额外开辟一个存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就巧妙化解的循环引用的问题。

这个存储空间,需要可以存储 key-value 形式的数据,且 key 可以是一个引用类型,我们可以选择 Map 这种数据结构:

  • 检查 map 中有无克隆过的对象
    • 有就直接返回
    • 没有就将当前对象作为 key,克隆对象作为 value 进行存储
  • 继续克隆
function clone(target, map = new Map()) {
    if (typeof target === 'object') {
        let cloneTarget = Array.isArray(target) ? [] : {};
        if (map.get(target)) {
            return map.get(target);
        }
        map.set(target, cloneTarget);
        for (const key in target) {
            cloneTarget[key] = clone(target[key], map);
        }
        return cloneTarget;
    } else {
        return target;
    }
};

赋值、浅拷贝、深拷贝的区别

这三者的区别如下,不过比较的前提都是针对引用类型

  • 赋值: 当我们把一个对象赋值给一个新的变量时,赋的其实是该对象的在栈中的地址,而不是堆中的数据。也就是两个对象指向的是同一个存储空间,无论哪个对象发生改变,其实都是改变的存储空间的内容,因此,两个对象是联动的。

  • 浅拷贝:重新在堆中创建内存,拷贝前后对象的基本数据类型互不影响,但拷贝前后对象的引用类型因共享同一块内存,会相互影响。

  • 深拷贝:从堆内存中开辟一个新的区域存放新对象,对对象中的子对象进行递归拷贝,深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存,拷贝前后的两个对象互不影响。

参考链接

程序员成长指北 掘金

segmentfault 高声望文章

浪里行舟 掘金