MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript引用类型的深浅拷贝

2024-03-191.3k 阅读

一、理解 JavaScript 中的引用类型

在 JavaScript 中,数据类型分为基本类型和引用类型。基本类型包括 undefinednullbooleannumberstringsymbol(ES6 新增),它们的值直接存储在栈内存中。而引用类型,如 ObjectArrayFunction 等,其值存储在堆内存中,在栈内存中存储的是指向堆内存中实际数据的引用地址。

例如,创建一个对象:

let obj = { name: 'John', age: 30 };

这里 obj 变量存储在栈内存中,它指向堆内存中存储对象 { name: 'John', age: 30 } 的地址。当我们进行赋值操作时:

let newObj = obj;

newObj 同样存储了指向堆内存中该对象的引用地址,而不是复制了一份对象数据。所以,如果通过 newObj 修改对象属性:

newObj.age = 31;
console.log(obj.age); // 输出 31

objage 属性也会改变,因为它们指向同一个对象。这就是引用类型的特性,理解这一点是掌握深浅拷贝的基础。

二、浅拷贝的概念与实现

(一)浅拷贝的定义

浅拷贝是创建一个新的对象或数组,这个新的对象或数组会复制原对象或数组的第一层属性或元素,但对于嵌套的引用类型,新对象和原对象仍然共享这些引用类型的内存地址。

(二)使用 Object.assign() 实现浅拷贝

Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它返回目标对象。

let originalObj = {
    name: 'Alice',
    hobbies: ['reading', 'painting'],
    address: { city: 'New York', country: 'USA' }
};

let shallowCopiedObj = Object.assign({}, originalObj);

// 修改浅拷贝对象的第一层属性
shallowCopiedObj.name = 'Bob';
console.log(originalObj.name); // 输出 'Alice'

// 修改浅拷贝对象的嵌套引用类型属性
shallowCopiedObj.address.city = 'Los Angeles';
console.log(originalObj.address.city); // 输出 'Los Angeles'

// 修改浅拷贝对象的数组属性
shallowCopiedObj.hobbies.push('dancing');
console.log(originalObj.hobbies); // 输出 ['reading', 'painting', 'dancing']

在上述代码中,使用 Object.assign()originalObj 进行浅拷贝得到 shallowCopiedObj。当修改 shallowCopiedObj 的第一层属性 name 时,originalObjname 属性不受影响。但当修改 shallowCopiedObj 的嵌套引用类型属性 address.city 以及数组属性 hobbies 时,originalObj 中的对应属性也会改变,这体现了浅拷贝对于嵌套引用类型只是复制引用地址的特性。

(三)使用展开运算符(...)实现浅拷贝

展开运算符也可以用于对象和数组的浅拷贝。

  1. 对象浅拷贝
let originalObj2 = {
    color: 'blue',
    size: 'large',
    details: { material: 'wood' }
};

let shallowCopiedObj2 = {...originalObj2 };

// 修改浅拷贝对象的第一层属性
shallowCopiedObj2.color = 'green';
console.log(originalObj2.color); // 输出 'blue'

// 修改浅拷贝对象的嵌套引用类型属性
shallowCopiedObj2.details.material ='metal';
console.log(originalObj2.details.material); // 输出'metal'
  1. 数组浅拷贝
let originalArray = [1, 2, { value: 3 }];
let shallowCopiedArray = [...originalArray];

// 修改浅拷贝数组的第一层元素
shallowCopiedArray[0] = 0;
console.log(originalArray[0]); // 输出 1

// 修改浅拷贝数组的嵌套引用类型元素
shallowCopiedArray[2].value = 4;
console.log(originalArray[2].value); // 输出 4

无论是对象还是数组,通过展开运算符进行的拷贝都是浅拷贝,对于嵌套的引用类型会共享内存地址。

(四)手动实现浅拷贝

我们也可以手动实现一个浅拷贝函数,对于对象的浅拷贝:

function shallowCopyObject(target) {
    let result = {};
    for (let key in target) {
        if (target.hasOwnProperty(key)) {
            result[key] = target[key];
        }
    }
    return result;
}

let originalObj3 = {
    title: 'Book',
    author: 'Author Name',
    info: { pages: 200 }
};

let shallowCopiedObj3 = shallowCopyObject(originalObj3);

// 修改浅拷贝对象的第一层属性
shallowCopiedObj3.title = 'New Book';
console.log(originalObj3.title); // 输出 'Book'

// 修改浅拷贝对象的嵌套引用类型属性
shallowCopiedObj3.info.pages = 300;
console.log(originalObj3.info.pages); // 输出 300

对于数组的浅拷贝:

function shallowCopyArray(target) {
    let result = [];
    for (let i = 0; i < target.length; i++) {
        result[i] = target[i];
    }
    return result;
}

let originalArray2 = [5, 6, { num: 7 }];
let shallowCopiedArray2 = shallowCopyArray(originalArray2);

// 修改浅拷贝数组的第一层元素
shallowCopiedArray2[0] = 4;
console.log(originalArray2[0]); // 输出 5

// 修改浅拷贝数组的嵌套引用类型元素
shallowCopiedArray2[2].num = 8;
console.log(originalArray2[2].num); // 输出 8

手动实现的浅拷贝同样遵循浅拷贝的规则,对于嵌套的引用类型只是复制引用。

三、深拷贝的概念与实现

(一)深拷贝的定义

深拷贝是创建一个全新的对象或数组,并且递归地复制原对象或数组的所有层级的属性或元素,新对象和原对象在内存中是完全独立的,修改新对象不会影响原对象,反之亦然。

(二)使用 JSON.parse(JSON.stringify()) 实现深拷贝

这是一种较为常见的深拷贝方法,它先将对象或数组转换为 JSON 字符串,然后再从 JSON 字符串解析回对象或数组,从而实现深拷贝。

let originalObj4 = {
    id: 1,
    name: 'Tom',
    friends: ['Jerry', 'Mike'],
    profile: { age: 25, gender:'male' }
};

let deepCopiedObj = JSON.parse(JSON.stringify(originalObj4));

// 修改深拷贝对象的属性
deepCopiedObj.name = 'Tim';
deepCopiedObj.profile.age = 26;
deepCopiedObj.friends.push('Jack');

console.log(originalObj4.name); // 输出 'Tom'
console.log(originalObj4.profile.age); // 输出 25
console.log(originalObj4.friends); // 输出 ['Jerry', 'Mike']

在上述代码中,通过 JSON.parse(JSON.stringify(originalObj4))originalObj4 进行深拷贝得到 deepCopiedObj。修改 deepCopiedObj 的属性不会影响 originalObj4,表明实现了深拷贝。

然而,这种方法有一些局限性:

  1. 不能处理函数:如果对象中包含函数属性,在 JSON.stringify() 过程中函数会被忽略。
let objWithFunction = {
    func: function() {
        console.log('This is a function');
    }
};

let copiedWithJson = JSON.parse(JSON.stringify(objWithFunction));
console.log(copiedWithJson.func); // 输出 undefined
  1. 不能处理 undefinedJSON.stringify() 会忽略值为 undefined 的属性。
let objWithUndefined = {
    value1: 10,
    value2: undefined
};

let copiedUndefined = JSON.parse(JSON.stringify(objWithUndefined));
console.log(copiedUndefined.value2); // 输出 undefined,实际上属性已被忽略
  1. 不能处理循环引用:如果对象存在循环引用,JSON.stringify() 会报错。
let circularObj = {};
circularObj.selfReference = circularObj;

try {
    JSON.stringify(circularObj);
} catch (error) {
    console.log('Error:', error); // 输出 'Error: Converting circular structure to JSON'
}

(三)手动递归实现深拷贝

为了实现一个更通用的深拷贝,我们可以手动递归地复制对象和数组。

function deepCopy(target) {
    if (typeof target!== 'object' || target === null) {
        return target;
    }

    let result;
    if (Array.isArray(target)) {
        result = [];
        for (let i = 0; i < target.length; i++) {
            result[i] = deepCopy(target[i]);
        }
    } else {
        result = {};
        for (let key in target) {
            if (target.hasOwnProperty(key)) {
                result[key] = deepCopy(target[key]);
            }
        }
    }
    return result;
}

let originalObj5 = {
    data: [1, 2, { subData: 3 }],
    settings: {
        option1: true,
        option2: 'value2',
        nested: { subOption: 'nested value' }
    }
};

let deepCopiedObj5 = deepCopy(originalObj5);

// 修改深拷贝对象的属性
deepCopiedObj5.data[2].subData = 4;
deepCopiedObj5.settings.nested.subOption = 'new nested value';

console.log(originalObj5.data[2].subData); // 输出 3
console.log(originalObj5.settings.nested.subOption); // 输出 'nested value'

在这个 deepCopy 函数中,首先判断 target 是否为对象或 null,如果不是则直接返回 target。对于数组,创建一个新数组并递归复制每个元素;对于对象,创建一个新对象并递归复制每个属性。这样就实现了对对象和数组的深拷贝,能够处理嵌套的引用类型以及函数、undefined 等情况,并且在一定程度上可以处理循环引用(虽然还不够完善,下面会介绍更完善的处理方法)。

(四)使用 WeakMap 处理循环引用实现深拷贝

在前面手动递归实现深拷贝的基础上,我们可以使用 WeakMap 来处理对象的循环引用。WeakMap 是一种弱引用的键值对集合,它的键必须是对象,并且这些对象不会阻止垃圾回收机制回收它们。

function deepCopyWithWeakMap(target, map = new WeakMap()) {
    if (typeof target!== 'object' || target === null) {
        return target;
    }

    if (map.has(target)) {
        return map.get(target);
    }

    let result;
    if (Array.isArray(target)) {
        result = [];
        map.set(target, result);
        for (let i = 0; i < target.length; i++) {
            result[i] = deepCopyWithWeakMap(target[i], map);
        }
    } else {
        result = {};
        map.set(target, result);
        for (let key in target) {
            if (target.hasOwnProperty(key)) {
                result[key] = deepCopyWithWeakMap(target[key], map);
            }
        }
    }
    return result;
}

let circularObj2 = {};
circularObj2.child = {};
circularObj2.child.parent = circularObj2;

let copiedCircularObj = deepCopyWithWeakMap(circularObj2);
console.log(copiedCircularObj.child.parent === copiedCircularObj); // 输出 true,表明处理了循环引用

deepCopyWithWeakMap 函数中,我们使用 WeakMap 来存储已经复制过的对象。在复制过程中,如果发现当前对象已经在 WeakMap 中,就直接返回已经复制的对象,从而避免了无限递归。这样就可以更完善地实现包含循环引用的对象的深拷贝。

四、深浅拷贝在实际项目中的应用场景

(一)浅拷贝的应用场景

  1. 性能优化场景:当对象的嵌套层级较浅,并且对嵌套引用类型的修改不会影响业务逻辑时,使用浅拷贝可以提高性能。因为深拷贝需要递归复制所有层级的属性,在数据量较大时性能开销较大。例如,在一些简单的数据展示场景中,只需要展示数据的第一层属性,并且不需要担心数据被误修改影响其他部分逻辑,此时浅拷贝就可以满足需求。
  2. 数据视图分离场景:在一些前端框架中,如 Vue.js 或 React,有时需要将数据的一部分展示给用户,并且允许用户进行一些操作,但这些操作不应该影响原始数据。例如,在一个商品列表展示页面,用户可以对商品的一些展示属性进行临时修改(如改变商品图片的显示大小),但不影响商品在数据库中的原始数据。这时可以使用浅拷贝将商品数据复制一份用于展示和用户操作。

(二)深拷贝的应用场景

  1. 数据备份与恢复场景:在进行数据处理之前,需要对原始数据进行备份,以便在处理出错时能够恢复到原始状态。例如,在一个数据清理脚本中,可能会对数据进行一系列的过滤、转换等操作。在操作之前对原始数据进行深拷贝,这样如果操作过程中出现错误,可以直接使用备份数据重新开始。
  2. 独立数据操作场景:当需要对数据进行独立的修改,并且不希望影响其他部分使用的相同数据时,深拷贝是必要的。比如在一个多人协作的在线文档编辑系统中,每个用户对文档的操作应该是独立的,互不影响。当用户打开文档时,系统可以对文档数据进行深拷贝,用户在自己的拷贝上进行编辑,不会影响其他用户看到的文档内容。

五、深浅拷贝与内存管理

(一)浅拷贝与内存

浅拷贝由于对于嵌套引用类型只是复制引用地址,所以在内存使用上,新对象和原对象会共享嵌套引用类型的内存空间。这在一定程度上可以节省内存,特别是当嵌套引用类型的数据量较大时。然而,如果对共享的嵌套引用类型数据进行频繁修改,可能会导致内存碎片化等问题,影响程序的性能。

例如,在一个包含大量图片对象的数组中,如果使用浅拷贝,新数组和原数组共享图片对象的引用,虽然减少了内存占用,但如果其中一个数组对图片对象的属性进行频繁修改,可能会导致图片对象在内存中的存储结构变得碎片化,影响图片的加载和显示性能。

(二)深拷贝与内存

深拷贝会递归地复制所有层级的属性,这意味着会占用更多的内存空间。对于大型复杂对象,深拷贝可能会导致内存消耗过大,甚至引发内存溢出错误。

例如,在处理一个包含大量嵌套对象和数组的地理信息数据时,进行深拷贝可能会占用大量内存。因此,在使用深拷贝时,需要谨慎评估数据量和系统的内存承受能力。同时,在深拷贝完成后,如果不再需要原对象,可以及时释放原对象占用的内存,以避免内存泄漏。

六、不同环境下深浅拷贝的兼容性

(一)浏览器环境

在现代浏览器中,Object.assign()、展开运算符(...)以及 JSON.parse(JSON.stringify()) 等方法都有较好的兼容性。然而,对于一些较老的浏览器,如 Internet Explorer,Object.assign() 和展开运算符可能不被支持,需要使用 polyfill 来实现类似功能。

对于手动实现的深浅拷贝方法,只要 JavaScript 引擎支持基本的语法和特性,就可以正常运行。但在处理一些特殊对象,如 Date 对象、RegExp 对象等时,可能需要额外的处理逻辑来确保兼容性。例如,JSON.parse(JSON.stringify()) 在处理 Date 对象时,会将其转换为字符串,深拷贝后的对象不再是 Date 实例。在手动实现深拷贝时,需要专门处理 Date 对象,创建新的 Date 实例并设置相同的时间值。

(二)Node.js 环境

在 Node.js 环境中,与浏览器环境类似,Object.assign()、展开运算符等方法也有很好的支持。JSON.parse(JSON.stringify()) 同样存在一些局限性,如不能处理函数、undefined 和循环引用等。

Node.js 环境中可能会处理更多的服务器端数据,如数据库查询结果等。这些数据可能包含一些特殊的数据类型,如 Buffer 对象(用于处理二进制数据)。在进行深浅拷贝时,需要特别注意对 Buffer 对象的处理。Buffer 对象不能直接通过 JSON.parse(JSON.stringify()) 进行深拷贝,需要手动实现对 Buffer 对象的复制逻辑,例如创建一个新的 Buffer 对象并复制原 Buffer 的内容。

七、深浅拷贝与函数参数传递

(一)浅拷贝与函数参数传递

当将一个引用类型作为函数参数传递时,实际上传递的是引用地址,这类似于浅拷贝的概念。在函数内部对参数对象或数组的修改会影响到外部的原始对象或数组。

function modifyArray(arr) {
    arr.push(4);
    return arr;
}

let originalArray3 = [1, 2, 3];
let resultArray = modifyArray(originalArray3);
console.log(originalArray3); // 输出 [1, 2, 3, 4]

在上述代码中,originalArray3 作为参数传递给 modifyArray 函数,函数内部对数组的修改影响到了外部的 originalArray3。如果希望在函数内部对数组的修改不影响外部数组,可以在函数内部进行浅拷贝。

function modifyArrayWithShallowCopy(arr) {
    let copiedArr = [...arr];
    copiedArr.push(4);
    return copiedArr;
}

let originalArray4 = [1, 2, 3];
let resultArray2 = modifyArrayWithShallowCopy(originalArray4);
console.log(originalArray4); // 输出 [1, 2, 3]

这样,通过浅拷贝,函数内部的操作不会影响到外部的原始数组。

(二)深拷贝与函数参数传递

如果函数内部需要对参数进行深度的独立修改,不希望任何层级的修改影响到外部原始对象,就需要进行深拷贝。

function modifyComplexObject(obj) {
    let deepCopiedObj = deepCopy(obj);
    deepCopiedObj.nested.value = 'new value';
    return deepCopiedObj;
}

let originalComplexObj = {
    data: 'initial data',
    nested: { value: 'old value' }
};

let resultComplexObj = modifyComplexObject(originalComplexObj);
console.log(originalComplexObj.nested.value); // 输出 'old value'

在上述代码中,通过对传入的 originalComplexObj 进行深拷贝,函数内部对深拷贝后的对象的修改不会影响到外部的 originalComplexObj

八、深浅拷贝与前端框架

(一)Vue.js 中的深浅拷贝

在 Vue.js 中,数据响应式系统依赖于对象的引用。当数据发生变化时,Vue 会检测到引用的变化并更新视图。在一些情况下,需要使用深浅拷贝来确保数据的独立性和正确的响应式更新。

例如,在 Vue 组件中,如果从父组件传递一个对象给子组件,并且子组件需要对这个对象进行独立的修改,不影响父组件的数据,可以在子组件中对传入的对象进行深拷贝。

<template>
  <div>
    <child-component :data="parentData"></child-component>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent
  },
  data() {
    return {
      parentData: {
        value: 'initial value'
      }
    };
  }
};
</script>

ChildComponent.vue 中:

<template>
  <div>
    <button @click="modifyData">Modify Data</button>
  </div>
</template>

<script>
export default {
  props: ['data'],
  methods: {
    modifyData() {
      let deepCopiedData = deepCopy(this.data);
      deepCopiedData.value = 'new value';
      // 这里可以使用深拷贝后的数据进行操作,不会影响父组件的 data
    }
  }
};
</script>

通过深拷贝,子组件可以独立地修改数据,而不会触发父组件数据的意外更新。

(二)React 中的深浅拷贝

在 React 中,数据是单向流动的,组件的状态和属性应该是不可变的。当需要更新状态或属性时,通常需要创建新的对象或数组,而不是直接修改原有的数据。这就涉及到深浅拷贝的概念。

例如,在一个 React 组件中更新对象状态:

import React, { useState } from'react';

function App() {
    const [data, setData] = useState({
        name: 'John',
        age: 30
    });

    const handleClick = () => {
        let newData = {...data };
        newData.age = 31;
        setData(newData);
    };

    return (
        <div>
            <p>Name: {data.name}, Age: {data.age}</p>
            <button onClick={handleClick}>Increment Age</button>
        </div>
    );
}

export default App;

这里使用展开运算符进行浅拷贝创建新的对象来更新状态。如果对象包含嵌套的引用类型,并且需要独立更新嵌套部分,就需要进行深拷贝。

import React, { useState } from'react';

function App() {
    const [data, setData] = useState({
        name: 'John',
        address: { city: 'New York' }
    });

    const handleClick = () => {
        let newData = deepCopy(data);
        newData.address.city = 'Los Angeles';
        setData(newData);
    };

    return (
        <div>
            <p>Name: {data.name}, City: {data.address.city}</p>
            <button onClick={handleClick}>Change City</button>
        </div>
    );
}

export default App;

通过深拷贝,确保了状态更新的独立性,符合 React 的不可变数据原则,从而保证了组件的可预测性和性能优化。

通过以上对 JavaScript 引用类型深浅拷贝的详细介绍,包括概念、实现方法、应用场景、内存管理、兼容性以及在前端框架中的应用等方面,希望能帮助开发者更好地理解和运用深浅拷贝,编写出更健壮、高效的 JavaScript 代码。