近期接觸 API 後,開始有帶入 response data 到程式碼的需求。這時如果將 API 回傳的資料直接帶入到空陣列,會造成陣列只有一個子陣列元素,和我們原先預期總共 80 個物件的結果形成落差。
由於回傳的是一個陣列,我們當然可以運用 for
迴圈,或是陣列專屬的 for...of
、forEach()
等方法。但其實 ES6 提供了一種更為簡潔的語法稱作展開運算子,可以把陣列中的元素展開來供我們使用。
這篇文章將記錄展開運算子的使用案例,以及展開運算子與淺拷貝 (shallow copy) 的地雷。
以下是 MDN 當中對於展開運算子的解釋:
The spread (...
) syntax allows an iterable, such as an array or string, to be expanded in places where zero or more arguments (for function calls) or elements (for array literals) are expected. In an object literal, the spread syntax enumerates the properties of an object and adds the key-value pairs to the object being created.
簡單來說,展開運算子由三個連續的半形句點(...)組成,將可迭代(iterable)的 JavaScript 物件(例如陣列、物件、字串等)展開為獨立的元素。
JavaScript Math 函式庫提供了眾多實用的運算方法,這些方法通常只接受數字而已。假設今天我們希望找出陣列當中最大的數字,就可以使用 Math.max()
。但由於 Math.max() 僅接受數字,所以我們帶入陣列 nums 會得到 NaN。
const nums = [1, 2, 3, 5,];
console.log(nums); // [1, 2, 3, 5,]
console.log(Math.max(nums)); //NaN
這時候展開運算子就能拜上用場!運作起來等於 Math.max(1, 2, 3, 5)
。展開運算子會將陣列展開,讓陣列中的元素變成 argument list。
const nums = [1, 2, 3, 5];
console.log(Math.max(nums)); //NaN
console.log(...nums); // 1, 2, 3, 5
console.log(Math.max(...nums)); // 5
此外,由於字串也是可迭代的,所以我們同樣可以用展開運算子,將字串拆解成字元。
console.log(...'Bonjour'); // B o n j o u r
以前若要合併兩個陣列,直覺上都會根據情境使用以下兩種方法:
array.concat()
:將 b 陣列合併至 a 陣列,會回傳一個新陣列array.unshift
:將 b 陣列插入至 a 陣列的尾端 (灬ºωº灬),直接改變 a 陣列現在學會展開運算式之後,就能用更簡潔、易懂的方式達到合併的效果囉。
const cats = ['三花', '橘貓', '玳瑁', '金吉拉'];
const dogs = ['博美', '吉娃娃', '法鬥', '柯基'];
const kawaii = [...cats, ...dogs];
console.log(kawaii); // ['三花', '橘貓', '玳瑁', '金吉拉', '博美', '吉娃娃', '法鬥', '柯基']
這邊要注意的是,展開運算式不會改變到原先的陣列。
當然,我們也可以透過展開運算子來複製陣列,但這會牽扯到淺拷貝的問題,所以留到下面用比較長的篇幅說明。
物件同樣可以運用和陣列的方式,透過展開運算子進行合併,但要注意,若兩個物件當中有重複的 key,JavaScript 將以後面的物件為主。
除此之外,由於物件在 JavaScript 是 by reference (其實更具體來說是 by sharing),因此若我們直接用賦值的方式進行物件複製,兩個變數其實背後都是儲存該物件的記憶體地址,所以任一個變數更改物件,都會影響到另一方。
這樣的背後指向的值相同,兩個物件操作會互相影響,即為淺拷貝(shallow copy)。
const user = {
name: 'Mimiball',
age: 2,
email: 'mimiball@gmail.com'
}
const user2 = user;
user2.age = 7;
console.log(`The age of user: ${user.age}`); //7
console.log(`The age of user2: ${user2.age}`); //7
如果希望讓兩個變數互不影響,可以透過展開運算子。在下面的範例中,我們先利用展開運算子來展開 user 物件 (...user
),然後將 user 物件內的屬性重新包覆在一個新的物件當中 ({...user}
):
const user = {
name: 'Mimiball',
age: 2,
email: 'mimiball@gmail.com'
}
const user2 = {...user};
user2.age = 7;
console.log(`The age of user: ${user.age}`); //2
console.log(`The age of user2: ${user2.age}`); //7
太好了,看來展開運算子可以實現物件的深拷貝(Deep Copy),讓兩個物件拷貝好不會相互影響,但如果我們的物件當中還有另一個子物件呢?
const user = {
name: 'Mimiball',
age: 2,
email: 'mimiball@gmail.com',
role: {
isAdmin: true,
position: 'CEO'
}
}
const user2 = {...user};
user2.role.isAdmin = false;
console.log(user.role.isAdmin); //false
console.log(user2.role.isAdmin); //false
可惡,看起來展開運算子只會讓物件第一層不相互影響,但如果中間包含子層物件,仍舊為淺拷貝。事實上的確如此,第一層的 name、age、email 都是 primitive data type,所以展開之後即為基本的資料類型。相較之下,子層物件展開之後,role 還是個物件,而 role 實際上儲存的值是這個子層物件的記憶體位址。
照這樣的邏輯,如果手動展開兩次的話,也可以對 role 進行深拷貝囉?
const user = {
name: 'Mimiball',
age: 2,
email: 'mimiball@gmail.com',
role: {
isAdmin: true,
position: 'CEO'
}
}
const user2 = {...user, role: {...user.role}};
user2.role.isAdmin = false;
console.log(user.role.isAdmin); //true
console.log(user2.role.isAdmin); //false
當然實務上不太會這樣做,若要進行深拷貝,可以參考底下資料的建議~
後來我在 MDN 關於淺拷貝的文件中,找到了以下這段話,總算解決了心中的疑惑 (很怕自己是胡亂測試、胡亂說服自己的):
For shallow copies, only the top-level properties are copied, not the values of nested objects.
此外,這份文件也寫道,倘若物件當中全部都是 primitive value,也就是只有第一層的話,該物件便同時滿足深拷貝以及淺拷貝的定義。所以討論沒有子層的物件拷貝,是沒有意義的。深拷貝通常會在改變子層屬性的情境下才討論。
The copy of an object whose properties all have primitive values fits the definition of both a deep copy and a shallow copy. It is somewhat useless to talk about the depth of such a copy, though, because it has no nested properties and we usually talk about deep copying in the context of mutating nested properties.
據說展開運算子的用法和地雷在往後的前端框架學習上很容易出現。這次剛好查了些資料也實際測試看看,記錄下來給未來的自己參考。前提是我之後沒有中途放棄網頁開發學習。