ES6 變數宣告與賦值:值傳遞、淺拷貝與深拷貝詳解

王下邀月熊發表於2019-02-28

ES6 變數宣告與賦值:值傳遞、淺拷貝與深拷貝詳解歸納於筆者的現代 JavaScript 開發:語法基礎與實踐技巧系列文章。本文首先介紹 ES6 中常用的三種變數宣告方式,然後討論了 JavaScript 按值傳遞的特性,最後介紹了複合型別拷貝的技巧;有興趣的可以閱讀下一章節 ES6 變數作用域與提升:變數的生命週期詳解

變數宣告與賦值

ES6 為我們引入了 let 與 const 兩種新的變數宣告關鍵字,同時也引入了塊作用域;本文首先介紹 ES6 中常用的三種變數宣告方式,然後討論了 JavaScript 按值傳遞的特性以及多種的賦值方式,最後介紹了複合型別拷貝的技巧。

變數宣告

在 JavaScript 中,基本的變數宣告可以用 var 方式;JavaScript 允許省略 var,直接對未宣告的變數賦值。也就是說,var a = 1a = 1,這兩條語句的效果相同。但是由於這樣的做法很容易不知不覺地建立全域性變數(尤其是在函式內部),所以建議總是使用 var 命令宣告變數。在 ES6 中,對於變數宣告的方式進行了擴充套件,引入了 let 與 const。var 與 let 兩個關鍵字建立變數的區別在於, var 宣告的變數作用域是最近的函式塊;而 let 宣告的變數作用域是最近的閉合塊,往往會小於函式塊。另一方面,以 let 關鍵字建立的變數雖然同樣被提升到作用域頭部,但是並不能在實際宣告前使用;如果強行使用則會丟擲 ReferenceError 異常。

var

var 是 JavaScript 中基礎的變數宣告方式之一,其基本語法為:

var x; // Declaration and initialization
x = "Hello World"; // Assignment

// Or all in one
var y = "Hello World";複製程式碼

ECMAScript 6 以前我們在 JavaScript 中並沒有其他的變數宣告方式,以 var 宣告的變數作用於函式作用域中,如果沒有相應的閉合函式作用域,那麼該變數會被當做預設的全域性變數進行處理。

function sayHello(){
  var hello = "Hello World";
  return hello;
}
console.log(hello);複製程式碼

像如上這種呼叫方式會丟擲異常: ReferenceError: hello is not defined,因為 hello 變數只能作用於 sayHello 函式中,不過如果按照如下先宣告全域性變數方式再使用時,其就能夠正常呼叫:

var hello = "Hello World";
function sayHello(){
  return hello;
}
console.log(hello);複製程式碼

let

在 ECMAScript 6 中我們可以使用 let 關鍵字進行變數宣告:

let x; // Declaration and initialization
x = "Hello World"; // Assignment

// Or all in one
let y = "Hello World";複製程式碼

let 關鍵字宣告的變數是屬於塊作用域,也就是包含在 {} 之內的作用於。使用 let 關鍵字的優勢在於能夠降低偶然的錯誤的概率,因為其保證了每個變數只能在最小的作用域內進行訪問。

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 時每次迴圈中使用的都是相同變數;而使用 let 宣告的 i 則會在每次迴圈時進行不同的繫結,即每次迴圈中閉包捕獲的都是不同的 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

const 關鍵字一般用於常量宣告,用 const 關鍵字宣告的常量需要在宣告時進行初始化並且不可以再進行修改,並且 const 關鍵字宣告的常量被限制於塊級作用域中進行訪問。

function f() {
  {
    let x;
    {
      // okay, block scoped name
      const x = "sneaky";
      // error, const
      x = "foo";
    }
    // error, already declared in block
    let x = "inner";
  }
}複製程式碼

JavaScript 中 const 關鍵字的表現於 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 限制的並非值不可變性;而是建立了不可變的繫結,即對於某個值的只讀引用,並且禁止了對於該引用的重賦值,即如下的程式碼會觸發錯誤:

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() 也只能防止頂層屬性被修改,而無法限制對於巢狀屬性的修改,這一點我們會在下文的淺拷貝與深拷貝部分繼續討論。

變數賦值

按值傳遞

JavaScript 中永遠是按值傳遞(pass-by-value),只不過當我們傳遞的是某個物件的引用時,這裡的值指的是物件的引用。按值傳遞中函式的形參是被呼叫時所傳實參的副本。修改形參的值並不會影響實參。而按引用傳遞(pass-by-reference)時,函式的形參接收實參的隱式引用,而不再是副本。這意味著函式形參的值如果被修改,實參也會被修改。同時兩者指向相同的值。我們首先看下 C 中按值傳遞與引用傳遞的區別:

void Modify(int p, int * q)
{
    p = 27; // 按值傳遞 - p是實參a的副本, 只有p被修改
    *q = 27; // q是b的引用,q和b都被修改
}
int main()
{
    int a = 1;
    int b = 1;
    Modify(a, &b);   // a 按值傳遞, b 按引用傳遞,
                     // a 未變化, b 改變了
    return(0);
}複製程式碼

而在 JavaScript 中,對比例子如下:

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);

// 輸出結果
10
changed
unchanged複製程式碼

JavaScript 按值傳遞就表現於在內部修改了 c 的值但是並不會影響到外部的 obj2 變數。如果我們更深入地來理解這個問題,JavaScript 對於物件的傳遞則是按共享傳遞的(pass-by-sharing,也叫按物件傳遞、按物件共享傳遞)。最早由Barbara Liskov. 在1974年的GLU語言中提出;該求值策略被用於Python、Java、Ruby、JS等多種語言。該策略的重點是:呼叫函式傳參時,函式接受物件實參引用的副本(既不是按值傳遞的物件副本,也不是按引用傳遞的隱式引用)。 它和按引用傳遞的不同在於:在共享傳遞中對函式形參的賦值,不會影響實參的值。按共享傳遞的直接表現就是上述程式碼中的 obj1,當我們在函式內修改了 b 指向的物件的屬性值時,我們使用 obj1 來訪問相同的變數時同樣會得到變化後的值。

連續賦值

JavaScript 中是支援變數的連續賦值,即譬如:

var a=b=1;複製程式碼

但是在連續賦值中,會發生引用保留,可以考慮如下情景:


var a = {n:1};  
a.x = a = {n:2};  
alert(a.x); // --> undefined複製程式碼

為了解釋上述問題,我們引入一個新的變數:


var a = {n:1};  
var b = a; // 持有a,以回查  
a.x = a = {n:2};  
alert(a.x);// --> undefined  
alert(b.x);// --> [object Object]複製程式碼

實際上在連續賦值中,值是直接賦予給變數指向的記憶體地址:


              a.x  =  a  = {n:2}
              │      │
      {n:1}<──┘      └─>{n:2}複製程式碼

Deconstruction: 解構賦值

解構賦值允許你使用類似陣列或物件字面量的語法將陣列和物件的屬性賦給各種變數。這種賦值語法極度簡潔,同時還比傳統的屬性訪問方法更為清晰。傳統的訪問陣列前三個元素的方式為:

    var first = someArray[0];
    var second = someArray[1];
    var third = someArray[2];複製程式碼

而通過解構賦值的特性,可以變為:

    var [first, second, third] = someArray;複製程式碼
// === Arrays

var [a, b] = [1, 2];
console.log(a, b);
//=> 1 2


// Use from functions, only select from pattern
var foo = () => {
  return [1, 2, 3];
};

var [a, b] = foo();
console.log(a, b);
// => 1 2


// Omit certain values
var [a, , b] = [1, 2, 3];
console.log(a, b);
// => 1 3


// Combine with spread/rest operator (accumulates the rest of the values)
var [a, ...b] = [1, 2, 3];
console.log(a, b);
// => 1 [ 2, 3 ]


// Fail-safe.
var [, , , a, b] = [1, 2, 3];
console.log(a, b);
// => undefined undefined


// Swap variables easily without temp
var a = 1, b = 2;
[b, a] = [a, b];
console.log(a, b);
// => 2 1


// Advance deep arrays
var [a, [b, [c, d]]] = [1, [2, [[[3, 4], 5], 6]]];
console.log("a:", a, "b:", b, "c:", c, "d:", d);
// => a: 1 b: 2 c: [ [ 3, 4 ], 5 ] d: 6


// === Objects

var {user: x} = {user: 5};
console.log(x);
// => 5


// Fail-safe
var {user: x} = {user2: 5};
console.log(x);
// => undefined


// More values
var {prop: x, prop2: y} = {prop: 5, prop2: 10};
console.log(x, y);
// => 5 10

// Short-hand syntax
var { prop, prop2} = {prop: 5, prop2: 10};
console.log(prop, prop2);
// => 5 10

// Equal to:
var { prop: prop, prop2: prop2} = {prop: 5, prop2: 10};
console.log(prop, prop2);
// => 5 10

// Oops: This doesn't work:
var a, b;
{ a, b } = {a: 1, b: 2};

// But this does work
var a, b;
({ a, b } = {a: 1, b: 2});
console.log(a, b);
// => 1 2

// This due to the grammar in JS. 
// Starting with { implies a block scope, not an object literal. 
// () converts to an expression.

// From Harmony Wiki:
// Note that object literals cannot appear in
// statement positions, so a plain object
// destructuring assignment statement
//  { x } = y must be parenthesized either
// as ({ x } = y) or ({ x }) = y.

// Combine objects and arrays
var {prop: x, prop2: [, y]} = {prop: 5, prop2: [10, 100]};
console.log(x, y);
// => 5 100


// Deep objects
var {
  prop: x,
  prop2: {
    prop2: {
      nested: [ , , b]
    }
  }
} = { prop: "Hello", prop2: { prop2: { nested: ["a", "b", "c"]}}};
console.log(x, b);
// => Hello c


// === Combining all to make fun happen

// All well and good, can we do more? Yes!
// Using as method parameters
var foo = function ({prop: x}) {
  console.log(x);
};

foo({invalid: 1});
foo({prop: 1});
// => undefined
// => 1


// Can also use with the advanced example
var foo = function ({
  prop: x,
  prop2: {
    prop2: {
      nested: b
    }
  }
}) {
  console.log(x, ...b);
};
foo({ prop: "Hello", prop2: { prop2: { nested: ["a", "b", "c"]}}});
// => Hello a b c


// In combination with other ES2015 features.

// Computed property names
const name = 'fieldName';
const computedObject = { [name]: name }; // (where object is { 'fieldName': 'fieldName' })
const { [name]: nameValue } = computedObject;
console.log(nameValue)
// => fieldName



// Rest and defaults
var ajax = function ({ url = "localhost", port: p = 80}, ...data) {
  console.log("Url:", url, "Port:", p, "Rest:", data);
};

ajax({ url: "someHost" }, "additional", "data", "hello");
// => Url: someHost Port: 80 Rest: [ 'additional', 'data', 'hello' ]

ajax({ }, "additional", "data", "hello");
// => Url: localhost Port: 80 Rest: [ 'additional', 'data', 'hello' ]


// Ooops: Doesn't work (in traceur)
var ajax = ({ url = "localhost", port: p = 80}, ...data) => {
  console.log("Url:", url, "Port:", p, "Rest:", data);
};
ajax({ }, "additional", "data", "hello");
// probably due to traceur compiler

But this does:
var ajax = ({ url: url = "localhost", port: p = 80}, ...data) => {
  console.log("Url:", url, "Port:", p, "Rest:", data);
};
ajax({ }, "additional", "data", "hello");


// Like _.pluck
var users = [
  { user: "Name1" },
  { user: "Name2" },
  { user: "Name2" },
  { user: "Name3" }
];
var names = users.map( ({ user }) => user );
console.log(names);
// => [ 'Name1', 'Name2', 'Name2', 'Name3' ]


// Advanced usage with Array Comprehension and default values
var users = [
  { user: "Name1" },
  { user: "Name2", age: 2 },
  { user: "Name2" },
  { user: "Name3", age: 4 }
];

[for ({ user, age = "DEFAULT AGE" } of users) console.log(user, age)];
// => Name1 DEFAULT AGE
// => Name2 2
// => Name2 DEFAULT AGE
// => Name3 4複製程式碼

陣列與迭代器

以上是陣列解構賦值的一個簡單示例,其語法的一般形式為:

    [ variable1, variable2, ..., variableN ] = array;複製程式碼

這將為variable1到variableN的變數賦予陣列中相應元素項的值。如果你想在賦值的同時宣告變數,可在賦值語句前加入varletconst關鍵字,例如:

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

當訪問空陣列或越界訪問陣列時,對其解構與對其索引的行為一致,最終得到的結果都是:undefined

    console.log([][0]);
    // undefined
    var [missing] = [];
    console.log(missing);
    // undefined複製程式碼

請注意,陣列解構賦值的模式同樣適用於任意迭代器:

    function* fibs() {
      var a = 0;
      var b = 1;
      while (true) {
        yield a;
        [a, b] = [b, a + b];
      }
    }
    var [first, second, third, fourth, fifth, sixth] = fibs();
    console.log(sixth);
    // 5複製程式碼

物件

通過解構物件,你可以把它的每個屬性與不同的變數繫結,首先指定被繫結的屬性,然後緊跟一個要解構的變數。

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

與陣列解構一樣,你可以隨意巢狀並進一步組合物件解構:

    var complicatedObj = {
      arrayProp: [
        "Zapp",
        { second: "Brannigan" }
      ]
    };
    var { arrayProp: [first, { second }] } = complicatedObj;
    console.log(first);
    // "Zapp"
    console.log(second);
    // "Brannigan"複製程式碼

當你解構一個未定義的屬性時,得到的值為undefined

    var { missing } = {};
    console.log(missing);
    // undefined複製程式碼

請注意,當你解構物件並賦值給變數時,如果你已經宣告或不打算宣告這些變數(亦即賦值語句前沒有letconstvar關鍵字),你應該注意這樣一個潛在的語法錯誤:

    { blowUp } = { blowUp: 10 };
    // Syntax error 語法錯誤複製程式碼

為什麼會出錯?這是因為JavaScript語法通知解析引擎將任何以{開始的語句解析為一個塊語句(例如,{console}是一個合法塊語句)。解決方案是將整個表示式用一對小括號包裹:

    ({ safe } = {});
    // No errors 沒有語法錯誤複製程式碼

預設值

當你要解構的屬性未定義時你可以提供一個預設值:

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

由於解構中允許對物件進行解構,並且還支援預設值,那麼完全可以將解構應用在函式引數以及引數的預設值中。

function removeBreakpoint({ url, line, column }) {
      // ...
    }複製程式碼

當我們構造一個提供配置的物件,並且需要這個物件的屬性攜帶預設值時,解構特性就派上用場了。舉個例子,jQuery的ajax函式使用一個配置物件作為它的第二引數,我們可以這樣重寫函式定義:

jQuery.ajax = function (url, {
      async = true,
      beforeSend = noop,
      cache = true,
      complete = noop,
      crossDomain = false,
      global = true,
      // ... 更多配置
    }) {
      // ... do stuff
    };複製程式碼

同樣,解構也可以應用在函式的多重返回值中,可以類似於其他語言中的元組的特性:

function returnMultipleValues() {
      return [1, 2];
    }
var [foo, bar] = returnMultipleValues();複製程式碼

Three Dots

Rest Operator

在 JavaScript 函式呼叫時我們往往會使用內建的 arguments 物件來獲取函式的呼叫引數,不過這種方式卻存在著很多的不方便性。譬如 arguments 物件是 Array-Like 物件,無法直接運用陣列的 .map() 或者 .forEach() 函式;並且因為 arguments 是繫結於當前函式作用域,如果我們希望在巢狀函式裡使用外層函式的 arguments 物件,我們還需要建立中間變數。

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 來以陣列形式獲取函式的呼叫引數,Rest Operator 也可以用於在解構賦值中以陣列方式獲取剩餘的變數:

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 的應用場景譬如進行不定陣列的指定型別過濾:

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 物件,但是我們仍然可以使用 Rest Operator 來獲取 Arrow Function 的呼叫引數:

(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 的功能正好相反,其常用於進行陣列構建與解構賦值,也可以用於將某個陣列轉化為函式的引數列表,其基本使用方式如下:

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 來簡化函式呼叫:

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 來方便地從舊有的物件中建立新的物件,並且能夠修改部分值;譬如:

var obj = {a:1,b:2}
var obj_new_1 = Object.assign({},obj,{a:3});
var obj_new_2 = {
  ...obj,
  a:3
}複製程式碼

最後我們還需要討論下 Spread Operator 與 Iteration Protocols,實際上 Spread Operator 也是使用的 Iteration Protocols 來進行元素遍歷與結果蒐集;因此我們也可以通過自定義 Iterator 的方式來控制 Spread Operator 的表現。Iterable 協議規定了物件必須包含 Symbol.iterator 方法,該方法返回某個 Iterator 物件:

interface Iterable {  
  [Symbol.iterator]() {
    //...
    return Iterator;
  }
}複製程式碼

該 Iterator 物件從屬於 Iterator Protocol,其需要提供 next 成員方法,該方法會返回某個包含 done 與 value 屬性的物件:

interface Iterator {  
  next() {
     //...
     return {
        value: <value>,
        done: <boolean>
     };
  };
}複製程式碼

典型的 Iterable 物件就是字串:

var str = 'hi';  
var iterator = str[Symbol.iterator]();  
iterator.toString(); // => '[object String Iterator]'  
iterator.next();     // => { value: 'h', done: false }  
iterator.next();     // => { value: 'i', done: false }  
iterator.next();     // => { value: undefined, done: true }  
[...str];            // => ['h', 'i']複製程式碼

我們可以通過自定義 array-like 物件的 Symbol.iterator 屬性來控制其在迭代器上的效果:

function iterator() {  
  var index = 0;
  return {
    next: () => ({ // Conform to Iterator protocol
      done : index >= this.length,
      value: this[index++]
    })
  };
}
var arrayLike = {  
  0: 'Cat',
  1: 'Bird',
  length: 2
};
// Conform to Iterable Protocol
arrayLike[Symbol.iterator] = iterator;  
var array = [...arrayLike];  
console.log(array); // => ['Cat', 'Bird']複製程式碼

arrayLike[Symbol.iterator] 為該物件建立了值為某個迭代器的屬性,從而使該物件符合了 Iterable 協議;而 iterator() 又返回了包含 next 成員方法的物件,使得該物件最終具有和陣列相似的行為表現。

Copy Composite Data Types: 複合型別的拷貝

Shallow Copy: 淺拷貝

頂層屬性遍歷

淺拷貝是指複製物件的時候,指對第一層鍵值對進行獨立的複製。一個簡單的實現如下:

// 淺拷貝實現
function shadowCopy(target, source){ 
    if( !source || typeof source !== 'object'){
        return;
    }
    // 這個方法有點小trick,target一定得事先定義好,不然就不能改變實參了。
       // 具體原因解釋可以看參考資料中 JS是值傳遞還是引用傳遞
    if( !target || typeof target !== 'object'){
        return;
    }  
    // 這邊最好區別一下物件和陣列的複製
    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

Object.assign() 方法可以把任意多個的源物件所擁有的自身可列舉屬性拷貝給目標物件,然後返回目標物件。Object.assign 方法只會拷貝源物件自身的並且可列舉的屬性到目標物件身上。注意,對於訪問器屬性,該方法會執行那個訪問器屬性的 getter 函式,然後把得到的值拷貝給目標物件,如果你想拷貝訪問器屬性本身,請使用 Object.getOwnPropertyDescriptor()Object.defineProperties() 方法。

注意,字串型別和 symbol 型別的屬性都會被拷貝。

注意,在屬性拷貝過程中可能會產生異常,比如目標物件的某個只讀屬性和源物件的某個屬性同名,這時該方法會丟擲一個 TypeError 異常,拷貝過程中斷,已經拷貝成功的屬性不會受到影響,還未拷貝的屬性將不會再被拷貝。

注意, Object.assign 會跳過那些值為 nullundefined 的源物件。

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 }複製程式碼
  • 例子:原始值會被隱式轉換成其包裝物件
var v1 = "123";
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": "1", "1": "2", "2": "3" }複製程式碼
  • 例子:拷貝屬性過程中發生異常
var target = Object.defineProperty({}, "foo", {
    value: 1,
    writeable: false
}); // target 的 foo 屬性是個只讀屬性。

Object.assign(target, {bar: 2}, {foo2: 3, foo: 3, foo3: 3}, {baz: 4});
// TypeError: "foo" is read-only
// 注意這個異常是在拷貝第二個源物件的第二個屬性時發生的。

console.log(target.bar);  // 2,說明第一個源物件拷貝成功了。
console.log(target.foo2); // 3,說明第二個源物件的第一個屬性也拷貝成功了。
console.log(target.foo);  // 1,只讀屬性不能被覆蓋,所以第二個源物件的第二個屬性拷貝失敗了。
console.log(target.foo3); // undefined,異常之後 assign 方法就退出了,第三個屬性是不會被拷貝到的。
console.log(target.baz);  // undefined,第三個源物件更是不會被拷貝到的。複製程式碼

使用 [].concat 來複制陣列

同樣類似於對於物件的複製,我們建議使用[].concat來進行陣列的深複製:

var list = [1, 2, 3];
var changedList = [].concat(list);
changedList[1] = 2;
list === changedList; // false複製程式碼

同樣的,concat方法也只能保證一層深複製:

> list = [[1,2,3]]
[ [ 1, 2, 3 ] ]
> new_list = [].concat(list)
[ [ 1, 2, 3 ] ]
> new_list[0][0] = 4
4
> list
[ [ 4, 2, 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);

// 預期結果
{
    title: {
        text: 'hello world',
        subtext: 'Yes, your world.'
    }
}
// 實際結果
{
    title: {
        subtext: 'Yes, your world.'
    }
}複製程式碼

上面這個例子中,對於物件的一級子元素而言,只會替換引用,而不會動態的新增內容。那麼,其實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);

// 結果
opt1:
{
    title: {
        text: 'hello world',
        subtext: 'Yes, your world.'
    }
}
opt2:
{
    title: {
        text: 'hello world',
        subtext: 'Yes, your world.'
    }
}複製程式碼

DeepCopy: 深拷貝

遞迴屬性遍歷

一般來說,在JavaScript中考慮複合型別的深層複製的時候,往往就是指對於Date、Object與Array這三個複合型別的處理。我們能想到的最常用的方法就是先建立一個空的新物件,然後遞迴遍歷舊物件,直到發現基礎型別的子節點才賦予到新物件對應的位置。不過這種方法會存在一個問題,就是JavaScript中存在著神奇的原型機制,並且這個原型會在遍歷的時候出現,然後原型不應該被賦予給新物件。那麼在遍歷的過程中,我們應該考慮使用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.");
}複製程式碼

呼叫如下:

// 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以及函式表示式。

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

延伸閱讀

相關文章