JS `Object.assign()`:对象浅拷贝与合并

各位靓仔靓女,老少爷们儿,大家好!我是你们的贴心小棉袄(技术层面的),今天咱们来聊聊JavaScript里一个非常实用,但又容易让人掉坑的家伙——Object.assign()

这玩意儿,说它简单吧,assign嘛,不就是“分配、赋值”的意思?说它复杂吧,一不小心就给你整出个深浅拷贝的幺蛾子,让你debug到怀疑人生。所以,今天咱们就来把它扒个精光,看看它到底是个什么玩意儿。

开场白:浅拷贝的世界你不懂

首先,我们得明确一个概念:JavaScript里的对象,那都是引用类型。啥意思呢?简单来说,你用等号=赋值的时候,赋的不是对象本身,而是指向这个对象在内存里的地址的“指针”。

let obj1 = { name: '张三', age: 20 };
let obj2 = obj1;

obj2.age = 25;

console.log(obj1.age); // 输出:25  !!! obj1也被修改了

看到没?明明改的是obj2ageobj1age也跟着变了!这就是引用类型的特性,obj1obj2指向的是同一个内存地址,修改其中一个,另一个自然也跟着变。

这种赋值方式,我们通常称之为“浅拷贝”。浅拷贝只是复制了对象的引用,而不是对象本身。

Object.assign():浅拷贝的福音?还是深坑?

Object.assign(),顾名思义,就是把一个或多个源对象(source objects)的属性复制到目标对象(target object)上。语法是这样的:

Object.assign(target, ...sources)
  • target: 目标对象,属性会被复制到这个对象上。
  • ...sources: 一个或多个源对象,它们的属性会被复制到目标对象上。

看起来很美好,对不对? 我们来试试:

let obj1 = { name: '张三', age: 20 };
let obj2 = Object.assign({}, obj1); // 注意,这里target是一个空对象{}

obj2.age = 25;

console.log(obj1.age); // 输出:20  !!! obj1没变了
console.log(obj2.age); // 输出:25

咦?这次obj1age没变!难道Object.assign()实现了深拷贝? 先别高兴得太早,我们再来试试更复杂的情况:

let obj1 = {
  name: '张三',
  age: 20,
  address: {
    city: '北京',
    street: '长安街'
  }
};

let obj2 = Object.assign({}, obj1);

obj2.address.city = '上海';

console.log(obj1.address.city); // 输出:上海  !!! obj1的address也被修改了
console.log(obj2.address.city); // 输出:上海

纳尼?obj1address.city也被修改了! 这说明了什么? Object.assign()本质上还是浅拷贝! 它只会复制源对象的第一层属性,如果属性值是对象或者数组,那么复制的仍然是引用。

Object.assign()的深度剖析:一层拷贝,深度共享

为了更清楚地了解Object.assign()的工作原理,我们来详细分析一下它的拷贝过程。

  1. 创建目标对象: 如果target参数是一个已经存在的对象,那么Object.assign()会直接修改这个对象。如果target参数是一个空对象{},那么Object.assign()会创建一个新的对象作为目标对象。
  2. 遍历源对象: Object.assign()会遍历所有源对象,并按照源对象出现的顺序,依次将它们的属性复制到目标对象上。
  3. 属性复制: 对于每一个属性,Object.assign()会执行以下操作:

    • 如果源对象中存在该属性,并且目标对象中也存在同名属性,那么源对象的属性值会覆盖目标对象的属性值。
    • 如果源对象中存在该属性,但是目标对象中不存在同名属性,那么该属性会被添加到目标对象中。
    • 如果源对象中的属性值是基本类型(string, number, boolean, null, undefined, symbol),那么Object.assign()会直接复制这个值。
    • 如果源对象中的属性值是对象或数组,那么Object.assign()会复制这个对象的引用! 这就是浅拷贝的本质。

Object.assign()的各种用法:不仅仅是拷贝

虽然Object.assign()是浅拷贝,但它仍然非常有用。 我们来看看它的各种用法:

  1. 合并对象: 这是Object.assign()最常见的用法。 你可以将多个源对象的属性合并到一个目标对象中。

    let obj1 = { name: '张三', age: 20 };
    let obj2 = { city: '北京', job: '程序员' };
    let obj3 = { hobby: 'coding', gender: 'male' };
    
    let mergedObj = Object.assign({}, obj1, obj2, obj3);
    
    console.log(mergedObj);
    // 输出:{ name: '张三', age: 20, city: '北京', job: '程序员', hobby: 'coding', gender: 'male' }
  2. 给对象添加属性: 你可以使用Object.assign()给一个已经存在的对象添加新的属性。

    let obj = { name: '张三' };
    
    Object.assign(obj, { age: 20, city: '北京' });
    
    console.log(obj);
    // 输出:{ name: '张三', age: 20, city: '北京' }
  3. 设置默认值: 你可以使用Object.assign()来设置对象的默认值。 如果源对象中没有某个属性,那么目标对象就会使用默认值。

    let defaults = {
      name: '匿名',
      age: 18,
      city: '未知'
    };
    
    let user = { name: '张三', age: 20 };
    
    let mergedUser = Object.assign({}, defaults, user);
    
    console.log(mergedUser);
    // 输出:{ name: '张三', age: 20, city: '未知' }  (city使用了默认值)
  4. 创建对象的副本(浅拷贝): 这就是我们一开始看到的例子。 使用空对象{}作为目标对象,可以创建一个对象的浅拷贝。

    let obj1 = { name: '张三', age: 20 };
    let obj2 = Object.assign({}, obj1);

Object.assign()的注意事项:坑就在细节里

在使用Object.assign()的时候,有一些细节需要注意,否则很容易掉坑里。

  1. target参数是必须的: Object.assign()必须至少有一个target参数。 如果没有target参数,会报错。

    // Object.assign(); // 报错:TypeError: Cannot convert undefined or null to object
  2. 源对象的属性会覆盖目标对象的同名属性: 如果源对象和目标对象有同名属性,那么源对象的属性值会覆盖目标对象的属性值。 后面的源对象的属性会覆盖前面源对象的同名属性。

    let obj1 = { name: '张三', age: 20 };
    let obj2 = { name: '李四', city: '北京' };
    
    let mergedObj = Object.assign({}, obj1, obj2);
    
    console.log(mergedObj.name); // 输出:李四  (obj2的name覆盖了obj1的name)
  3. Object.assign()只能复制可枚举属性: Object.assign()只会复制源对象的可枚举属性。 不可枚举属性会被忽略。

    let obj1 = {};
    Object.defineProperty(obj1, 'name', {
      value: '张三',
      enumerable: false // 不可枚举
    });
    
    let obj2 = Object.assign({}, obj1);
    
    console.log(obj2.name); // 输出:undefined  (name属性没有被复制)
  4. Object.assign()会触发setter 如果目标对象有setter方法,那么Object.assign()会触发setter方法。

    let obj1 = {
      set name(value) {
        this._name = value.toUpperCase();
      },
      get name() {
        return this._name;
      }
    };
    
    let obj2 = {};
    
    Object.assign(obj2, { name: '张三' });
    
    console.log(obj2.name); // 输出:张三  (setter方法被触发,但是并没有实际改变值)

    (注意:这里输出的张三是因为赋值的时候,obj2并没有_name属性,所以get name方法返回了undefined。如果你在obj2中预先定义了_name属性,那么get name方法会返回_name的值。)

  5. Object.assign()nullundefined的处理: 如果源对象是nullundefined,那么Object.assign()会跳过它们,不会报错。

    let obj = { name: '张三' };
    
    Object.assign(obj, null, undefined, { age: 20 });
    
    console.log(obj);
    // 输出:{ name: '张三', age: 20 }

深拷贝的解决方案:告别浅拷贝的烦恼

既然Object.assign()是浅拷贝,那么如果我们需要深拷贝,该怎么办呢? 有几种常见的解决方案:

  1. JSON.parse(JSON.stringify(obj)): 这是最简单粗暴的方法。 先将对象转换为JSON字符串,然后再将JSON字符串解析为对象。 但是,这种方法有一些限制:

    • 不能复制函数。
    • 不能复制undefined
    • 不能复制Symbol类型的值。
    • 如果对象中存在循环引用,会报错。
    let obj1 = {
      name: '张三',
      age: 20,
      address: {
        city: '北京',
        street: '长安街'
      },
      func: function() {
        console.log('hello');
      },
      sym: Symbol('test'),
      undef: undefined
    };
    
    let obj2 = JSON.parse(JSON.stringify(obj1));
    
    obj2.address.city = '上海';
    
    console.log(obj1.address.city); // 输出:北京  (深拷贝成功)
    console.log(obj2.func); // 输出:undefined  (函数被忽略了)
    console.log(obj2.sym); // 输出:undefined (Symbol被忽略了)
    console.log(obj2.undef); // 输出:undefined (Undefined被忽略了)
    
    obj1.circular = obj1;
    //JSON.parse(JSON.stringify(obj1)); // 报错:TypeError: Converting circular structure to JSON
  2. 递归拷贝: 这是最通用的深拷贝方法。 递归遍历对象的每一个属性,如果属性值是对象或数组,那么递归调用拷贝函数。

    function deepClone(obj) {
      if (typeof obj !== 'object' || obj === null) {
        return obj; // 如果不是对象或数组,直接返回
      }
    
      let clonedObj = Array.isArray(obj) ? [] : {}; // 创建一个新的对象或数组
    
      for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
          clonedObj[key] = deepClone(obj[key]); // 递归调用拷贝函数
        }
      }
    
      return clonedObj;
    }
    
    let obj1 = {
      name: '张三',
      age: 20,
      address: {
        city: '北京',
        street: '长安街'
      },
      func: function() {
        console.log('hello');
      }
    };
    
    let obj2 = deepClone(obj1);
    
    obj2.address.city = '上海';
    obj2.func = function() {
      console.log('world');
    };
    
    console.log(obj1.address.city); // 输出:北京  (深拷贝成功)
    console.log(obj2.func); //输出: [Function (anonymous)]
    

    这个递归拷贝的函数,可以处理函数,但是仍然不能处理循环引用的情况,为了处理循环引用,需要使用WeakMap来记录已经拷贝过的对象。

  3. 使用lodash的_.cloneDeep()方法: lodash是一个非常流行的JavaScript工具库,它提供了很多实用的函数,包括深拷贝函数_.cloneDeep()

    const _ = require('lodash'); // 或者 import _ from 'lodash';
    
    let obj1 = {
      name: '张三',
      age: 20,
      address: {
        city: '北京',
        street: '长安街'
      }
    };
    
    let obj2 = _.cloneDeep(obj1);
    
    obj2.address.city = '上海';
    
    console.log(obj1.address.city); // 输出:北京  (深拷贝成功)

总结:Object.assign()是把好用的瑞士军刀,用对了很爽

Object.assign()是一个非常实用的工具,它可以用来合并对象、添加属性、设置默认值等等。但是,它本质上是浅拷贝,所以在使用的时候需要注意。如果需要深拷贝,可以使用JSON.parse(JSON.stringify(obj))、递归拷贝或者lodash的_.cloneDeep()方法。

记住:

  • Object.assign()是浅拷贝。
  • Object.assign()会覆盖目标对象的同名属性。
  • Object.assign()只能复制可枚举属性。
  • Object.assign()会触发setter方法。
  • Object.assign()nullundefined的处理是跳过。

希望今天的讲解能帮助大家更好地理解和使用Object.assign()。 下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注