ES6 變量聲明與賦值:值傳遞、淺拷貝與深拷貝詳解
ES6 為我們引入了 let 與 const 兩種新的變量聲明關(guān)鍵字,同時也引入了塊作用域;本文首先介紹 ES6 中常用的三種變量聲明方式,然后討論了 JavaScript 按值傳遞的特性以及多種的賦值方式,最后介紹了復合類型拷貝的技巧。
變量聲明
在 ES6 中,對于變量聲明的方式進行了擴展,引入了 let 與 const。var 與 let 兩個關(guān)鍵字創(chuàng)建變量的區(qū)別在于, var 聲明的變量作用域是最近的函數(shù)塊;而 let 聲明的變量作用域是最近的閉合塊,往往會小于函數(shù)塊。
另一方面,以 let 關(guān)鍵字創(chuàng)建的變量雖然同樣被提升到作用域頭部,但是并不能在實際聲明前使用;如果強行使用則會拋出 ReferenceError 異常。
function sayHello(){
var hello = "Hello World";
return hello;
}
console.log(hello);
像如上這種調(diào)用方式會拋出異常: ReferenceError: hello is not defined,因為 hello 變量只能作用于 sayHello 函數(shù)中
let
let 關(guān)鍵字聲明的變量是屬于塊作用域,也就是包含在 {} 之內(nèi)的作用于。使用 let 關(guān)鍵字的優(yōu)勢在于能夠降低偶然的錯誤的概率,因為其保證了每個變量只能在最小的作用域內(nèi)進行訪問。
var name = "Peter";
if(name === "Peter"){
let hello = "Hello Peter";
} else {
let hello = "Hi";
}
console.log(hello);
上述代碼同樣會拋出 ReferenceError: hello is not defined 異常,因為 hello 只能夠在閉合的塊作用域中進行訪問,我們可以進行如下修改:
var name = "Peter";
if(name === "Peter"){
let hello = "Hello Peter";
console.log(hello);
} else {
let hello = "Hi";
console.log(hello);
}
我們可以利用這種塊級作用域的特性來避免閉包中因為變量保留而導致的問題,譬如如下兩種異步代碼,使用 var 時每次循環(huán)中使用的都是相同變量;而使用 let 聲明的 i 則會在每次循環(huán)時進行不同的綁定,即每次循環(huán)中閉包捕獲的都是不同的 i 實例:
for(let i = 0;i < 2; i++) {
setTimeout(()=>{console.log(`i:${i}`)},0);
}
for(var j = 0;j < 2; j++) {
setTimeout(()=>{console.log(`j:${j}`)},0);
}
let k = 0;
for(k = 0;k < 2; k++) {
setTimeout(()=>{console.log(`k:${k}`)},0);
}
// output
i:0
i:1
j:2
j:2
k:2
k:2
const
JavaScript 中 const 關(guān)鍵字的表現(xiàn)于 C 中存在著一定差異,譬如下述使用方式在 JavaScript 中就是正確的,而在 C 中則拋出異常:
// JavaScript
const numbers = [1, 2, 3, 4, 6]
numbers[4] = 5
console.log(numbers[4]) // print 5
// C
const int numbers[] = {1, 2, 3, 4, 6};
numbers[4] = 5; // error: read-only variable is not assignable
printf("%d\n", numbers[4]);
從上述對比我們也可以看出,JavaScript 中 const 限制的并非值不可變性;而是創(chuàng)建了不可變的綁定,即對于某個值的只讀引用,并且禁止了對于該引用的重賦值,即如下的代碼會觸發(fā)錯誤:
const numbers = [1, 2, 3, 4, 6]
numbers = [7, 8, 9, 10, 11] // error: assignment to constant variable
console.log(numbers[4])
JavaScript 中存在著所謂的原始類型與復合類型,使用 const 聲明的原始類型是值不可變的:
// Example 1
const a = 10
a = a + 1 // error: assignment to constant variable
// Example 2
const isTrue = true
isTrue = false // error: assignment to constant variable
// Example 3
const sLower = 'hello world'
const sUpper = sLower.toUpperCase() // create a new string
console.log(sLower) // print hello world
console.log(sUpper) // print HELLO WORLD
而如果我們希望將某個對象同樣變成不可變類型,則需要使用 Object.freeze();不過該方法僅對于鍵值對的 Object 起作用,而無法作用于 Date、Map 與 Set 等類型:
// Example 4
const me = Object.freeze({name: “Jacopo”})
me.age = 28
console.log(me.age) // print undefined
// Example 5
const arr = Object.freeze([-1, 1, 2, 3])
arr[0] = 0
console.log(arr[0]) // print -1
// Example 6
const me = Object.freeze({
name: 'Jacopo',
pet: {
type: 'dog',
name: 'Spock'
}
})
me.pet.name = 'Rocky'
me.pet.breed = 'German Shepherd'
console.log(me.pet.name) // print Rocky
console.log(me.pet.breed) // print German Shepherd
即使是 Object.freeze() 也只能防止頂層屬性被修改,而無法限制對于嵌套屬性的修改,這一點我們會在下文的淺拷貝與深拷貝部分繼續(xù)討論。
變量賦值
按值傳遞
JavaScript 中永遠是按值傳遞(pass-by-value),只不過當我們傳遞的是某個對象的引用時,這里的值指的是對象的引用。按值傳遞中函數(shù)的形參是被調(diào)用時所傳實參的副本。修改形參的值并不會影響實參。而按引用傳遞(pass-by-reference)時,函數(shù)的形參接收實參的隱式引用,而不再是副本。這意味著函數(shù)形參的值如果被修改,實參也會被修改。同時兩者指向相同的值。
function changeStuff(a, b, c)
{
a = a * 10;
b.item = "changed";
c = {item: "changed"};
}
var num = 10;
var obj1 = {item: "unchanged"};
var obj2 = {item: "unchanged"};
changeStuff(num, obj1, obj2);
console.log(num);
console.log(obj1.item);
console.log(obj2.item);
// 輸出結(jié)果
10
changed
unchanged
JavaScript 按值傳遞就表現(xiàn)于在內(nèi)部修改了 c 的值但是并不會影響到外部的 obj2 變量。如果我們更深入地來理解這個問題,JavaScript 對于對象的傳遞則是按共享傳遞的(pass-by-sharing,也叫按對象傳遞、按對象共享傳遞)。
解構(gòu)賦值
解構(gòu)賦值允許你使用類似數(shù)組或?qū)ο笞置媪康恼Z法將數(shù)組和對象的屬性賦給各種變量。
數(shù)組與迭代器
var [ variable1, variable2, ..., variableN ] = array;
let [ variable1, variable2, ..., variableN ] = array;
const [ variable1, variable2, ..., variableN ] = array;
var [foo, [[bar], baz]] = [1, [[2], 3]];
console.log(foo);
// 1
console.log(bar);
// 2
console.log(baz);
// 3
var [,,third] = ["foo", "bar", "baz"];
console.log(third);
// "baz"
var [head, ...tail] = [1, 2, 3, 4];
console.log(tail);
// [2, 3, 4]
console.log([][0]);
// undefined
var [missing] = [];
console.log(missing);
// undefined
對象
通過解構(gòu)對象,你可以把它的每個屬性與不同的變量綁定,首先指定被綁定的屬性,然后緊跟一個要解構(gòu)的變量。
var robotA = { name: "Bender" };
var robotB = { name: "Flexo" };
var { name: nameA } = robotA;
var { name: nameB } = robotB;
console.log(nameA);
// "Bender"
console.log(nameB);
// "Flexo"
當屬性名與變量名一致時,可以通過一種實用的句法簡寫:
var { foo, bar } = { foo: "lorem", bar: "ipsum" };
console.log(foo);
// "lorem"
console.log(bar);
// "ipsum"
與數(shù)組解構(gòu)一樣,你可以隨意嵌套并進一步組合對象解構(gòu):
var complicatedObj = {
arrayProp: [
"Zapp",
{ second: "Brannigan" }
]
};
var { arrayProp: [first, { second }] } = complicatedObj;
console.log(first);
// "Zapp"
console.log(second);
// "Brannigan"
當你解構(gòu)一個未定義的屬性時,得到的值為undefined:
var { missing } = {};
console.log(missing);
// undefined
請注意,當你解構(gòu)對象并賦值給變量時,如果你已經(jīng)聲明或不打算聲明這些變量(亦即賦值語句前沒有l(wèi)et、const或var關(guān)鍵字),你應(yīng)該注意這樣一個潛在的語法錯誤:
{ blowUp } = { blowUp: 10 };
// Syntax error 語法錯誤
為什么會出錯?這是因為JavaScript語法通知解析引擎將任何以{開始的語句解析為一個塊語句(例如,{console}是一個合法塊語句)。解決方案是將整個表達式用一對小括號包裹:
({ safe } = {});
// No errors 沒有語法錯誤
默認值
當你要解構(gòu)的屬性未定義時你可以提供一個默認值:
var [missing = true] = [];
console.log(missing);
// true
var { message: msg = "Something went wrong" } = {};
console.log(msg);
// "Something went wrong"
var { x = 3 } = {};
console.log(x);
// 3
由于解構(gòu)中允許對對象進行解構(gòu),并且還支持默認值,那么完全可以將解構(gòu)應(yīng)用在函數(shù)參數(shù)以及參數(shù)的默認值中。
function removeBreakpoint({ url, line, column }) {
// ...
}
Three Dots
Rest Operator
在 JavaScript 函數(shù)調(diào)用時我們往往會使用內(nèi)置的 arguments 對象來獲取函數(shù)的調(diào)用參數(shù),不過這種方式卻存在著很多的不方便性。譬如 arguments 對象是 Array-Like 對象,無法直接運用數(shù)組的 .map() 或者 .forEach() 函數(shù);并且因為 arguments 是綁定于當前函數(shù)作用域,如果我們希望在嵌套函數(shù)里使用外層函數(shù)的 arguments 對象,我們還需要創(chuàng)建中間變量。
function outerFunction() {
// store arguments into a separated variable
var argsOuter = arguments;
function innerFunction() {
// args is an array-like object
var even = Array.prototype.map.call(argsOuter, function(item) {
// do something with argsOuter
});
}
}
ES6 中為我們提供了 Rest Operator 來以數(shù)組形式獲取函數(shù)的調(diào)用參數(shù),Rest Operator 也可以用于在解構(gòu)賦值中以數(shù)組方式獲取剩余的變量:
function countArguments(...args) {
return args.length;
}
// get the number of arguments
countArguments('welcome', 'to', 'Earth'); // => 3
// destructure an array
let otherSeasons, autumn;
[autumn, ...otherSeasons] = cold;
otherSeasons // => ['winter']
典型的 Rest Operator 的應(yīng)用場景譬如進行不定數(shù)組的指定類型過濾:
function filter(type, ...items) {
return items.filter(item => typeof item === type);
}
filter('boolean', true, 0, false); // => [true, false]
filter('number', false, 4, 'Welcome', 7); // => [4, 7]
盡管 Arrow Function 中并沒有定義 arguments 對象,但是我們?nèi)匀豢梢允褂?Rest Operator 來獲取 Arrow Function 的調(diào)用參數(shù):
(function() {
let outerArguments = arguments;
const concat = (...items) => {
console.log(arguments === outerArguments); // => true
return items.reduce((result, item) => result + item, '');
};
concat(1, 5, 'nine'); // => '15nine'
})();
Spread Operator
Spread Operator 則與 Rest Opeator 的功能正好相反,其常用于進行數(shù)組構(gòu)建與解構(gòu)賦值,也可以用于將某個數(shù)組轉(zhuǎn)化為函數(shù)的參數(shù)列表,其基本使用方式如下:
let cold = ['autumn', 'winter'];
let warm = ['spring', 'summer'];
// construct an array
[...cold, ...warm] // => ['autumn', 'winter', 'spring', 'summer']
// function arguments from an array
cold.push(...warm);
cold // => ['autumn', 'winter', 'spring', 'summer']
我們也可以使用 Spread Operator 來簡化函數(shù)調(diào)用:
class King {
constructor(name, country) {
this.name = name;
this.country = country;
}
getDescription() {
return `${this.name} leads ${this.country}`;
}
}
var details = ['Alexander the Great', 'Greece'];
var Alexander = new King(...details);
Alexander.getDescription(); // => 'Alexander the Great leads Greece'
還有另外一個好處就是可以用來替換 Object.assign 來方便地從舊有的對象中創(chuàng)建新的對象,并且能夠修改部分值;譬如:
var obj = {a:1,b:2}
var obj_new_1 = Object.assign({},obj,{a:3});
var obj_new_2 = {
...obj,
a:3
}
復合類型的拷貝
淺拷貝
頂層屬性遍歷
淺拷貝是指復制對象的時候,指對第一層鍵值對進行獨立的復制。一個簡單的實現(xiàn)如下:
// 淺拷貝實現(xiàn)
function shadowCopy(target, source){
if( !source || typeof source !== 'object'){
return;
}
// 這個方法有點小trick,target一定得事先定義好,不然就不能改變實參了。
// 具體原因解釋可以看參考資料中 JS是值傳遞還是引用傳遞
if( !target || typeof target !== 'object'){
return;
}
// 這邊最好區(qū)別一下對象和數(shù)組的復制
for(var key in source){
if(source.hasOwnProperty(key)){
target[key] = source[key];
}
}
}
//測試例子
var arr = [1,2,3];
var arr2 = [];
shadowCopy(arr2, arr);
console.log(arr2);
//[1,2,3]
var today = {
weather: 'Sunny',
date: {
week: 'Wed'
}
}
var tomorrow = {};
shadowCopy(tomorrow, today);
console.log(tomorrow);
// Object {weather: "Sunny", date: Object}
Object.assign
注意,在屬性拷貝過程中可能會產(chǎn)生異常,比如目標對象的某個只讀屬性和源對象的某個屬性同名,這時該方法會拋出一個 TypeError 異常,拷貝過程中斷,已經(jīng)拷貝成功的屬性不會受到影響,還未拷貝的屬性將不會再被拷貝。
注意, Object.assign 會跳過那些值為 null 或 undefined 的源對象。
Object.assign(target, ...sources)
例子:淺拷貝一個對象
var obj = { a: 1 };
var copy = Object.assign({}, obj);
console.log(copy); // { a: 1 }
例子:合并若干個對象
var o1 = { a: 1 };
var o2 = { b: 2 };
var o3 = { c: 3 };
var obj = Object.assign(o1, o2, o3);
console.log(obj); // { a: 1, b: 2, c: 3 }
console.log(o1); // { a: 1, b: 2, c: 3 }, 注意目標對象自身也會改變。
例子:拷貝 symbol 類型的屬性
var o1 = { a: 1 };
var o2 = { [Symbol("foo")]: 2 };
var obj = Object.assign({}, o1, o2);
console.log(obj); // { a: 1, [Symbol("foo")]: 2 }
例子:繼承屬性和不可枚舉屬性是不能拷貝的
var obj = Object.create({foo: 1}, { // foo 是個繼承屬性。
bar: {
value: 2 // bar 是個不可枚舉屬性。
},
baz: {
value: 3,
enumerable: true // baz 是個自身可枚舉屬性。
}
});
var copy = Object.assign({}, obj);
console.log(copy); // { baz: 3 }
不過需要注意的是,assign是淺拷貝,或者說,它是一級深拷貝,舉兩個例子說明:
const defaultOpt = {
title: {
text: 'hello world',
subtext: 'It\'s my world.'
}
};
const opt = Object.assign({}, defaultOpt, {
title: {
subtext: 'Yes, your world.'
}
});
console.log(opt);
// 預期結(jié)果
{
title: {
text: 'hello world',
subtext: 'Yes, your world.'
}
}
// 實際結(jié)果
{
title: {
subtext: 'Yes, your world.'
}
}
上面這個例子中,對于對象的一級子元素而言,只會替換引用,而不會動態(tài)的添加內(nèi)容。那么,其實assign并沒有解決對象的引用混亂問題,參考下下面這個例子:
const defaultOpt = {
title: {
text: 'hello world',
subtext: 'It\'s my world.'
}
};
const opt1 = Object.assign({}, defaultOpt);
const opt2 = Object.assign({}, defaultOpt);
opt2.title.subtext = 'Yes, your world.';
console.log('opt1:');
console.log(opt1);
console.log('opt2:');
console.log(opt2);
// 結(jié)果
opt1:
{
title: {
text: 'hello world',
subtext: 'Yes, your world.'
}
}
opt2:
{
title: {
text: 'hello world',
subtext: 'Yes, your world.'
}
}
深拷貝
遞歸屬性遍歷
一般來說,在JavaScript中考慮復合類型的深層復制的時候,往往就是指對于Date、Object與Array這三個復合類型的處理。我們能想到的最常用的方法就是先創(chuàng)建一個空的新對象,然后遞歸遍歷舊對象,直到發(fā)現(xiàn)基礎(chǔ)類型的子節(jié)點才賦予到新對象對應(yīng)的位置。不過這種方法會存在一個問題,就是JavaScript中存在著神奇的原型機制,并且這個原型會在遍歷的時候出現(xiàn),然后原型不應(yīng)該被賦予給新對象。那么在遍歷的過程中,我們應(yīng)該考慮使用hasOenProperty方法來過濾掉那些繼承自原型鏈上的屬性:
function clone(obj) {
var copy;
// Handle the 3 simple types, and null or undefined
if (null == obj || "object" != typeof obj) return obj;
// Handle Date
if (obj instanceof Date) {
copy = new Date();
copy.setTime(obj.getTime());
return copy;
}
// Handle Array
if (obj instanceof Array) {
copy = [];
for (var i = 0, len = obj.length; i < len; i++) {
copy[i] = clone(obj[i]);
}
return copy;
}
// Handle Object
if (obj instanceof Object) {
copy = {};
for (var attr in obj) {
if (obj.hasOwnProperty(attr)) copy[attr] = clone(obj[attr]);
}
return copy;
}
throw new Error("Unable to copy obj! Its type isn't supported.");
}
調(diào)用如下:
// This would be cloneable:
var tree = {
"left" : { "left" : null, "right" : null, "data" : 3 },
"right" : null,
"data" : 8
};
// This would kind-of work, but you would get 2 copies of the
// inner node instead of 2 references to the same copy
var directedAcylicGraph = {
"left" : { "left" : null, "right" : null, "data" : 3 },
"data" : 8
};
directedAcyclicGraph["right"] = directedAcyclicGraph["left"];
// Cloning this would cause a stack overflow due to infinite recursion:
var cylicGraph = {
"left" : { "left" : null, "right" : null, "data" : 3 },
"data" : 8
};
cylicGraph["right"] = cylicGraph;
利用 JSON 深拷貝
JSON.parse(JSON.stringify(obj));
對于一般的需求是可以滿足的,但是它有缺點。下例中,可以看到JSON復制會忽略掉值為undefined以及函數(shù)表達式。
var obj = {
a: 1,
b: 2,
c: undefined,
sum: function() { return a + b; }
};
var obj2 = JSON.parse(JSON.stringify(obj));
console.log(obj2);
//Object {a: 1, b: 2}