解析賦值、淺拷貝和深拷貝

  • 2019 年 11 月 7 日
  • 筆記

1.相關數據結構

堆(stack)和棧(heap)都是記憶體中劃分出來用來存儲的區域。

  • 棧數據結構 棧遵循後進先出(LIFO),執行上下文的基礎結構就是棧。
  • 堆數據結構 堆數據結構是一種樹狀結構。它的存取數據的方式與書架和書非常相似。我們只需要知道書的名字就可以直接取出書了,並不需要把上面的書取出來。JSON格式的數據中,我們存儲的 key-value 可以是無序的,因為順序的不同並不影響我們的使用,我們只需要關心書的名字。
  • 隊列數據結構 隊列遵循先進先出(FIFO),事件循環的基礎結構就是隊列。

2.數據類型

2.1 基本數據類型:

  • js 有 6 種基本數據類型:undefined,null,boolean,number,string,symbol
  • 基本數據類型在記憶體中分別佔有固定大小的空間,所以都是保存在棧記憶體中的(閉包的自由變數是例外,其保存在堆記憶體中,因此 context stack 銷毀後依然存在)
  • 基本數據類型是按值訪問的
  • 比較:值的比較

2.2 引用數據類型:

  • 一般指的是 object
  • 對象在記憶體中的大小不固定,所以保存在堆記憶體中,又由於對象的地址大小固定,所以地址保存在棧記憶體中
  • 引用數據類型是按引用訪問的。訪問對象時,先從棧中讀取記憶體地址,然後再根據這個地址找到堆中的對象
  • 比較:引用的比較

3.賦值、淺拷貝和深拷貝的區別

3.1 賦值

賦值是將某一數值或對象賦給某個變數的過程,包括兩種:

  • 基本數據類型: 就是簡單的賦值。因為在記憶體中開闢了一塊新的棧空間,所以賦值之後兩個變數獨立、互不影響
  • 引用數據類型: 賦址。實際操作的是對象記憶體的地址,最後使兩個變數具有相同的引用,都指向同一個對象,因此這兩個變數相互之間有影響

3.2 淺拷貝

定義:

淺拷貝即 swallow copy,它會創建一個新對象,這個對象有著原始對象屬性值的一份精確拷貝。如果屬性是基本類型,拷貝的就是基本類型的值;如果屬性是引用類型,拷貝的就是其記憶體地址,所以如果其中一個對象改變了這個地址,就會影響到另一個對象。 簡而言之,淺拷貝的「淺」在於它的拷貝只停留在一層,即:拷貝第一層的基本類型值,以及第一層的引用類型地址。如圖: 拷貝第一層的基本類型值,以及第一層的引用類型地址。

哪些地方是淺拷貝?

  • Object.assign()

Object.assign() 方法用於將所有可枚舉屬性的值從一個或多個源對象複製到目標對象,最後再將目標對象返回。

let a = {      author: "Jack",      article: {          title: "Understanding event loop",          date: "2019-1-1"      }  }  let b = Object.assign({}, a);  console.log(b);  /* {      author: "Jack",      article: {          title: "Understanding event loop",          date: "2019-1-1"      }  } */  a.author = "Bob";  a.article.date = "2019.2.1";  console.log(a);  /* {      author: "Bob",      article: {          title: "Understanding event loop",          date: "2019-2-1"      }  } */    console.log(b);  /* {      author: "Jack",      article: {          title: "Understanding event loop",          date: "2019-2-1"      }  } */

可以看到,因為 a 和 b 有各自的基本類型屬性,所以對這種屬性的修改是獨立的;但是由於 a 和 b 的引用類型屬性指向記憶體中的同一個對象,所以 a 對該對象的修改會反映到 b 上,這是淺拷貝的特點。

  • 展開語法...

展開語法(Spread syntax), 可以在函數調用/數組構造時, 將數組表達式或者字元串在語法層面展開;還可以在構造字面量對象時, 將對象表達式按 key-value 的方式展開。

let a = {      author: "Jack",      article: {          title: "Understanding event loop",          date: "2019-1-1"      }  }  let b = {...a};  console.log(b);  /* {      author: "Jack",      article: {          title: "Understanding event loop",          date: "2019-1-1"      }  } */  a.author = "Bob";  a.article.date = "2019.2.1";  console.log(a);  /* {      author: "Bob",      article: {          title: "Understanding event loop",          date: "2019-2-1"      }  } */    console.log(b);  /* {      author: "Jack",      article: {          title: "Understanding event loop",          date: "2019-2-1"      }  } */
  • Array.prototype.slice()slice() 方法返回一個新的數組對象,這一對象是一個由 begin 和 end(不包括 end )決定的原數組的淺拷貝。原始數組不會被改變。
let a = [0, "1", [2, 3]];  let b = a.slice(1);  console.log(b);  // ["1", [2, 3]]    a[1] = "99";  a[2][0] = 4;  console.log(a);  // [0, "99", [4, 3]]    console.log(b);  //  ["1", [4, 3]]

可以看到,因為 a 和 b 都有各自的基本類型屬性,所以修改 a[1] 對 b 沒有影響;但是由於 a 和 b 的引用類型屬性指向記憶體中的同一個數組對象,所以對 a[2][0] 的修改會反映到 b 上。

3.3 深拷貝

定義:

深拷貝即 deep copy,它會拷貝所有的屬性,並拷貝屬性指向的動態分配的記憶體。當對象和它所引用的對象一起拷貝時即發生深拷貝。深拷貝相比於淺拷貝速度較慢並且花銷較大。拷貝前後兩個對象互不影響。 簡而言之,深拷貝的「深」在於它的拷貝不僅僅停留在一層,而是連同裡面的子對象也一同拷貝。如圖:

哪些地方是深拷貝?

  • JSON.parse(JSON.stringify(object))

對象(包括數組)的序列化和反序列化

let a = {      author: "Jack",      article: {          title: "Understanding event loop",          date: "2019-1-1"      }  }  let b = JSON.parse(JSON.stringfy(a));  console.log(b);  /* {      author: "Jack",      article: {          title: "Understanding event loop",          date: "2019-1-1"      }  } */  a.author = "Bob";  a.article.date = "2019.2.1";  console.log(a);  /* {      author: "Bob",      article: {          title: "Understanding event loop",          date: "2019-2-1"      }  } */    console.log(b);  /* {      author: "Jack",      article: {          title: "Understanding event loop",          date: "2019-1-1"      }  } */

改變 a.article.date 之後對 b 沒有影響,可見這是深拷貝,a 和 b 有各自的 article 對象。

不過,JSON.parse(JSON.stringify(object)) 有以下幾個問題:

1、會忽略 undefined

2、會忽略 symbol

3、會忽略函數(不能序列化函數)

4、不能解決循環引用的對象

5、不能正確處理 new Date()

6、不能處理正則

3.4 總結

--

和原數據是否指向同一對象

第一層數據為基本數據類型

原數據中包含子對象

賦值

是

改變會使原數據一同改變

改變會使原數據一同改變

淺拷貝

否

改變不會使原數據一同改變

改變會使原數據一同改變

深拷貝

否

改變不會使原數據一同改變

改變不會使原數據一同改變

4.模擬實現 Object.assign() 的淺拷貝

4.1 Object.assign() 淺拷貝的特點:

  • 如果目標對象中的屬性具有相同的鍵,則屬性將被源對象中的屬性覆蓋。後來的源對象的屬性將類似地覆蓋早先的屬性;
  • string、symbol、null 和 undefined 類型的屬性都會被拷貝

4.2 實現 Object.assign() 的基本思路:

1、判斷原生 Object 是否支援該函數,如果不存在的話創建一個 assign 函數,並使用 Object.defineProperty 將該函數綁定到 Object 上。

2、判斷參數是否正確(目標對象不能為空,我們可以直接設置 {} 傳遞進去,但必須設置值)。

3、使用 Object() 轉成對象,並保存為 to,最後返回這個對象 to。

4、使用 for..in 循環遍歷出所有可枚舉屬性,配合 hasOwnProperty 獲取所有可枚舉自有(非原型鏈上的)屬性,再複製給新的目標對象。

4.3 具體實現程式碼:

下面是 MDN 上 assign() 的 polyfill(注意:此 polyfill 不支援 symbol 屬性,因為 ES5 中根本沒有 symbol):

if (typeof Object.assign != 'function') {    // Must be writable: true, enumerable: false, configurable: true    Object.defineProperty(Object, "assign", {      value: function assign(target, varArgs) { // .length of function is 2        'use strict';        if (target == null) { // TypeError if undefined or null          throw new TypeError('Cannot convert undefined or null to object');        }          let to = Object(target);          for (var index = 1; index < arguments.length; index++) {          var nextSource = arguments[index];            if (nextSource != null) { // Skip over if undefined or null            for (let nextKey in nextSource) {              // Avoid bugs when hasOwnProperty is shadowed              if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {                to[nextKey] = nextSource[nextKey];              }            }          }        }        return to;      },      writable: true,      configurable: true    });  }

4.4 重點注意

經過測試,該模擬實現的 assign() 可以達到和原生 Object 的 assign() 一樣的淺拷貝效果。下面說幾個重點需要注意的地方:

  • 為什麼使用 Object。defineProperty() 添加方法,而不是直接掛載?

我們知道,for...in 可以遍歷出自身以及原型鏈上的可枚舉屬性,而 Object.keys() 只能遍歷出自身的可枚舉屬性

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

可見,Object 的屬性默認都是不可枚舉的,但是,直接掛載在 Object 上面的屬性卻是可枚舉的:

Object.a = 1;  for(var i in Object){      console.log(Object[i]);  }  // 1    Object.keys( Object );  // ["a"]

所以,只有 assign() 確實是可枚舉屬性時,才可以將其直接掛載在 assign() 上。但是, assign() 實際上是不可枚舉的。 我們可以使用 2 種方法查看 assign() 是否可枚舉:

  • Object.getOwnPropertyDescriptor()
  • Object.propertyIsEnumerable() 其中,後者會檢查給定的屬性名是否直接存在於對象中(而不是在原型鏈上)並且滿足 enumerable: true。

具體用法如下:

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

上面程式碼說明 Object.assign() 是不可枚舉的。因此,這裡適合用Object.defineProperty() 給 Object 添加屬性,用這個方法時也可以不顯式指定 enumerable 為 false,因為它默認就是 false。

  • 參數判斷:
if (target === undefined || target === null) {      throw new TypeError('Cannot convert undefined or null to object');  }

上面這種參數判斷也可以,但是由於 null 和 undefined 的相等性判斷返回 true,所以實際上直接寫 if(target == null) 即可

  • 為什麼要用 Obejct() 將 target 參數包裝成對象?

正常情況下傳入的參數應該是一個對象,但是模擬實現的時候需要考慮傳入參數不一定為對象的情況。

1.如果作為源對象的參數不是對象:

null 和 undefined 會被忽略;除了這兩者之外的原始類型則將被包裝成對象,在這種情況下,只有自身擁有可枚舉屬性的包裝對象才會被拷貝,其它類型的包裝對象會被忽略

var v1 = "abc";  var v2 = true;  var v3 = 10;  var v4 = Symbol("foo");    Object.keys( v1 ); // [ '0', '1', '2' ]  Object.keys( v2 ); // []  Object.keys( v3 ); // []  Object.keys( v4 ); // []  // 可以看到,只有字元串類型的包裝對象,才有自身的可枚舉屬性
var obj = Object.assign({}, null, undefined, v1, v2, v3, v4);  // 只有 v1 會被拷貝  console.log(obj);  // { "0": "a", "1": "b", "2": "c" }

2.如果作為目標對象的參數不是對象:

同理,我們還要考慮 target 參數也可能不是對象的情況,所以在上面的 polyfill 中,要使用 Object() 將 target 參數包裝成對象。

  • 為什麼要用嚴格模式? 使用 Object() 對參數進行包裝後,對於得到的包裝對象而言,其既有屬性的 writable 為 false,也就是說不能對其既有屬性進行改寫,否則會報錯
var str1 = "abc";  var str2 = "def";  Object.assign(str1, str2); 

assign() 的內部實現中將 str1 和 str2 包裝為對象,因此這兩者在內部是這樣的:

// str1  {      0:"a",      1:"b",      2:"c"  }  // str2  {      0:"d",      1:"e",      2:"f"  }

Object.assign(str1, str2) 相當於用 str2 的同名屬性依次覆蓋 str1 的屬性,從而實現拷貝。但是由於 str1 是用 Object() 進行包裝的,所以這個拷貝是不生效的,會報錯。 但是,如果在 assign() 的實現內部不使用嚴格模式,則不會報錯:

var myObject = Object('abc');    Object.getOwnPropertyDescriptor(myObject, '0');  // {  //   value: 'a',  //   writable: false, // 注意這裡  //   enumerable: true,  //   configurable: false  // }    myObject[0] = 'd';  // 'd'    myObject[0];  // 'a'

這裡並沒有報錯,原因在於 js 對於不可寫的屬性值的修改靜默失敗,只有在嚴格模式下才會提示錯誤。為了實現正常的報錯,我們必須在內部使用嚴格模式。

  • 為什麼要用 Object.prototype.hasOwnProperty.call()?

通過 for...in.. 得到的是源對象自身及其原型鏈上的可枚舉屬性,但淺拷貝只需要拷貝自身可枚舉屬性,所以需要用 hasOwnProperty() 篩選,但是直接調用這個方法是不行的。一方面,我們需要考慮到源對象可能重寫了這個方法而導致其無法正常調用,另一方面還要考慮到源對象可能是基於 Object.create(null) 創建的,而這種方法創建的對象由於不具有與 Object 原型鏈的聯繫,因此不具有 hasOwnProperty() 方法,在調用的時候會報錯。

var myObject = Object.create( null );  myObject.b = 2;    myObject.hasOwnProperty( "b" );  // TypeError: myObject.hasOwnProperty is not a function

所以,這裡採用 Object.prototype.hasOwnProperty.call(),將 hasOwnProperty() 內部的 this 綁定到源對象上,也可以達到同樣的效果。

var myObject = Object.create( null );  myObject.b = 2;    Object.prototype.hasOwnProperty.call(myObject, "b");  // true

參考: https://github.com/yygmind/blog/issues/25 https://github.com/yygmind/blog/issues/26 https://juejin.im/post/59ac1c4ef265da248e75892b#heading-13

Tags: