你不懂JS:ES6與未來 第二章:語法(上)

官方中文版原文鏈接

感謝社區中各位的大力支持,譯者再次奉上一點點福利:阿里云產品券,享受所有官網優惠,并抽取幸運大獎:點擊這里領取

如果你曾經或多或少地寫過JS,那么你很可能對它的語法感到十分熟悉。當然有一些奇怪之處,但是總體來講這是一種與其他語言有很多相似之處的,相當合理而且直接的語法。

然而,ES6增加了好幾種需要費些功夫才能習慣的新語法形式。在這一章中,我們將遍歷它們來看看葫蘆里到底賣的什么藥。

提示: 在寫作本書時,這本書中所討論的特性中的一些已經被各種瀏覽器(Firefox,Chrome,等等)實現了,但是有一些僅僅被實現了一部分,而另一些根本就沒實現。如果直接嘗試這些例子,你的體驗可能會夾雜著三種情況。如果是這樣,就使用轉譯器嘗試吧,這些特性中的大多數都被那些工具涵蓋了。ES6Fiddle(http://www.es6fiddle.net/)是一個了不起的嘗試ES6的游樂場,簡單易用,它是一個Babel轉譯器的在線REPL(http://babeljs.io/repl/)。

塊兒作用域聲明

你可能知道在JavaScript中變量作用域的基本單位總是function。如果你需要創建一個作用域的塊兒,除了普通的函數聲明以外最流行的方法就是使用立即被調用的函數表達式(IIFE)。例如:

var a = 2;

(function IIFE(){
    var a = 3;
    console.log( a );   // 3
})();

console.log( a );       // 2

let聲明

但是,現在我們可以創建綁定到任意的塊兒上的聲明了,它(勿庸置疑地)稱為 塊兒作用域。這意味著一對{ .. }就是我們用來創建一個作用域所需要的全部。var總是聲明附著在外圍函數(或者全局,如果在頂層的話)上的變量,取而代之的是,使用let

var a = 2;

{
    let a = 3;
    console.log( a );   // 3
}

console.log( a );       // 2

迄今為止,在JS中使用獨立的{ .. }塊兒不是很常見,也不是慣用模式,但它總是合法的。而且那些來自擁有 塊兒作用域 的語言的開發者將很容易認出這種模式。

我相信使用一個專門的{ .. }塊兒是創建塊兒作用域變量的最佳方法。但是,你應該總是將let聲明放在塊兒的最頂端。如果你有多于一個的聲明,我推薦只使用一個let

從文體上說,我甚至喜歡將let放在與開放的{的同一行中,以便更清楚地表示這個塊兒的目的僅僅是為了這些變量聲明作用域。

{   let a = 2, b, c;
    // ..
}

它現在看起來很奇怪,而且不大可能與其他大多數ES6文獻中推薦的文法吻合。但我的瘋狂是有原因的。

這是另一種實驗性的(不是標準化的)let聲明形式,稱為let塊兒,看起來就像這樣:

let (a = 2, b, c) {
    // ..
}

我稱這種形式為 明確的 塊兒作用域,而與var相似的let聲明形式更像是 隱含的,因為它在某種意義上劫持了它所處的{ .. }。一般來說開發者們認為 明確的 機制要比 隱含的 機制更好一些,我主張這種情況就是這樣的情況之一。

如果你比較前面兩個形式的代碼段,它們非常相似,而且我個人認為兩種形式都有資格在文體上稱為 明確的 塊兒作用域。不幸的是,兩者中最 明確的 let (..) { .. }形式沒有被ES6所采用。它可能會在后ES6時代被重新提起,但我想目前為止前者是我們的最佳選擇。

為了增強對let ..聲明的 隱含 性質的理解,考慮一下這些用法:

let a = 2;

if (a > 1) {
    let b = a * 3;
    console.log( b );       // 6

    for (let i = a; i <= b; i++) {
        let j = i + 10;
        console.log( j );
    }
    // 12 13 14 15 16

    let c = a + b;
    console.log( c );       // 8
}

不要回頭去看這個代碼段,小測驗:哪些變量僅存在于if語句內部?哪些變量僅存在于for循環內部?

答案:if語句包含塊兒作用域變量bc,而for循環包含塊兒作用域變量ij

你有任何遲疑嗎?i沒有被加入外圍的if語句的作用域讓你驚訝嗎?思維上的停頓和疑問 —— 我稱之為“思維稅” —— 不僅源自于let機制對我們來說是新東西,還因為它是 隱含的

還有一個災難是let c = ..聲明出現在作用域中太過靠下的地方。傳統的被var聲明的變量,無論它們出現在何處,都會被附著在整個外圍的函數作用域中;與此不同的是,let聲明附著在塊兒作用域,而且在它們出現在塊兒中之前是不會被初始化的。

在一個let ..聲明/初始化之前訪問一個用let聲明的變量會導致一個錯誤,而對于var聲明來說這個順序無關緊要(除了文體上的區別)。

考慮如下代碼:

{
    console.log( a );   // undefined
    console.log( b );   // ReferenceError!

    var a;
    let b;
}

警告: 這個由于過早訪問被let聲明的引用而引起的ReferenceError在技術上稱為一個 臨時死區(Temporal Dead Zone —— TDZ) 錯誤 —— 你在訪問一個已經被聲明但還沒被初始化的變量。這將不是我們唯一能夠見到TDZ錯誤的地方 —— 在ES6中它們會在幾種地方意外地發生。另外,注意“初始化”并不要求在你的代碼中明確地賦一個值,比如let b;是完全合法的。一個在聲明時沒有被賦值的變量被認為已經被賦予了undefined值,所以let b;let b = undefined;是一樣的。無論是否明確賦值,在let b語句運行之前你都不能訪問b

最后一個坑:對于TDZ變量和未聲明的(或聲明的!)變量,typeof的行為是不同的。例如:

{
    // `a` 沒有被聲明
    if (typeof a === "undefined") {
        console.log( "cool" );
    }

    // `b` 被聲明了,但位于它的TDZ中
    if (typeof b === "undefined") {     // ReferenceError!
        // ..
    }

    // ..

    let b;
}

a沒有被聲明,所以typeof是檢查它是否存在的唯一安全的方法。但是typeof b拋出了TDZ錯誤,因為在代碼下面很遠的地方偶然出現了一個let b聲明。噢。

現在你應當清楚為什么我堅持認為所有的let聲明都應該位于它們作用域的頂部了。這完全避免了偶然過早訪問的錯誤。當你觀察一個塊兒,或任何塊兒的開始部分時,它還更 明確 地指出這個塊兒中含有什么變量。

你的塊兒(if語句,while循環,等等)不一定要與作用域行為共享它們原有的行為。

這種明確性要由你負責,由你用毅力來維護,它將為你省去許多重構時的頭疼和后續的麻煩。

注意: 更多關于let和塊兒作用域的信息,參見本系列的 作用域與閉包 的第三章。

let + for

我偏好 明確 形式的let聲明塊兒,但對此的唯一例外是出現在for循環頭部的let。這里的原因看起來很微妙,但我相信它是更重要的ES6特性中的一個。

考慮如下代碼:

var funcs = [];

for (let i = 0; i < 5; i++) {
    funcs.push( function(){
        console.log( i );
    } );
}

funcs[3]();     // 3

for頭部中的let i不僅是為for循環本身聲明了一個i,而且它為循環的每一次迭代都重新聲明了一個新的i。這意味著在循環迭代內部創建的閉包都分別引用著那些在每次迭代中創建的變量,正如你期望的那樣。

如果你嘗試在這段相同代碼的for循環頭部使用var i,那么你會得到5而不是3,因為在被引用的外部作用域中只有一個i,而不是為每次迭代的函數都有一個i被引用。

你也可以稍稍繁冗地實現相同的東西:

var funcs = [];

for (var i = 0; i < 5; i++) {
    let j = i;
    funcs.push( function(){
        console.log( j );
    } );
}

funcs[3]();     // 3

在這里,我們強制地為每次迭代都創建一個新的j,然后閉包以相同的方式工作。我喜歡前一種形式;那種額外的特殊能力正是我支持for(let .. ) ..形式的原因。可能有人會爭論說它有點兒 隱晦,但是對我的口味來說,它足夠 明確 了,也足夠有用。

letfor..infor..of(參見“for..of循環”)循環中也以形同的方式工作。

const聲明

還有另一種需要考慮的塊兒作用域聲明:const,它創建 常量

到底什么是一個常量?它是一個在初始值被設定后就成為只讀的變量。考慮如下代碼:

{
    const a = 2;
    console.log( a );   // 2

    a = 3;              // TypeError!
}

變量持有的值一旦在聲明時被設定就不允許你改變了。一個const聲明必須擁有一個明確的初始化。如果想要一個持有undefined值的 常量,你必須聲明const a = undefined來得到它。

常量不是一個作用于值本身的制約,而是作用于變量對這個值的賦值。換句話說,值不會因為const而凍結或不可變,只是它的賦值被凍結了。如果這個值是一個復雜值,比如對象或數組,那么這個值的內容仍然是可以被修改的:

{
    const a = [1,2,3];
    a.push( 4 );
    console.log( a );       // [1,2,3,4]

    a = 42;                 // TypeError!
}

變量a實際上沒有持有一個恒定的數組;而是持有一個指向數組的恒定的引用。數組本身可以自由變化。

警告: 將一個對象或數組作為常量賦值意味著這個值在常量的詞法作用域消失以前是不能夠被垃圾回收的,因為指向這個值的引用是永遠不能解除的。這可能是你期望的,但如果不是你就要小心!

實質上,const聲明強制實行了我們許多年來在代碼中用文體來表明的東西:我們聲明一個名稱全由大寫字母組成的變量并賦予它某些字面值,我們小心照看它以使它永不改變。var賦值沒有強制性,但是現在const賦值上有了,它可以幫你發現不經意的改變。

const可以 被用于forfor..in,和for..of循環(參見“for..of循環”)的變量聲明。然而,如果有任何重新賦值的企圖,一個錯誤就會被拋出,例如在for循環中常見的i++子句。

const用還是不用

有些流傳的猜測認為在特定的場景下,與letvar相比一個const可能會被JS引擎進行更多的優化。理論上,引擎可以更容易地知道變量的值/類型將永遠不會改變,所以它可以免除一些可能的追蹤工作。

無論const在這方面是否真的有幫助,還是這僅僅是我們的幻想和直覺,你要做的更重要的決定是你是否打算使用常量的行為。記住:源代碼扮演的一個最重要的角色是為了明確地交流你的意圖是什么,不僅是與你自己,而且還是與未來的你和其他的代碼協作者。

一些開發者喜歡在一開始將每個變量都聲明為一個const,然后當它的值在代碼中有必要發生變化的時候將聲明放松至一個let。這是一個有趣的角度,但是不清楚這是否真正能夠改善代碼的可讀性或可推理性。

就像許多人認為的那樣,它不是一種真正的 保護,因為任何后來的想要改變一個const值的開發者都可以盲目地將聲明從const改為let。它至多是防止意外的改變。但是同樣地,除了我們的直覺和感覺以外,似乎沒有客觀和明確的標準可以衡量什么構成了“意外”或預防措施。這與類型強制上的思維模式類似。

我的建議:為了避免潛在的令人糊涂的代碼,僅將const用于那些你有意地并且明顯地標識為不會改變的變量。換言之,不要為了代碼行為而 依靠 const,而是在為了意圖可以被清楚地表明時,將它作為一個表明意圖的工具。

塊兒作用域的函數

從ES6開始,發生在塊兒內部的函數聲明現在被明確規定屬于那個塊兒的作用域。在ES6之前,語言規范沒有要求這一點,但是許多實現不管怎樣都是這么做的。所以現在語言規范和現實吻合了。

考慮如下代碼:

{
    foo();                  // 好用!

    function foo() {
        // ..
    }
}

foo();                      // ReferenceError

函數foo()是在{ .. }塊兒內部被聲明的,由于ES6的原因它是屬于那里的塊兒作用域的。所以在那個塊兒的外部是不可用的。但是還要注意它在塊兒里面被“提升”了,這與早先提到的遭受TDZ錯誤陷阱的let聲明是相反的。

如果你以前曾經寫過這樣的代碼,并依賴于老舊的非塊兒作用域行為的話,那么函數聲明的塊兒作用域可能是一個問題:

if (something) {
    function foo() {
        console.log( "1" );
    }
}
else {
    function foo() {
        console.log( "2" );
    }
}

foo();      // ??

在前ES6環境下,無論something的值是什么foo()都將會打印"2",因為兩個函數聲明被提升到了塊兒的頂端,而且總是第二個有效。

在ES6中,最后一行將拋出一個ReferenceError

擴散/剩余

ES6引入了一個新的...操作符,根據你在何處以及如何使用它,它一般被稱作 擴散(spread)剩余(rest) 操作符。讓我們看一看:

function foo(x,y,z) {
    console.log( x, y, z );
}

foo( ...[1,2,3] );              // 1 2 3

...在一個數組(實際上,是我們將在第三章中講解的任何的 可迭代 對象)前面被使用時,它就將數組“擴散”為它的個別的值。

通常你將會在前面所展示的那樣的代碼段中看到這種用法,它將一個數組擴散為函數調用的一組參數。在這種用法中,...扮演了apply(..)方法的簡約語法替代品,在前ES6中我們經常這樣使用apply(..)

foo.apply( null, [1,2,3] );     // 1 2 3

...也可以在其他上下文環境中被用于擴散/展開一個值,比如在另一個數組聲明內部:

var a = [2,3,4];
var b = [ 1, ...a, 5 ];

console.log( b );                   // [1,2,3,4,5]

在這種用法中,...取代了concat(..),它在這里的行為就像[1].concat( a, [5] )

另一種...的用法常見于一種實質上相反的操作;與將值散開不同,...將一組值 收集 到一個數組中。

function foo(x, y, ...z) {
    console.log( x, y, z );
}

foo( 1, 2, 3, 4, 5 );           // 1 2 [3,4,5]

這個代碼段中的...z實質上是在說:“將 剩余的 參數值(如果有的話)收集到一個稱為z的數組中。” 因為x被賦值為1,而y被賦值為2,所以剩余的參數值34,和5被收集進了z

當然,如果你沒有任何命名參數,...會收集所有的參數值:

function foo(...args) {
    console.log( args );
}

foo( 1, 2, 3, 4, 5);            // [1,2,3,4,5]

注意:foo(..)函數聲明中的...args經常因為你向其中收集參數的剩余部分而被稱為“剩余參數”。我喜歡使用“收集”這個詞,因為它描述了它做什么而不是它包含什么。

這種用法最棒的地方是,它為被廢棄了很久的arguments數組 —— 實際上它不是一個真正的數組,而是一個類數組對象 —— 提供了一種非常穩健的替代方案。因為args(無論你叫它什么 —— 許多人喜歡叫它r或者rest)是一個真正的數組,我們可以擺脫許多愚蠢的前ES6技巧,我們曾經通過這些技巧盡全力去使arguments變成我們可以視之為數組的東西。

考慮如下代碼:

// 使用新的ES6方式
function foo(...args) {
    // `args`已經是一個真正的數組了

    // 丟棄`args`中的第一個元素
    args.shift();

    // 將`args`的所有內容作為參數值傳給`console.log(..)`
    console.log( ...args );
}

// 使用老舊的前ES6方式
function bar() {
    // 將`arguments`轉換為一個真正的數組
    var args = Array.prototype.slice.call( arguments );

    // 在末尾添加一些元素
    args.push( 4, 5 );

    // 過濾掉所有奇數
    args = args.filter( function(v){
        return v % 2 == 0;
    } );

    // 將`args`的所有內容作為參數值傳給`foo(..)`
    foo.apply( null, args );
}

bar( 0, 1, 2, 3 );                  // 2 4

在函數foo(..)聲明中的...args收集參數值,而在console.log(..)調用中的...args將它們擴散開。這個例子很好地展示了...操作符平行但相反的用途。

除了在函數聲明中...的用法以外,還有另一種...被用于收集值的情況,我們將在本章稍后的“太多,太少,正合適”一節中檢視它。

默認參數值

也許在JavaScript中最常見的慣用法之一就是為函數參數設置默認值。我們多年來一直使用的方法應當看起來很熟悉:

function foo(x,y) {
    x = x || 11;
    y = y || 31;

    console.log( x + y );
}

foo();              // 42
foo( 5, 6 );        // 11
foo( 5 );           // 36
foo( null, 6 );     // 17

當然,如果你曾經用過這種模式,你就會知道它既有用又有點兒危險,例如如果你需要能夠為其中一個參數傳入一個可能被認為是falsy的值。考慮下面的代碼:

foo( 0, 42 );       // 53 <-- 噢,不是42

為什么?因為0是falsy,因此x || 11的結果為11,而不是直接被傳入的0

為了填這個坑,一些人會像這樣更加啰嗦地編寫檢查:

function foo(x,y) {
    x = (x !== undefined) ? x : 11;
    y = (y !== undefined) ? y : 31;

    console.log( x + y );
}

foo( 0, 42 );           // 42
foo( undefined, 6 );    // 17

當然,這意味著除了undefined以外的任何值都可以直接傳入。然而,undefined將被假定是這樣一種信號,“我沒有傳入這個值。” 除非你實際需要能夠傳入undefined,它就工作的很好。

在那樣的情況下,你可以通過測試參數值是否沒有出現在arguments數組中,來看它是否實際上被省略了,也許是像這樣:

function foo(x,y) {
    x = (0 in arguments) ? x : 11;
    y = (1 in arguments) ? y : 31;

    console.log( x + y );
}

foo( 5 );               // 36
foo( 5, undefined );    // NaN

但是在沒有能力傳入意味著“我省略了這個參數值”的任何種類的值(連undefined也不行)的情況下,你如何才能省略第一個參數值x呢?

foo(,5)很誘人,但它不是合法的語法。foo.apply(null,[,5])看起來應該可以實現這個技巧,但是apply(..)的奇怪之處意味著這組參數值將被視為[undefined,5],顯然它沒有被省略。

如果你深入調查下去,你將發現你只能通過簡單地傳入比“期望的”參數值個數少的參數值來省略末尾的參數值,但是你不能省略在參數值列表中間或者開頭的參數值。這就是不可能。

這里有一個施用于JavaScript設計的重要原則需要記住:undefined意味著 缺失。也就是,在undefined缺失 之間沒有區別,至少是就函數參數值而言。

注意: 容易令人糊涂的是,JS中有其他的地方不適用這種特殊的設計原則,比如帶有空值槽的數組。更多信息參見本系列的 類型與文法

帶著所有這些認識,現在我們可以檢視在ES6中新增的一種有用的好語法,來簡化對丟失的參數值進行默認值的賦值。

function foo(x = 11, y = 31) {
    console.log( x + y );
}

foo();                  // 42
foo( 5, 6 );            // 11
foo( 0, 42 );           // 42

foo( 5 );               // 36
foo( 5, undefined );    // 36 <-- `undefined`是缺失
foo( 5, null );         // 5  <-- null強制轉換為`0`

foo( undefined, 6 );    // 17 <-- `undefined`是缺失
foo( null, 6 );         // 6  <-- null強制轉換為`0`

注意這些結果,和它們如何暗示了與前面的方式的微妙區別和相似之處。

與常見得多的x || 11慣用法相比,在一個函數聲明中的x = 11更像x !== undefined ? x : 11,所以在將你的前ES6代碼轉換為這種ES6默認參數值語法時要多加小心。

注意: 一個剩余/收集參數(參見“擴散/剩余”)不能擁有默認值。所以,雖然function foo(...vals=[1,2,3]) {看起來是一種迷人的能力,但它不是合法的語法。有必要的話你需要繼續手動實施那種邏輯。

默認值表達式

函數默認值可以比像31這樣的簡單值復雜得多;它們可以是任何合法的表達式,甚至是函數調用:

function bar(val) {
    console.log( "bar called!" );
    return y + val;
}

function foo(x = y + 3, z = bar( x )) {
    console.log( x, z );
}

var y = 5;
foo();                              // "bar called"
                                    // 8 13
foo( 10 );                          // "bar called"
                                    // 10 15
y = 6;
foo( undefined, 10 );               // 9 10

如你所見,默認值表達式是被懶惰地求值的,這意味著他們僅在被需要時運行 —— 也就是,當一個參數的參數值被省略或者為undefined

這是一個微妙的細節,但是在一個函數聲明中的正式參數是在它們自己的作用域中的(將它想象為一個僅僅圍繞在函數聲明的(..)外面的一個作用域氣泡),不是在函數體的作用域中。這意味著在一個默認值表達式中的標識符引用會在首先在正式參數的作用域中查找標識符,然后再查找一個外部作用域。更多信息參見本系列的 作用域與閉包

考慮如下代碼:

var w = 1, z = 2;

function foo( x = w + 1, y = x + 1, z = z + 1 ) {
    console.log( x, y, z );
}

foo();                  // ReferenceError

在默認值表達式w + 1中的w在正式參數作用域中查找w,但沒有找到,所以外部作用域的w被使用了。接下來,在默認值表達式x + 1中的x在正式參數的作用域中找到了x,而且走運的是x已經被初始化了,所以對y的賦值工作的很好。

然而,z + 1中的z找到了一個在那個時刻還沒有被初始化的參數變量z,所以它絕不會試著在外部作用域中尋找z

正如我們在本章早先的“let聲明”一節中提到過的那樣,ES6擁有一個TDZ,它會防止一個變量在它還沒有被初始化的狀態下被訪問。因此,z + 1默認值表達式拋出一個TDZReferenceError錯誤。

雖然對于代碼的清晰度來說不見得是一個好主意,一個默認值表達式甚至可以是一個內聯的函數表達式調用 —— 通常被稱為一個立即被調用的函數表達式(IIFE):

function foo( x =
    (function(v){ return v + 11; })( 31 )
) {
    console.log( x );
}

foo();          // 42

一個IIFE(或者任何其他被執行的內聯函數表達式)作為默認值表示來說很合適是非常少見的。如果你發現自己試圖這么做,那么就退一步再考慮一下!

警告: 如果一個IIFE試圖訪問標識符x,而且還沒有聲明自己的x,那么這也將是一個TDZ錯誤,就像我們剛才討論的一樣。

前一個代碼段的默認值表達式是一個IIFE,這是因為它是通過(31)在內聯時立即被執行。如果我們去掉這一部分,賦予x的默認值將會僅僅是一個函數的引用,也許像一個默認的回調。可能有一些情況這種模式將十分有用,比如:

function ajax(url, cb = function(){}) {
    // ..
}

ajax( "http://some.url.1" );

這種情況下,我們實質上想在沒有其他值被指定時,讓默認的cb是一個沒有操作的空函數。這個函數表達式只是一個函數引用,不是一個調用它自己(在它末尾沒有調用的())以達成自己目的的函數。

從JS的早些年開始,就有一個少為人知但是十分有用的奇怪之處可供我們使用:Function.prototype本身就是一個沒有操作的空函數。這樣,這個聲明可以是cb = Function.prototype而省去內聯函數表達式的創建。

解構

ES6引入了一個稱為 解構 的新語法特性,如果你將它考慮為 結構化賦值 那么它令人困惑的程度可能會小一些。為了理解它的含義,考慮如下代碼:

function foo() {
    return [1,2,3];
}

var tmp = foo(),
    a = tmp[0], b = tmp[1], c = tmp[2];

console.log( a, b, c );             // 1 2 3

如你所見,我們創建了一個手動賦值:從foo()返回的數組中的值到個別的變量ab,和c,而且這么做我們就(不幸地)需要tmp變量。

相似地,我們也可以用對象這么做:

function bar() {
    return {
        x: 4,
        y: 5,
        z: 6
    };
}

var tmp = bar(),
    x = tmp.x, y = tmp.y, z = tmp.z;

console.log( x, y, z );             // 4 5 6

屬性值tmp.x被賦值給變量xtmp.yytmp.zz也一樣。

從一個數組中取得索引的值,或從一個對象中取得屬性并手動賦值可以被認為是 結構化賦值。ES6為 解構 增加了一種專門的語法,具體地稱為 數組解構對象結構。這種語法消滅了前一個代碼段中對變量tmp的需要,使它們更加干凈。考慮如下代碼:

var [ a, b, c ] = foo();
var { x: x, y: y, z: z } = bar();

console.log( a, b, c );             // 1 2 3
console.log( x, y, z );             // 4 5 6

你很可能更加習慣于看到像[a,b,c]這樣的東西出現在一個=賦值的右手邊的語法,即作為要被賦予的值。

解構對稱地翻轉了這個模式,所以在=賦值左手邊的[a,b,c]被看作是為了將右手邊的數組拆解為分離的變量賦值的某種“模式”。

類似地,{ x: x, y: y, z: z }指明了一種“模式”把來自于bar()的對象拆解為分離的變量賦值。

對象屬性賦值模式

讓我們深入前一個代碼段中的{ x: x, .. }語法。如果屬性名與你想要聲明的變量名一致,你實際上可以縮寫這個語法:

var { x, y, z } = bar();

console.log( x, y, z );             // 4 5 6

很酷,對吧?

{ x, .. }是省略了x:部分還是省略了: x部分?當我們使用這種縮寫語法時,我們實際上省略了x:部分。這看起來可能不是一個重要的細節,但是一會兒你就會了解它的重要性。

如果你能寫縮寫形式,那為什么你還要寫出更長的形式呢?因為更長的形式事實上允許你將一個屬性賦值給一個不同的變量名稱,這有時很有用:

var { x: bam, y: baz, z: bap } = bar();

console.log( bam, baz, bap );       // 4 5 6
console.log( x, y, z );             // ReferenceError

關于這種對象結構形式有一個微妙但超級重要的怪異之處需要理解。為了展示為什么它可能是一個你需要注意的坑,讓我們考慮一下普通對象字面量的“模式”是如何被指定的:

var X = 10, Y = 20;

var o = { a: X, b: Y };

console.log( o.a, o.b );            // 10 20

{ a: X, b: Y }中,我們知道a是對象屬性,而X是被賦值給它的源值。換句話說,它的語義模式是目標: 源,或者更明顯地,屬性別名: 值。我們能直觀地明白這一點,因為它和=賦值是一樣的,而它的模式就是目標 = 源

然而,當你使用對象解構賦值時 —— 也就是,將看起來像是對象字面量的{ .. }語法放在=操作符的左手邊 —— 你反轉了這個目標: 源的模式。

回想一下:

var { x: bam, y: baz, z: bap } = bar();

這里面對稱的模式是源: 目標(或者值: 屬性別名)。x: bam意味著屬性x是源值而ban是被賦值的目標變量。換句話說,對象字面量是target <-- source,而對象解構賦值是source --> target。看到它是如何反轉的了嗎?

有另外一種考慮這種語法的方式,可能有助于緩和這種困惑。考慮如下代碼:

var aa = 10, bb = 20;

var o = { x: aa, y: bb };
var     { x: AA, y: BB } = o;

console.log( AA, BB );              // 10 20

{ x: aa, y: bb }這一行中,xy代表對象屬性。在{ x: AA, y: BB }這一行,xy 代表對象屬性。

還記得剛才我是如何斷言{ x, .. }省去了x:部分的嗎?在這兩行中,如果你在代碼段中擦掉x:y:部分,僅留下aa, bbAA, BB,它的效果 —— 從概念上講,實際上不能 —— 將是從aa賦值到AA和從bb賦值到BB

所以,這種平行性也許有助于解釋為什么對于這種ES6特性,語法模式被故意地反轉了。

注意: 對于解構賦值來說我更喜歡它的語法是{ AA: x , BB: y },因為那樣的話可以在兩種用法中一致地使用我們更熟悉的target: source模式。唉,我已經被迫訓練自己的大腦去習慣這種反轉了,就像一些讀者也不得不去做的那樣。

不僅是聲明

至此,我們一直將解構賦值與var聲明(當然,它們也可以使用letconst)一起使用,但是解構是一種一般意義上的賦值操作,不僅是一種聲明。

考慮如下代碼:

var a, b, c, x, y, z;

[a,b,c] = foo();
( { x, y, z } = bar() );

console.log( a, b, c );             // 1 2 3
console.log( x, y, z );             // 4 5 6

變量可以是已經被定義好的,然后解構僅僅負責賦值,正如我們已經看到的那樣。

注意: 特別對于對象解構形式來說,當我們省略了var/let/const聲明符時,就必須將整個賦值表達式包含在()中,因為如果不這樣做的話左手邊作為語句第一個元素的{ .. }將被視為一個語句塊兒而不是一個對象。

事實上,變量表達式(ay,等等)不必是一個變量標識符。任何合法的賦值表達式都是允許的。例如:

var o = {};

[o.a, o.b, o.c] = foo();
( { x: o.x, y: o.y, z: o.z } = bar() );

console.log( o.a, o.b, o.c );       // 1 2 3
console.log( o.x, o.y, o.z );       // 4 5 6

你甚至可以在解構中使用計算型屬性名。考慮如下代碼:

var which = "x",
    o = {};

( { [which]: o[which] } = bar() );

console.log( o.x );                 // 4

[which]:的部分是計算型屬性名,它的結果是x —— 將從當前的對象中拆解出來作為賦值的源頭的屬性。o[which]的部分只是一個普通的對象鍵引用,作為賦值的目標來說它與o.x是等價的。

你可以使用普通的賦值來創建對象映射/變形,例如:

var o1 = { a: 1, b: 2, c: 3 },
    o2 = {};

( { a: o2.x, b: o2.y, c: o2.z } = o1 );

console.log( o2.x, o2.y, o2.z );    // 1 2 3

或者你可以將對象映射進一個數組,例如:

var o1 = { a: 1, b: 2, c: 3 },
    a2 = [];

( { a: a2[0], b: a2[1], c: a2[2] } = o1 );

console.log( a2 );                  // [1,2,3]

或者從另一個方向:

var a1 = [ 1, 2, 3 ],
    o2 = {};

[ o2.a, o2.b, o2.c ] = a1;

console.log( o2.a, o2.b, o2.c );    // 1 2 3

或者你可以將一個數組重排到另一個數組中:

var a1 = [ 1, 2, 3 ],
    a2 = [];

[ a2[2], a2[0], a2[1] ] = a1;

console.log( a2 );                  // [2,3,1]

你甚至可以不使用臨時變量來解決傳統的“交換兩個變量”的問題:

var x = 10, y = 20;

[ y, x ] = [ x, y ];

console.log( x, y );                // 20 10

警告: 小心:你不應該將聲明和賦值混在一起,除非你想要所有的賦值表達式 被視為聲明。否則,你會得到一個語法錯誤。這就是為什么在剛才的例子中我必須將var a2 = [][ a2[0], .. ] = ..解構賦值分開做。嘗試var [ a2[0], .. ] = ..沒有任何意義,因為a2[0]不是一個合法的聲明標識符;很顯然它也不能隱含地創建一個var a2 = []聲明來使用。

重復賦值

對象解構形式允許源屬性(持有任意值的類型)被羅列多次。例如:

var { a: X, a: Y } = { a: 1 };

X;  // 1
Y;  // 1

這意味著你既可以解構一個子對象/數組屬性,也可以捕獲這個子對象/數組的值本身。考慮如下代碼:

var { a: { x: X, x: Y }, a } = { a: { x: 1 } };

X;  // 1
Y;  // 1
a;  // { x: 1 }

( { a: X, a: Y, a: [ Z ] } = { a: [ 1 ] } );

X.push( 2 );
Y[0] = 10;

X;  // [10,2]
Y;  // [10,2]
Z;  // 1

關于解構有一句話要提醒:像我們到目前為止的討論中做的那樣,將所有的解構賦值都羅列在單獨一行中的方式可能很誘人。然而,一個好得多的主意是使用恰當的縮進將解構賦值的模式分散在多行中 —— 和你在JSON或對象字面量中做的事非常相似 —— 為了可讀性。

// 很難讀懂:
var { a: { b: [ c, d ], e: { f } }, g } = obj;

// 好一些:
var {
    a: {
        b: [ c, d ],
        e: { f }
    },
    g
} = obj;

記住:解構的目的不僅是為了少打些字,更多是為了聲明可讀性

解構賦值表達式

帶有對象或數組解構的賦值表達式的完成值是右手邊完整的對象/數組值。考慮如下代碼:

var o = { a:1, b:2, c:3 },
    a, b, c, p;

p = { a, b, c } = o;

console.log( a, b, c );         // 1 2 3
p === o;                        // true

在前面的代碼段中,p被賦值為對象o的引用,而不是ab,或c的值。數組解構也是一樣:

var o = [1,2,3],
    a, b, c, p;

p = [ a, b, c ] = o;

console.log( a, b, c );         // 1 2 3
p === o;                        // true

通過將這個對象/數組作為完成值傳遞下去,你可將解構賦值表達式鏈接在一起:

var o = { a:1, b:2, c:3 },
    p = [4,5,6],
    a, b, c, x, y, z;

( {a} = {b,c} = o );
[x,y] = [z] = p;

console.log( a, b, c );         // 1 2 3
console.log( x, y, z );         // 4 5 4

太多,太少,正合適

對于數組解構賦值和對象解構賦值兩者來說,你不必分配所有出現的值。例如:

var [,b] = foo();
var { x, z } = bar();

console.log( b, x, z );             // 2 4 6

foo()返回的值13被丟棄了,從bar()返回的值5也是。

相似地,如果你試著分配比你正在解構/拆解的值要多的值時,它們會如你所想的那樣安靜地退回到undefined

var [,,c,d] = foo();
var { w, z } = bar();

console.log( c, z );                // 3 6
console.log( d, w );                // undefined undefined

這種行為平行地遵循早先提到的“undefined意味著缺失”原則。

我們在本章早先檢視了...操作符,并看到了它有時可以用于將一個數組值擴散為它的分離值,而有時它可以被用于相反的操作:將一組值收集進一個數組。

除了在函數聲明中的收集/剩余用法以外,...可以在解構賦值中實施相同的行為。為了展示這一點,讓我們回想一下本章早先的一個代碼段:

var a = [2,3,4];
var b = [ 1, ...a, 5 ];

console.log( b );                   // [1,2,3,4,5]

我們在這里看到因為...a出現在數組[ .. ]中值的位置,所以它將a擴散開。如果...a出現一個數組解構的位置,它會實施收集行為:

var a = [2,3,4];
var [ b, ...c ] = a;

console.log( b, c );                // 2 [3,4]

解構賦值var [ .. ] = a為了將a賦值給在[ .. ]中描述的模式而將它擴散開。第一部分的名稱b對應a中的第一個值(2)。然后...c將剩余的值(34)收集到一個稱為c的數組中。

注意: 我們已經看到...是如何與數組一起工作的,但是對象呢?那不是一個ES6特性,但是參看第八章中關于一種可能的“ES6之后”的特性的討論,它可以讓...擴散或者收集對象。

默認值賦值

兩種形式的解構都可以為賦值提供默認值選項,它使用和早先討論過的默認函數參數值相似的=語法。

考慮如下代碼:

var [ a = 3, b = 6, c = 9, d = 12 ] = foo();
var { x = 5, y = 10, z = 15, w = 20 } = bar();

console.log( a, b, c, d );          // 1 2 3 12
console.log( x, y, z, w );          // 4 5 6 20

你可以將默認值賦值與前面講過的賦值表達式語法組合在一起。例如:

var { x, y, z, w: WW = 20 } = bar();

console.log( x, y, z, WW );         // 4 5 6 20

如果你在一個解構中使用一個對象或者數組作為默認值,那么要小心不要把自己(或者讀你的代碼的其他開發者)搞糊涂了。你可能會創建一些非常難理解的代碼:

var x = 200, y = 300, z = 100;
var o1 = { x: { y: 42 }, z: { y: z } };

( { y: x = { y: y } } = o1 );
( { z: y = { y: z } } = o1 );
( { x: z = { y: x } } = o1 );

你能從這個代碼段中看出xyz最終是什么值嗎?花點兒時間好好考慮一下,我能想象你的樣子。我會終結這個懸念:

console.log( x.y, y.y, z.y );       // 300 100 42

這里的要點是:解構很棒也可以很有用,但是如果使用得不明智,它也是一把可以傷人(某人的大腦)的利劍。

嵌套解構

如果你正在解構的值擁有嵌套的對象或數組,你也可以解構這些嵌套的值:

var a1 = [ 1, [2, 3, 4], 5 ];
var o1 = { x: { y: { z: 6 } } };

var [ a, [ b, c, d ], e ] = a1;
var { x: { y: { z: w } } } = o1;

console.log( a, b, c, d, e );       // 1 2 3 4 5
console.log( w );                   // 6

嵌套的解構可以是一種將對象名稱空間扁平化的簡單方法。例如:

var App = {
    model: {
        User: function(){ .. }
    }
};

// 取代:
// var User = App.model.User;

var { model: { User } } = App;

參數解構

你能在下面的代碼段中發現賦值嗎?

function foo(x) {
    console.log( x );
}

foo( 42 );

其中的賦值有點兒被隱藏的感覺:當foo(42)被執行時42(參數值)被賦值給x(參數)。如果參數/參數值對是一種賦值,那么按常理說它是一個可以被解構的賦值,對吧?當然!

考慮參數的數組解構:

function foo( [ x, y ] ) {
    console.log( x, y );
}

foo( [ 1, 2 ] );                    // 1 2
foo( [ 1 ] );                       // 1 undefined
foo( [] );                          // undefined undefined

參數也可以進行對象解構:

function foo( { x, y } ) {
    console.log( x, y );
}

foo( { y: 1, x: 2 } );              // 2 1
foo( { y: 42 } );                   // undefined 42
foo( {} );                          // undefined undefined

這種技術是命名參數值(一個長期以來被渴求的JS特性!)的一種近似解法:對象上的屬性映射到被解構的同名參數上。這也意味著我們免費地(在任何位置)得到了可選參數,如你所見,省去“參數”x可以如我們期望的那樣工作。

當然,先前討論過的所有解構的種類對于參數解構來說都是可用的,包括嵌套解構,默認值,和其他。解構也可以和其他ES6函數參數功能很好地混合在一起,比如默認參數值和剩余/收集參數。

考慮這些快速的示例(當然這沒有窮盡所有可能的種類):

function f1([ x=2, y=3, z ]) { .. }
function f2([ x, y, ...z], w) { .. }
function f3([ x, y, ...z], ...w) { .. }

function f4({ x: X, y }) { .. }
function f5({ x: X = 10, y = 20 }) { .. }
function f6({ x = 10 } = {}, { y } = { y: 10 }) { .. }

為了展示一下,讓我們從這個代碼段中取一個例子來檢視:

function f3([ x, y, ...z], ...w) {
    console.log( x, y, z, w );
}

f3( [] );                           // undefined undefined [] []
f3( [1,2,3,4], 5, 6 );              // 1 2 [3,4] [5,6]

這里使用了兩個...操作符,他們都是將值收集到數組中(zw),雖然...z是從第一個數組參數值的剩余值中收集,而...w是從第一個之后的剩余主參數值中收集的。

解構默認值 + 參數默認值

有一個微妙的地方你應當注意要特別小心 —— 解構默認值與函數參數默認值的行為之間的不同。例如:

function f6({ x = 10 } = {}, { y } = { y: 10 }) {
    console.log( x, y );
}

f6();                               // 10 10

首先,看起來我們用兩種不同的方法為參數xy都聲明了默認值10。然而,這兩種不同的方式會在特定的情況下表現出不同的行為,而且這種區別極其微妙。

考慮如下代碼:

f6( {}, {} );                       // 10 undefined

等等,為什么會這樣?十分清楚,如果在第一個參數值的對象中沒有一個同名屬性被傳遞,那么命名參數x將默認為10

yundefined是怎么回事兒?值{ y: 10 }是一個作為函數參數默認值的對象,不是結構默認值。因此,它僅在第二個參數根本沒有被傳遞,或者undefined被傳遞時生效,

在前面的代碼段中,我們傳遞了第二個參數({}),所以默認值{ y: 10 }不被使用,而解構{ y }會針對被傳入的空對象值{}發生。

現在,將{ y } = { y: 10 }{ x = 10 } = {}比較一下。

對于x的使用形式來說,如果第一個函數參數值被省略或者是undefined,會默認地使用空對象{}。然后,不管在第一個參數值的位置上是什么值 —— 要么是默認的{},要么是你傳入的 —— 都會被{ x = 10 }解構,它會檢查屬性x是否被找到,如果沒有找到(或者是undefined),默認值10會被設置到命名參數x上。

深呼吸。回過頭去把最后幾段多讀幾遍。讓我們用代碼復習一下:

function f6({ x = 10 } = {}, { y } = { y: 10 }) {
    console.log( x, y );
}

f6();                               // 10 10
f6( undefined, undefined );         // 10 10
f6( {}, undefined );                // 10 10

f6( {}, {} );                       // 10 undefined
f6( undefined, {} );                // 10 undefined

f6( { x: 2 }, { y: 3 } );           // 2 3

一般來說,與參數y的默認行為比起來,參數x的默認行為可能看起來更可取也更合理。因此,理解{ x = 10 } = {}形式與{ y } = { y: 10 }形式為何與如何不同是很重要的。

如果這仍然有點兒模糊,回頭再把它讀一遍,并親自把它玩弄一番。未來的你將會感謝你花了時間把這種非常微妙的,晦澀的細節的坑搞明白。

嵌套默認值:解構與重構

雖然一開始可能很難掌握,但是為一個嵌套的對象的屬性設置默認值產生了一種有趣的慣用法:將對象解構與一種我成為 重構 的東西一起使用。

考慮在一個嵌套的對象結構中的一組默認值,就像下面這樣:

// 摘自:http://es-discourse.com/t/partial-default-arguments/120/7

var defaults = {
    options: {
        remove: true,
        enable: false,
        instance: {}
    },
    log: {
        warn: true,
        error: true
    }
};

現在,我們假定你有一個稱為config的對象,它有一些這其中的值,但也許不全有,而且你想要將所有的默認值設置到這個對象的缺失點上,但不覆蓋已經存在的特定設置:

var config = {
    options: {
        remove: false,
        instance: null
    }
};

你當然可以手動這樣做,就像你可能曾經做過的那樣:

config.options = config.options || {};
config.options.remove = (config.options.remove !== undefined) ?
    config.options.remove : defaults.options.remove;
config.options.enable = (config.options.enable !== undefined) ?
    config.options.enable : defaults.options.enable;
...

討厭。

另一些人可能喜歡用覆蓋賦值的方式來完成這個任務。你可能會被ES6的Object.assign(..)工具(見第六章)所吸引,來首先克隆defaults中的屬性然后使用從config中克隆的屬性覆蓋它,像這樣:

config = Object.assign( {}, defaults, config );

這看起來好多了,是吧?但是這里有一個重大問題!Object.assign(..)是淺拷貝,這意味著當它拷貝defaults.options時,它僅僅拷貝這個對象的引用,而不是深度克隆這個對象的屬性到一個config.options對象。Object.assign(..)需要在你的對象樹的每一層中實施才能得到你期望的深度克隆。

注意: 許多JS工具庫/框架都為對象的深度克隆提供它們自己的選項,但是那些方式和它們的坑超出了我們在這里的討論范圍。

那么讓我們檢視一下ES6的帶有默認值的對象解構能否幫到我們:

config.options = config.options || {};
config.log = config.log || {};
({
    options: {
        remove: config.options.remove = defaults.options.remove,
        enable: config.options.enable = defaults.options.enable,
        instance: config.options.instance = defaults.options.instance
    } = {},
    log: {
        warn: config.log.warn = defaults.log.warn,
        error: config.log.error = defaults.log.error
    } = {}
} = config);

不像Object.assign(..)的虛假諾言(因為它只是淺拷貝)那么好,但是我想它要比手動的方式強多了。雖然它仍然很不幸地帶有冗余和重復。

前面的代碼段的方式可以工作,因為我黑進了結構和默認機制來為我做屬性的=== undefined檢查和賦值的決定。這里的技巧是,我解構了config(看看在代碼段末尾的= config),但是我將所有解構出來的值又立即賦值回config,帶著config.options.enable賦值引用。

但還是太多了。讓我們看看能否做得更好。

下面的技巧在你知道你正在解構的所有屬性的名稱都是唯一的情況下工作得最好。但即使不是這樣的情況你也仍然可以使用它,只是沒有那么好 —— 你將不得不分階段解構,或者創建獨一無二的本地變量作為臨時的別名。

如果我們將所有的屬性完全解構為頂層變量,那么我們就可以立即重構來重組原本的嵌套對象解構。

但是所有那些游蕩在外的臨時變量將會污染作用域。所以,讓我們通過一個普通的{ }包圍塊兒來使用塊兒作用域(參見本章早先的“塊兒作用域聲明”)。

// 將`defaults`混入`config`
{
    // 解構(使用默認值賦值)
    let {
        options: {
            remove = defaults.options.remove,
            enable = defaults.options.enable,
            instance = defaults.options.instance
        } = {},
        log: {
            warn = defaults.log.warn,
            error = defaults.log.error
        } = {}
    } = config;

    // 重構
    config = {
        options: { remove, enable, instance },
        log: { warn, error }
    };
}

這看起來好多了,是吧?

注意: 你也可以使用箭頭IIFE來代替一般的{ }塊兒和let聲明來達到圈占作用域的目的。你的解構賦值/默認值將位于參數列表中,而你的重構將位于函數體的return語句中。

在重構部分的{ warn, error }語法可能是你初次見到;它稱為“簡約屬性”,我們將在下一節講解它!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容