Try   HackMD

JavaScript 的「傳值」與「傳址」

竹白記事本,筆記紀錄。

tags: JavaScript 竹白記事本

這部分在 JavaScript 是相當重要的觀念,正確了解這個觀念能避免發生不必要的錯誤。

▶ 概述

在 Javascript 中,值的傳遞分為兩種:

  • 傳值,Call by value 或是 Pass by value。
  • 傳址(傳參考),Call by reference 或是 Pass by reference。

在其他程式語言有語法可以決定要「傳值」還是「傳址」,但在 JavaScript 中沒有選擇,基本型別 就是「傳值」行為,物件型別 就是「傳址」行為。

▶ 傳值

當值的型別為 基本型別,那麼永遠都是藉由值的複製來賦值,所以稱作 傳值

來看程式碼:

var a = 10;
var b = 10;

a === b ;  // console.log: true
  • 由於 10 的型別為 基本型別
  • 當我們比較 ab 時,是互相比較彼此的值,所以回傳 true

繼續來看另一段程式碼:

var a = 2;
var b = a;
b += 1;

a;  // console.log: 2
b;  // console.log: 3
  • b 指定為 a 時,由於 a 的值是 基本型別
  • 所以會 b 得到的是 a 的值而不是 a 的記憶體位址;
  • 因此 b 就算改變了,a 也不會受到影響,兩者是獨立的。

▶ 傳址

接下來,我們來看 物件型別 的情況。

var obj1 = { a: 1 };
var obj2 = { a: 1 };

obj1 === obj2;  // console.log: false

你可以觀察到,就算 obj1obj2 的屬性名稱與值都相同,但互相比較的結果卻是 false

這是因為每個物件都是獨立存在的實體,兩者的記憶體位置並不相同。在比較 物件型別 時,比較的是記憶體位置,而非值。

繼續來看另一段程式碼:

var obj1 = { a: 1 };
var obj2 = obj1;

obj1.a = 0;
obj2.a;         // console.log: 0

obj2.b = 2;
obj1.b;         // console.log: 2

obj1 === obj2;  // console.log: true

obj1 = {};
obj1 === obj2;  // console.log: false
  • obj2 透過 obj2 = obj1 的方式賦值;
  • 當我們修改任意物件屬性時,另一邊的屬性也會更動;
  • 這是因為兩個變數指向相同的記憶體位置,並沒有新的物件被複製出來;
  • 但當我們將變數 ob1 賦值新的物件時,obj1 會指向新的記憶體位置;
  • obj2 依然保持原物件記憶體位置,這時 obj1obj2 彼此之間就沒有關係了,所以比較的結果為 false

物件型別 之間的傳遞是記憶體位置指向,所以稱作 傳址

▶ 函式參數

如果一個 物件型別 作為函式參數傳遞,一樣是傳址行為:

function foo(obj) {
  obj.a = 10;
}

var obj1 = {};
foo(obj1);

obj1; // console.log: { a: 10 }

直接更改屬性,會影響原物件。

如果我們將參數重新指向新物件,那麼不影響原物件:

function foo(obj) {
  obj = { a: 10 };
}

var obj1 = {};
foo(obj1);

obj1; // console.log: {}

▶ 複製物件

物件型別是傳參考,那我們該如何複製物件呢?

複製物件的方式分為兩類:

  • 淺拷貝(Shallow Copy)
  • 深拷貝(Deep Copy)

1.淺拷貝操作

常見淺拷貝物件的方式:

  • 使用 Object.assign() 將原本的 obj 內容複製到一個空物件中。
  • 在一個空物件內,使用 ... 展開運算子展開物件。
var obj = { foo: 1, bar: 2};

var obj2 = Object.assign({}, obj);
var obj3 = { ... obj };

obj === obj2;  // console.log: false
obj2;          // console.log: { foo: 1, bar: 2 }
obj === obj3;  // console.log: false
obj3;          // console.log: { foo: 1, bar: 2 }

常見淺拷貝陣列的方式:

  • 利用會回傳新陣列的方法,例如 slice()concat() map()等等。
  • 在一個空陣列內,使用 ... 展開運算子展開陣列。
var arr = [1, 2, 3, 4];

var arr2 = arr.slice(0);
var arr3 = [].concat(arr);
var arr4 = arr.map((item)=> item);
var arr5 = [...arr];

arr === arr2;  // console.log: false
arr2;          // console.log: [1, 2, 3, 4]
arr === arr3;  // console.log: false
arr3;          // console.log: [1, 2, 3, 4]
arr === arr4;  // console.log: false
arr4;          // console.log: [1, 2, 3, 4]
arr === arr5;  // console.log: false
arr5;          // console.log: [1, 2, 3, 4]

總結,淺拷貝使用 ... 展開運算子就對了,簡單易懂。

2. 什麼是淺拷貝

以上拷貝方式有一個問題,請考慮下面程式碼:

var obj1 = {
  a: 10,
  b: {
    foo: 20,
  },
};

var obj2 = { ...obj1 };

obj1 === obj2;      // console.log: false
obj1.b === obj2.b;  // console.log: true

假如物件內還有物件,就算我們使用 ... 展開運算子複製物件,第二層物件還是指向相同的記憶體位置。

因為我們的複製操作只改變外層容器的地址,但沒動到內層,所以這種複製操作稱為「淺拷貝」。也就是說,當我們的物件或陣列是巢狀或多維的,要多注意。

3. 深拷貝

深拷貝就是完全複製一份,不會有共用記憶體的問題。

常見深拷貝物件方法有:

  • 利用 JSON 方法:先轉 JSON 格式,再轉回來。
  • 使用第三方函式庫:

3.1 JSON

先用 JSON.parse() 轉再 JSON 格式,再用 JSON.stringify() 轉回 JS 物件:

var obj1 = {
  a: 10,
  b: {
    foo: 20,
  },
};

var obj2 = JSON.parse(JSON.stringify(obj1));

obj1 === obj2;      // console.log: false
obj1.b === obj2.b;  // console.log: false

但有幾點要注意,當物件轉 JSON 格式時,

  • 函式、undefinedSymbol 會被忽略;
  • NaNInfinity-Infinity 會被轉成 null

所以利用 JSON 方法深拷貝物件,只能用在單純只有資料的物件。

3.2 jQuery

jQuery 是常見的 JS 函式庫之一。

jQuery.extend(),是用來將兩個或更多物件的內容合併到第一個物件,我們可以用它來複製一個全新的物件。

var obj1 = {
  a: 10,
  b: {
    foo: 20,
  },
};

var obj2 = $.extend(true, {}, obj1);

obj1 === obj2;      // console.log: false
obj1.b === obj2.b;  // console.log: false

第一個參數預設為 fasle(可省略),如果是 false 就是淺拷貝,true 為深拷貝。

3.3 Lodash

Lodash 是熱門 JS 工具函式庫之一。

_.cloneDeep(value),能深拷貝一個物件:

var obj1 = {
  a: 10,
  b: {
    foo: 20,
  },
};

var obj2 = _.cloneDeep(obj1);

obj1 === obj2;      // console.log: false
obj1.b === obj2.b;  // console.log: false