【進階4-2期】Object.assign 原理及其實現

木易楊說發表於2019-03-03

引言

上篇文章介紹了賦值、淺拷貝和深拷貝,其中介紹了很多賦值和淺拷貝的相關知識以及兩者區別,限於篇幅只介紹了一種常用深拷貝方案。

本篇文章會先介紹淺拷貝 Object.assign 的實現原理,然後帶你手動實現一個淺拷貝,並在文末留下一道面試題,期待你的評論。

淺拷貝 Object.assign

上篇文章介紹了其定義和使用,主要是將所有可列舉屬性的值從一個或多個源物件複製到目標物件,同時返回目標物件。(來自 MDN)

語法如下所示:

Object.assign(target, ...sources)

其中 target 是目標物件,sources 是源物件,可以有多個,返回修改後的目標物件 target

如果目標物件中的屬性具有相同的鍵,則屬性將被源物件中的屬性覆蓋。後來的源物件的屬性將類似地覆蓋早先的屬性。

示例1

我們知道淺拷貝就是拷貝第一層的基本型別值,以及第一層的引用型別地址

// 木易楊
// 第一步
let a = {
    name: "advanced",
    age: 18
}
let b = {
    name: "muyiy",
    book: {
        title: "You Don`t Know JS",
        price: "45"
    }
}
let c = Object.assign(a, b);
console.log(c);
// {
// 	name: "muyiy",
//  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: "muyiy",
//  age: 18,
// 	book: {title: "You Don`t Know JS", price: "55"}
// } 
複製程式碼

1、在第一步中,使用 Object.assign 把源物件 b 的值複製到目標物件 a 中,這裡把返回值定義為物件 c,可以看出 b 會替換掉 a 中具有相同鍵的值,即如果目標物件(a)中的屬性具有相同的鍵,則屬性將被源物件(b)中的屬性覆蓋。這裡需要注意下,返回物件 c 就是 目標物件 a。

2、在第二步中,修改源物件 b 的基本型別值(name)和引用型別值(book)。

3、在第三步中,淺拷貝之後目標物件 a 的基本型別值沒有改變,但是引用型別值發生了改變,因為 Object.assign() 拷貝的是屬性值。假如源物件的屬性值是一個指向物件的引用,它也只拷貝那個引用地址

示例2

String 型別和 Symbol 型別的屬性都會被拷貝,而且不會跳過那些值為 nullundefined 的源物件。

// 木易楊
// 第一步
let a = {
    name: "muyiy",
    age: 18
}
let b = {
    b1: Symbol("muyiy"),
    b2: null,
    b3: undefined
}
let c = Object.assign(a, b);
console.log(c);
// {
// 	name: "muyiy",
//  age: 18,
// 	b1: Symbol(muyiy),
// 	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`) {
  // Attention 1
  Object.defineProperty(Object, "assign2", {
    value: function (target) {
      `use strict`;
      if (target == null) { // Attention 2
        throw new TypeError(`Cannot convert undefined or null to object`);
      }

      // Attention 3
      var to = Object(target);
        
      for (var index = 1; index < arguments.length; index++) {
        var nextSource = arguments[index];

        if (nextSource != null) {  // Attention 2
          // Attention 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: "muyiy",
    book: {
        title: "You Don`t Know JS",
        price: "45"
    }
}
let c = Object.assign2(a, b);
console.log(c);
// {
// 	name: "muyiy",
//  age: 18,
// 	book: {title: "You Don`t Know JS", price: "45"}
// } 
console.log(a === c);
// true
複製程式碼

針對上面的程式碼做如下擴充套件。

注意1:可列舉性

原生情況下掛載在 Object 上的屬性是不可列舉的,但是直接在 Object 上掛載屬性 a 之後是可列舉的,所以這裡必須使用 Object.defineProperty,並設定 enumerable: false 以及 writable: true, configurable: true

// 木易楊
for(var i in Object) {
    console.log(Object[i]);
}
// 無輸出

Object.keys( Object );
// []
複製程式碼

上面程式碼說明原生 Object 上的屬性不可列舉。

我們可以使用 2 種方法檢視 Object.assign 是否可列舉,使用 Object.getOwnPropertyDescriptor 或者 Object.propertyIsEnumerable 都可以,其中propertyIsEnumerable(..) 會檢查給定的屬性名是否直接存在於物件中(而不是在原型鏈上)並且滿足 enumerable: true。具體用法如下:

// 木易楊
// 方法1
Object.getOwnPropertyDescriptor(Object, "assign");
// {
// 	value: ƒ, 
//  writable: true, 	// 可寫
//  enumerable: false,  // 不可列舉,注意這裡是 false
//  configurable: true	// 可配置
// }

// 方法2
Object.propertyIsEnumerable("assign");
// false
複製程式碼

上面程式碼說明 Object.assign 是不可列舉的。

介紹這麼多是因為直接在 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, 	// 注意這裡是 false
//  enumerable: false,  // 注意這裡是 false
//  configurable: false	// 注意這裡是 false
// }
複製程式碼

所以具體到本次模擬實現中,相關程式碼如下。

// 木易楊
// 判斷原生 Object 中是否存在函式 assign2
if (typeof Object.assign2 != `function`) {
  // 使用屬性描述符定義新屬性 assign2
  Object.defineProperty(Object, "assign2", {
    value: function (target) { 
      ...
    },
    // 預設值是 false,即 enumerable: false
    writable: true,
    configurable: true
  });
}
複製程式碼

注意2:判斷引數是否正確

有些文章判斷引數是否正確是這樣的。

// 木易楊
if (target === undefined || target === null) {
	throw new TypeError(`Cannot convert undefined or null to object`);
}
複製程式碼

這樣肯定沒問題,但是這樣寫沒有必要,因為 undefinednull 是相等的(高程 3 P52 ),即 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.assign({}, v1, null, v2, undefined, v3, v4); 
// 原始型別會被包裝,null 和 undefined 會被忽略。
// 注意,只有字串的包裝物件才可能有自身可列舉屬性。
console.log(obj); 
// { "0": "a", "1": "b", "2": "c" }
複製程式碼

上面程式碼中的源物件 v2、v3、v4 實際上被忽略了,原因在於他們自身沒有可列舉屬性

// 木易楊
var v1 = "abc";
var v2 = true;
var v3 = 10;
var v4 = Symbol("foo");
var v5 = null;

// 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 = Object.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(..) 就可以了。

// 木易楊
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("abc") 時,其屬性描述符為不可寫,即 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 
// }
複製程式碼

同理,下面的程式碼也會報錯。

// 木易楊
var a = "abc";
var b = {
  0: "d"
};
Object.assign(a, b); 
// TypeError: Cannot assign to read only property `0` of object `[object String]`
複製程式碼

但是並不是說只要 writable: false 就會報錯,看下面的程式碼。

// 木易楊
var myObject = Object(`abc`); 

Object.getOwnPropertyDescriptor(myObject, `0`);
// { 
//   value: `a`,
//   writable: false, // 注意這裡
//   enumerable: true,
//   configurable: false 
// }

myObject[0] = `d`;
// `d`

myObject[0];
// `a`
複製程式碼

這裡並沒有報錯,原因在於 JS 對於不可寫的屬性值的修改靜默失敗(silently failed),在嚴格模式下才會提示錯誤。

// 木易楊
`use strict`
var myObject = Object(`abc`); 

myObject[0] = `d`;
// TypeError: Cannot assign to read only property `0` of object `[object String]`
複製程式碼

所以我們在模擬實現 Object.assign 時需要使用嚴格模式。

注意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 方法,區別如下(你不知道的JS上卷 P119):

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
複製程式碼

解決方法也很簡單,使用我們在【進階3-3期】中介紹的 call 就可以了,使用如下。

// 木易楊
var myObject = Object.create( null );
myObject.b = 2;

Object.prototype.hasOwnProperty.call(myObject, "b");
// true
複製程式碼

所以具體到本次模擬實現中,相關程式碼如下。

// 木易楊
// 使用 for..in 遍歷物件 nextSource 獲取屬性值
// 此處會同時檢查其原型鏈上的屬性
for (var nextKey in nextSource) {
    // 使用 hasOwnProperty 判斷物件 nextSource 中是否存在屬性 nextKey
    // 過濾其原型鏈上的屬性
    if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
        // 賦值給物件 to,並在遍歷結束後返回物件 to
        to[nextKey] = nextSource[nextKey];
    }
}
複製程式碼

本期思考題

如何實現一個深拷貝?

參考

MDN 之 Object.assign()

ES2015系列(二) 理解 Object.assign

進階系列目錄

  • 【進階1期】 呼叫堆疊
  • 【進階2期】 作用域閉包
  • 【進階3期】 this全面解析
  • 【進階4期】 深淺拷貝原理
  • 【進階5期】 原型Prototype
  • 【進階6期】 高階函式
  • 【進階7期】 事件機制
  • 【進階8期】 Event Loop原理
  • 【進階9期】 Promise原理
  • 【進階10期】Async/Await原理
  • 【進階11期】防抖/節流原理
  • 【進階12期】模組化詳解
  • 【進階13期】ES6重難點
  • 【進階14期】計算機網路概述
  • 【進階15期】瀏覽器渲染原理
  • 【進階16期】webpack配置
  • 【進階17期】webpack原理
  • 【進階18期】前端監控
  • 【進階19期】跨域和安全
  • 【進階20期】效能優化
  • 【進階21期】VirtualDom原理
  • 【進階22期】Diff演算法
  • 【進階23期】MVVM雙向繫結
  • 【進階24期】Vuex原理
  • 【進階25期】Redux原理
  • 【進階26期】路由原理
  • 【進階27期】VueRouter原始碼解析
  • 【進階28期】ReactRouter原始碼解析

交流

進階系列文章彙總如下,內有優質前端資料,覺得不錯點個star。

github.com/yygmind/blo…

我是木易楊,網易高階前端工程師,跟著我每週重點攻克一個前端面試重難點。接下來讓我帶你走進高階前端的世界,在進階的路上,共勉!

【進階4-2期】Object.assign 原理及其實現

相關文章