Object.assign的原理及其实现方式

·  阅读 1089

此文首发于 lijing0906.github.io

上周在总结赋值和深浅拷贝的时候提到了Object.assign这种浅拷贝方式。这周谈谈它的原理以及实现方式。

浅拷贝Object.assign

上篇文章有讲到它的定义和用法,主要是将所有可枚举属性的值从一个或多个源对象中复制到目标对象,同时返回目标对象。 语法如下:

Object.assign(target,  ...source)
复制代码

其中target是目标对象,...source是源对象,可以是一个或多个,返回修改后的目标对象。 如果目标对象和源对象具有相同属性,则目标对象的该属性将会被源对象的相同属性覆盖,后来的源对象的属性将会类似地覆盖早先的属性。

示例1

我们知道浅拷贝就是拷贝对象的第一层的基本类型值,以及第一层的引用类型地址。

// 第一步
let a = {
    name: "Kitty",
    age: 18
}
let b = {
    name: "Jane",
    book: {
        title: "You Don't Know JS",
        price: "45"
    }
}
let c = Object.assign(a, b);
console.log(c);
// {
//     name: "Jane",
//     age: 18,
//     book: {title: "You Don't Know JS", price: "45"}
// } 
console.log(a === c); // true

// 第二步
b.name = "change";
b.book.price = "55";
console.log(b);
// {
//     name: "change",
//     book: {title: "You Don't Know JS", price: "55"}
// } 

// 第三步
console.log(a);
// {
//     name: "Jane",
//     age: 18,
//     book: {title: "You Don't Know JS", price: "55"}
// } 
复制代码

1、第一步中,使用Object.assign把源对象b中的属性复制到目标对象a中,把改变后的对象定义为c,可以看出b会替换掉a中相同的属性的值。上面的代码需要注意的是,返回对象c就是目标对象a。 2、第二步中,修改源对象b的基本类型值(name)和引用类型值(book)。 3、第三步中,浅拷贝之后目标对象a的基本类型值没有改变,但引用类型值被改变了,因为Object.assign拷贝的是属性值,当属性值是一个指向对象的引用时,它拷贝的那个引用地址。

示例2

String类型和Symbol类型的属性都会被拷贝,而且不会跳过那些值为nullundefined的属性。

let a = {
   name: "Jane",
   age: 20
}
let b = {
   b1: Symbol("Jane"),
   b2: null,
   b3: undefined
}
let c = Object.assign(a, b);
console.log(c);
// {
//     name: "Jane",
//     age: 20,
//     b1: Symbol(Jane),
//     b2: null,
//     b3: undefined
// } 
console.log(a === c); // true
复制代码

Object.assign模拟实现

实现一个Object.assign大致思路如下:

1、判断原生Object是否支持该函数,如果不存在的话创建一个函数assign,并使用Object.defineProperty将该函数绑定到Object上。 2、判断参数是否正确(目标对象不能为空,我们可以直接设置{}传递进去,但必须设置值)。 3、使用Object()转成对象,并保存为to,最后返回这个对象to。 4、使用for...in循环遍历出所有可枚举的自有属性。并复制给新的目标对象(hasOwnProperty返回非原型链上的属性)。 为了方便验证方便,使用assign2代替assign,注意以下模拟实现不支持symbol属性,因为ES5中根本没有symbol,实现代码如下:

if (typeof Object.assign2 != 'function') {
   Object.defineProperty(Object, 'assign2', { // 注意点1
       value: function(target) {
           'use strict';
           if (target == null) { // 注意点2
               throw new Error('Cannot convert undefined or null to object');
           }
           var to = Object(target); // 注意点3
           for (var index = 1; index < arguments.length; index++) {
               var nextSource = arguments[index];
               if (nextSource != null) { // 注意点2
                   // 注意点4
                   for (var nextKey in nextSource) {
                       if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
                           to[nextKey] = nextSource[nextKey];
                       }
                   }
               }
           }
           return to;
       },
       writable: true,
       configurable: true
   })
}
复制代码

测试一下:

let a = {
   name: "advanced",
   age: 18
}
let b = {
   name: "Jane",
   book: {
       title: "You Don't Know JS",
       price: "45"
   }
}
let c = Object.assign2(a, b);
console.log(c);
// {
//     name: "Jane",
//     age: 18,
//     book: {title: "You Don't Know JS", price: "45"}
// } 
console.log(a === c); // true
复制代码

注意点1 可枚举型

原生情况下挂载在Object上的属性是不可枚举的,但是直接在Object上挂载属性a之后是可枚举的,所以必须使用Object.defineProperty,并设置enumerable: falsewritable: true, configurable: true。 以下代码说明Object上的属性不可枚举:

for (var i in Object) {
   console.log(Object[i]);
}
// 无输出
Object.keys(Object); // []
复制代码

我们可以使用2种方法查看Object.assign是否可枚举,使用Object.getOwnPropertyDescriptor或者Object.propertyIsEnumerable都可以,其中propertyIsEnumerable(...)会检查给定的属性名是否直接存在于对象中(而不是在原型链上)并且满足enumerable: true。具体用法如下:

Object.getOwnPropertyDescriptor(Object, 'assign');
// {
//     value: ƒ, 
//     writable: true,     // 可写
//     enumerable: false,  // 不可枚举,注意这里是 false
//     configurable: true  // 可配置
// }
Object.propertyIsEnumerable('assign'); // false
复制代码

来看看直接在Object上挂载属性a之后可枚举的情况:

Object.a = function() {
    console.log('log a');
}
Object.getOwnpropertyDescriptor(Object, 'a');
// {
//      value: ƒ, 
//      writable: true, 
//      enumerable: true,  // 注意这里是 true
//      configurable: true
// }
Object.propertyIsEnumerable('a'); // true
复制代码

因为Object.assign是不可枚举的,所以不能用直接挂载的方式(可枚举)来模拟实现,必须用Object.defineProperty来设置writable: true, enumerable: false, configurable: true,当然默认情况下都是false

Object.defineProperty(Object, 'b', {
    value: function() {
        console.log('log b');
    }
})
Object.getOwnPropertyDescriptor(Object, 'b');
// {
//     value: ƒ, 
//     writable: false,     // 可写
//     enumerable: false,  // 不可枚举,注意这里是 false
//     configurable: false  // 可配置
// }
复制代码

注意点2 判断参数是否正确

因为undefinednull是相等的,即undefined == null返回true,只需要按照如下方式判断就好了。

if (target == null) { // TypeError if undefined or null
    throw new TypeError('Cannot convert undefined or null to object');
}
复制代码

注意点3 原始类型被包装为对象

var v1 = 'abc';
var v2 = true;
var v3 = 10;
var v4 = Symbol('foo');
var obj = Object.assgin({}, v1, null, v2, undefined, v3, v4);
// 原始类型会被包装,null和undefined会被忽略
// 注意,只有字符串的包装对象才可能有自身可枚举属性
console.log(obj); // {'0': 'a', '1': 'b', '2': 'c'}
复制代码

上面的代码可以看出v1、v2、v3实际上被忽略了,原因在于他们自身没有可枚举属性

var v1 = 'abc';
var v2 = true;
var v3 = 10;
var v4 = Symbol('foo');

// Object.keys() 返回一个数组,包含所有可枚举属性
// 只会查找对象直接包含的属性,不查找[[Prototype]]链
Object.keys( v1 ); // [ '0', '1', '2' ]
Object.keys( v2 ); // []
Object.keys( v3 ); // []
Object.keys( v4 ); // []
Object.keys( v5 ); // TypeError: Cannot convert undefined or null to object

// Object.getOwnPropertyNames(..) 返回一个数组,包含所有属性,无论它们是否可枚举
// 只会查找对象直接包含的属性,不查找[[Prototype]]链
Object.getOwnPropertyNames( v1 ); // [ '0', '1', '2', 'length' ]
Object.getOwnPropertyNames( v2 ); // []
Object.getOwnPropertyNames( v3 ); // []
Object.getOwnPropertyNames( v4 ); // []
Object.getOwnPropertyNames( v5 ); // TypeError: Cannot convert undefined or null to object
复制代码

但是下面的代码是可以执行的:

var a = 'abc';
var b = {
    v1: 'def',
    v2: true,
    v3: 10,
    v4: Symbol('foo'),
    v5: null,
    v6: undefined
}
var obj = Objec.assign(a, b);
console.log(obj);
// {
//     [String: 'abc']
//     v1: 'def',
//     v2: true,
//     v3: 10,
//     v4: Symbol('foo'),
//     v5: null,
//     v6: undefined
// }
复制代码

原因很简单,因为此时undefinedtrue等不适 作为对象,而是作为对象b的属性值,对象b是可枚举的。

Object.keys(b); // [ 'v1', 'v2', 'v3', 'v4', 'v5', 'v6' ]
复制代码

这里其实又可以看出一个问题来,那就是目标对象如果是原始类型,会被包装成对象,对应上面的代码就是目标对象a会被包装成[String: 'abc'],那模拟实现时应该如何处理呢?很简单,使用Object()就OK。

var a = 'abc';
console.log(Object(a)); // [String: 'abc']
复制代码

到这里已经介绍了很多知识了,让我们再来延伸一下。

var a = 'abc';
var b = 'def';
Object.assign(a, b); // TypeError: Cannot assign to read only property '0' of object '[object String]'
复制代码

报错的原因在于Object.assgin()时,其属性描述符为不可写,即writable: false

var myObject = Object('abc');
Object.getOwnPropertyNames(myObject);
// [ '0', '1', '2', 'length' ]

Object.getOwnPropertyDescriptor(myObject, '0');
// { 
//   value: 'a',
//   writable: false, // 注意这里
//   enumerable: true,
//   configurable: false 
// }
复制代码

注意点4 存在性

如何在不访问属性值的情况下判断对象中是否存在某个属性呢:

var anotherObject = {
    a: 1
};

// 创建一个关联到anotherObject的对象
var myObject = Object.create(anotherObject);
myObject.b = 2;

('a' in myObject); // true
('b' in myObject); // true

myObject.hasOwnProperty('a'); // false
myObject.hasOwnProperty('b'); // true
复制代码

上边用in操作符和hasOwnProperty方法,区别如下:

  1. in操作符会检查属性是否在对象及其[[Prototype]]原型链上
  2. hasOwnProperty()只会检查属性是否存在于myObject对象中,不会检查[[Prototype]]原型链 Object.assign方法肯定不会拷贝原型链上的属性,所以模拟实现时需要用hasOwnProperty()判断处理下,但是直接使用myObject.hasOwnProperty()是有问题的,因为有的对象可能没有连接到Object.prototype上(比如通过 Object.create(null)来创建),这种情况下,使用myObject.hasOwnProperty()就会失败。
var myObject = Object.create(null);
myObject.b = 2;

('b' in myObject); // true

myObject.hasOwnProperty( 'b' ); // TypeError: myObject.hasOwnProperty is not a function
复制代码

解决方法也很简单,使用之前介绍的call就可以了,使用如下:

var myObject = Object.create(null);
myObject.b = 2;

Object.prototype.hasOwnProperty.call(myObject, 'b'); // true
复制代码

参考文章

分类:
前端
标签:
分类:
前端
标签: