感謝社區中各位的大力支持,譯者再次奉上一點點福利:阿里云產品券,享受所有官網優惠,并抽取幸運大獎:點擊這里領取
模塊
我覺得這樣說并不夸張:在所有的JavaScript代碼組織模式中最重要的就是,而且一直是,模塊。對于我自己來說,而且我認為對廣大典型的技術社區來說,模塊模式驅動著絕大多數代碼。
過去的方式
傳統的模塊模式基于一個外部函數,它帶有內部變量和函數,以及一個被返回的“公有API”。這個“公有API”帶有對內部變量和功能擁有閉包的方法。它經常這樣表達:
function Hello(name) {
function greeting() {
console.log( "Hello " + name + "!" );
}
// 公有API
return {
greeting: greeting
};
}
var me = Hello( "Kyle" );
me.greeting(); // Hello Kyle!
這個Hello(..)
模塊通過被后續調用可以產生多個實例。有時,一個模塊為了作為一個單例(也就是,只需要一個實例)而只被調用一次,這樣的情況下常見的是一種前面代碼段的變種,使用IIFE:
var me = (function Hello(name){
function greeting() {
console.log( "Hello " + name + "!" );
}
// 公有API
return {
greeting: greeting
};
})( "Kyle" );
me.greeting(); // Hello Kyle!
這種模式是經受過檢驗的。它也足夠靈活,以至于在許多不同的場景下可以有大量的各種變化。
其中一種最常見的是異步模塊定義(AMD),另一種是統一模塊定義(UMD)。我們不會在這里涵蓋這些特定的模式和技術,但是它們在網上的許多地方有大量的講解。
向前邁進
在ES6中,我們不再需要依賴外圍函數和閉包來為我們提供模塊支持了。ES6模塊擁有頭等語法上和功能上的支持。
在我們接觸這些具體語法之前,重要的是要理解ES6模塊與你以前曾經用過的模塊比較起來,在概念上的一些相當顯著的不同之處:
-
ES6使用基于文件的模塊,這意味著一個模塊一個文件。目前,沒有標準的方法將多個模塊組合到一個文件中。
這意味著如果你要直接把ES6模塊加載到一個瀏覽器web應用中的話,你將個別地加載它們,不是像常見的那樣為了性能優化而作為一個單獨文件中的一個巨大的包加載。
預計同時期到來的HTTP/2將會大幅緩和這種性能上的顧慮,因為它工作在一個持續的套接字連接上,因而可以用并行的,互相交錯的方式非常高效地加載許多小文件。
-
一個ES6模塊的API是靜態的。這就是說,你在模塊的公有API上靜態地定義所有被導出的頂層內容,而這些內容導出之后不能被修改。
有些用法習慣于能夠提供動態API定義,它的方法可以根據運行時的條件被增加/刪除/替換。這些用法要么必須改變以適應ES6靜態API,要么它們就不得不將屬性/方法的動態修改限制在一個內層對象中。
ES6模塊都是單例。也就是,模塊只有一個維持它狀態的實例。每次你將這個模塊導入到另一個模塊時,你得到的都是一個指向中央實例的引用。如果你想要能夠產生多個模塊實例,你的模塊將需要提供某種工廠來這么做。
-
你在模塊的公有API上暴露的屬性和方法不是值和引用的普通賦值。它們是在你內部模塊定義中的標識符的實際綁定(幾乎就是指針)。
在前ES6的模塊中,如果你將一個持有像數字或者字符串這樣基本類型的屬性放在你的共有API中,那么這個屬性是通過值拷貝賦值的,任何對相應內部變量的更新都將是分離的,不會影響在API對象上的共有拷貝。
在ES6中,導出一個本地私有變量,即便它當前持有一個基本類型的字符串/數字/等等,導出的都是這個變量的一個綁定。如果這個模塊改變了這個變量的值,外部導入的綁定就會解析為那個新的值。
-
導入一個模塊和靜態地請求它被加載是同一件事情(如果它還沒被加載的話)。如果你在瀏覽器中,這意味著通過網絡的阻塞加載。如果你在服務器中,它是一個通過文件系統的阻塞加載。
但是,不要對它在性能的影響上驚慌。因為ES6模塊是靜態定義的,導入的請求可以被靜態地掃描,并提前加載,甚至是在你使用這個模塊之前。
ES6并沒有實際規定或操縱這些加載請求如何工作的機制。有一個模塊加載器的分離概念,它讓每一個宿主環境(瀏覽器,Node.js,等等)為該環境提供合適的默認加載器。一個模塊的導入使用一個字符串值來表示從哪里去取得模塊(URL,文件路徑,等等),但是這個值在你的程序中是不透明的,它僅對加載器自身有意義。
如果你想要比默認加載器提供的更細致的控制能力,你可以定義你自己的加載器 —— 默認加載器基本上不提供任何控制,它對于你的程序代碼是完全隱藏的。
如你所見,ES6模塊將通過封裝,控制共有API,以及應用依賴導入來服務于所有的代碼組織需求。但是它們用一種非常特別的方式來這樣做,這可能與你已經使用多年的模塊方式十分接近,也肯能差得很遠。
CommonJS
有一種相似,但不是完全兼容的模塊語法,稱為CommonJS,那些使用Node.js生態系統的人很熟悉它。
不太委婉地說,從長久看來,ES6模塊實質上將要取代所有先前的模塊格式與標準,即便是CommonJS,因為它們是建立在語言的語法支持上的。如果除了普遍性以外沒有其他原因,遲早ES6將不可避免地作為更好的方式勝出。
但是,要達到那一天我們還有相當長的路要走。在服務器端的JavaScript世界中差不多有成百上千的CommonJS風格模塊,而在瀏覽器的世界里各種格式標準的模塊(UMD,AMD,臨時性的模塊方案)數量還要多十倍。這要花許多年過渡才能取得任何顯著的進展。
在這個過渡期間,模塊轉譯器/轉換器將是絕對必要的。你可能剛剛適應了這種新的現實。不論你是使用正規的模塊,AMD,UMD,CommonJS,或者ES6,這些工具都不得不解析并轉換為適合你代碼運行環境的格式。
對于Node.js,這可能意味著(目前)轉換的目標是CommonJS。對于瀏覽器來說,可能是UMD或者AMD。除了在接下來的幾年中隨著這些工具的成熟和最佳實踐的出現而發生的許多變化。
從現在起,我能對模塊的提出的最佳建議是:不管你曾經由于強烈的愛好而虔誠地追隨哪一種格式,都要培養對理解ES6模塊的欣賞能力,并讓你對其他模塊模式的傾向性漸漸消失掉。它們就是JS中模塊的未來,即便現實有些偏差。
新的方式
使用ES6模塊的兩個主要的新關鍵字是import
和export
。在語法上有許多微妙的地方,那么讓我們深入地看看。
警告: 一個容易忽視的重要細節:import
和export
都必須總是出現在它們分別被使用之處的頂層作用域。例如,你不能把import
或export
放在一個if
條件內部;它們必須出現在所有塊兒和函數的外部。
export
API成員
export
關鍵字要么放在一個聲明的前面,要么就與一組特殊的要被導出的綁定一起用作一個操作符。考慮如下代碼:
export function foo() {
// ..
}
export var awesome = 42;
var bar = [1,2,3];
export { bar };
表達相同導出的另一種方法:
function foo() {
// ..
}
var awesome = 42;
var bar = [1,2,3];
export { foo, awesome, bar };
這些都稱為 命名導出,因為你實際上導出的是變量/函數/等等其他的名稱綁定。
任何你沒有使用export
標記 的東西將在模塊作用域的內部保持私有。也就是說,雖然有些像var bar = ..
的東西看起來像是在頂層全局作用域中聲明的,但是這個頂層作用域實際上是模塊本身;在模塊中沒有全局作用域。
注意: 模塊確實依然可以訪問掛在它外面的window
和所有的“全局”,只是不作為頂層詞法作用域而已。但是,你真的應該在你的模塊中盡可能地遠離全局。
你還可以在命名導出期間“重命名”(也叫別名)一個模塊成員:
function foo() { .. }
export { foo as bar };
當這個模塊被導入時,只有成員名稱bar
可以用于導入;foo
在模塊內部保持隱藏。
模塊導出不像你習以為常的=
賦值操作符那樣,僅僅是值或引用的普通賦值。實際上,當你導出某些東西時,你導出了一個對那個東西(變量等)的一個綁定(有些像指針)。
在你的模塊內部,如果你改變一個你已經被導出綁定的變量的值,即使它已經被導入了(見下一節),這個被導入的綁定也將解析為當前的(更新后的)值。
考慮如下代碼:
var awesome = 42;
export { awesome };
// 稍后
awesome = 100;
當這個模塊被導入時,無論它是在awesome = 100
設定的之前還是之后,一旦這個賦值發生,被導入的綁定都將被解析為值100
,不是42
。
這是因為,這個綁定實質上是一個指向變量awesome
本身的一個引用,或指針,而不是它的值的一個拷貝。ES6模塊綁定引入了一個對于JS來說幾乎是史無前例的概念。
雖然你顯然可以在一個模塊定義的內部多次使用export
,但是ES6絕對偏向于一個模塊只有一個單獨導出的方式,這稱為 默認導出。用TC39協會的一些成員的話說,如果你遵循這個模式你就可以“獲得更簡單的import
語法作為獎勵”,如果你不遵循你就會反過來得到更繁冗的語法作為“懲罰”。
一個默認導出將一個特定的導出綁定設置為在這個模塊被導入時的默認綁定。這個綁定的名稱是字面上的default
。正如你即將看到的,在導入模塊綁定時你還可以重命名它們,你經常會對默認導出這么做。
每個模塊定義只能有一個default
。我們將在下一節中講解import
,你將看到如果模塊擁有默認導入時import
語法如何變得更簡潔。
默認導出語法有一個微妙的細節你應當多加注意。比較這兩個代碼段:
function foo(..) {
// ..
}
export default foo;
和這一個:
function foo(..) {
// ..
}
export { foo as default };
在第一個代碼段中,你導出的是那一個函數表達式在那一刻的值的綁定,不是 標識符foo
的綁定。換句話說,export default ..
接收一個表達式。如果你稍后在你的模塊內部賦給foo
一個不同的值,這個模塊導入將依然表示原本被導出的函數,而不是那個新的值。
順帶一提,第一個代碼段還可以寫做:
export default function foo(..) {
// ..
}
警告: 雖然技術上講這里的function foo..
部分是一個函數表達式,但是對于模塊內部作用域來說,它被視為一個函數聲明,因為名稱foo
被綁定在模塊的頂層作用域(經常稱為“提升”)。對export default var foo = ..
也是如此。然而,雖然你 可以 export var foo = ..
,但是一個令人沮喪的不一致是,你目前還不能export default bar foo = ..
(或者let
和const
)。在寫作本書時,為了保持一致性,已經開始了在后ES6不久的時期增加這種能力的討論。
再次回想一下第二個代碼段:
function foo(..) {
// ..
}
export { foo as default };
這種版本的模塊導出中,默認導出的綁定實際上是標識符foo
而不是它的值,所以你會得到先前描述過的綁定行為(也就是,如果你稍后改變foo
的值,在導入一端看到的值也會被更新)。
要非常小心這種默認導出語法的微妙區別,特別是在你的邏輯需要導出的值要被更新時。如果你永遠不打算更新一個默認導出的值,export default ..
就沒問題。如果你確實打算更新這個值,你必須使用export { .. as default }
。無論哪種情況,都要確保注釋你的代碼以解釋你的意圖!
因為一個模塊只能有一個default
,這可能會誘使你將你的模塊設計為默認導出一個帶有你所有API方法的對象,就像這樣:
export default {
foo() { .. },
bar() { .. },
..
};
這種模式看起來十分接近于許多開發者構建它們的前ES6模塊時曾經用過的模式,所以它看起來像是一種十分自然的方式。不幸的是,它有一些缺陷并且不為官方所鼓勵使用。
特別是,JS引擎不能靜態地分析一個普通對象的內容,這意味著它不能為靜態import
性能進行一些優化。使每個成員獨立地并明確地導出的好處是,引擎 可以 進行靜態分析和性能優化。
如果你的API已經有多于一個的成員,這些原則 —— 一個模塊一個默認導出,和所有API成員作為被命名的導出 —— 看起來是沖突的,不是嗎?但是你 可以 有一個單獨的默認導出并且有其他的被命名導出;它們不是互相排斥的。
所以,取代這種(不被鼓勵使用的)模式:
export default function foo() { .. }
foo.bar = function() { .. };
foo.baz = function() { .. };
你可以這樣做:
export default function foo() { .. }
export function bar() { .. }
export function baz() { .. }
注意: 在前面這個代碼段中,我為標記為default
的函數使用了名稱foo
。但是,這個名稱foo
為了導出的目的而被忽略掉了 —— default
才是實際上被導出的名稱。當你導入這個默認綁定時,你可以叫它任何你想用的名字,就像你將在下一節中看到的。
或者,一些人喜歡:
function foo() { .. }
function bar() { .. }
function baz() { .. }
export { foo as default, bar, baz, .. };
混合默認和被命名導出的效果將在稍后我們講解import
時更加清晰。但它實質上意味著最簡潔的默認導入形式將僅僅取回foo()
函數。用戶可以額外地手動羅列bar
和baz
作為命名導入,如果他們想用它們的話。
你可能能夠想象,如果你的模塊有許多命名導出綁定,那么對于模塊的消費者來說將有多么乏味。有一個通配符導入形式,你可以在一個名稱空間對象中導入一個模塊的所有導出,但是沒有辦法用通配符導入到頂層綁定。
要重申的是,ES6模塊機制被有意設計為不鼓勵帶有許多導出的模塊;相對而言,它被期望成為一種更困難一些的,作為某種社會工程的方式,以鼓勵對大型/復雜模塊設計有利的簡單模塊設計。
我將可能推薦你不要將默認導出與命名導出混在一起,特別是當你有一個大型API,并且將它重構為分離的模塊是不現實或不希望的時候。在這種情況下,就都使用命名導出,并在文檔中記錄你的模塊的消費者可能應當使用import * as ..
(名稱空間導入,在下一節中討論)方式來將整個API一次性地帶到一個單獨的名稱空間中。
我們早先提到過這一點,但讓我們回過頭來更詳細地討論一下。除了導出一個表達式的值的綁定的export default ...
形式,所有其他的導出形式都導出本地標識符的綁定。對于這些綁定,如果你在導出之后改變一個模塊內部變量的值,外部被導入的綁定將可以訪問這個被更新的值:
var foo = 42;
export { foo as default };
export var bar = "hello world";
foo = 10;
bar = "cool";
當你導出這個模塊時,default
和bar
導出將會綁定到本地變量foo
和bar
,這意味著它們將反映被更新的值10
和"cool"
。在被導出時的值是無關緊要的。在被導入時的值是無關緊要的。這些綁定是實時的鏈接,所以唯一重要的是當你訪問這個綁定時它當前的值是什么。
警告: 雙向綁定是不允許的。如果你從一個模塊中導入一個foo
,并試圖改變你導入的變量foo
的值,一個錯誤就會被拋出!我們將在下一節重新回到這個問題。
你還可以重新導出另一個模塊的導出,比如:
export { foo, bar } from "baz";
export { foo as FOO, bar as BAR } from "baz";
export * from "baz";
這些形式都與首先從"baz"
模塊導入然后為了從你的模塊中到處而明確地羅列它的成員相似。然而,在這些形式中,模塊"baz"
的成員從沒有被導入到你的模塊的本地作用域;某種程度上,它們原封不動地穿了過去。
import
API成員
要導入一個模塊,你將不出意料地使用import
語句。就像export
有幾種微妙的變化一樣,import
也有,所以你要花相當多的時間來考慮下面的問題,并試驗你的選擇。
如果你想要導入一個模塊的API中的特定命名成員到你的頂層作用域,使用這種語法:
import { foo, bar, baz } from "foo";
警告: 這里的{ .. }
語法可能看起來像一個對象字面量,甚至是像一個對象解構語法。但是,它的形式僅對模塊而言是特殊的,所以不要將它與其他地方的{ .. }
模式搞混了。
字符串"foo"
稱為一個 模塊指示符。因為它的全部目的在于可以靜態分析的語法,所以模塊指示符必須是一個字符串字面量;它不能是一個持有字符串值的變量。
從你的ES6代碼和JS引擎本身的角度來看,這個字符串字面量的內容是完全不透明和沒有意義的。模塊加載器將會把這個字符串翻譯為一個在何處尋找被期望的模塊的指令,不是作為一個URL路徑就是一個本地文件系統路徑。
被羅列的標識符foo
,bar
和baz
必須匹配在模塊的API上的命名導出(這里將會發生靜態分析和錯誤斷言)。它們在你當前的作用域中被綁定為頂層標識符。
import { foo } from "foo";
foo();
你可以重命名被導入的綁定標識符,就像:
import { foo as theFooFunc } from "foo";
theFooFunc();
如果這個模塊僅有一個你想要導入并綁定到一個標識符的默認導出,你可以為這個綁定選擇性地跳過外圍的{ .. }
語法。在這種首選情況下import
會得到最好的最簡潔的import
語法形式:
import foo from "foo";
// 或者:
import { default as foo } from "foo";
注意: 正如我們在前一節中講解過的,一個模塊的export
中的default
關鍵字指定了一個名稱實際上為default
的命名導出,正如在第二個更加繁冗的語法中展示的那樣。在這個例子中,從default
到foo
的重命名在后者的語法中是明確的,并且與前者隱含地重命名是完全相同的。
如果模塊有這樣的定義,你還可以與其他的命名導出一起導入一個默認導出。回憶一下先前的這個模塊定義:
export default function foo() { .. }
export function bar() { .. }
export function baz() { .. }
要引入這個模塊的默認導出和它的兩個命名導出:
import FOOFN, { bar, baz as BAZ } from "foo";
FOOFN();
bar();
BAZ();
ES6的模塊哲學強烈推薦的方式是,你只從一個模塊中導入你需要的特定的綁定。如果一個模塊提供10個API方法,但是你只需它們中的兩個,有些人認為帶入整套API綁定是一種浪費。
一個好處是,除了代碼變得更加明確,收窄導入使得靜態分析和錯誤檢測(例如,不小心使用了錯誤的綁定名稱)變得更加健壯。
當然,這只是受ES6設計哲學影響的標準觀點;沒有什么東西要求我們堅持這種方式。
許多開發者可能很快指出這樣的方式更令人厭煩,每次你發現自己需要一個模塊中的其他某些東西時,它要求你經常地重新找到并更新你的import
語句。它的代價是犧牲便利性。
以這種觀點看,首選方式可能是將模塊中的所有東西都導入到一個單獨的名稱空間中,而不是將每個個別的成員直接導入到作用域中。幸運的是,import
語句擁有一個變種語法可以支持這種風格的模塊使用,它被稱為 名稱空間導入。
考慮一個被這樣導出的"foo"
模塊:
export function bar() { .. }
export var x = 42;
export function baz() { .. }
你可以將整個API導入到一個單獨的模塊名稱空間綁定中:
import * as foo from "foo";
foo.bar();
foo.x; // 42
foo.baz();
注意: * as ..
子句要求使用*
通配符。換句話說,你不能做像import { bar, x } as foo from "foo"
這樣的事情來將API的一部分綁定到foo
名稱空間。我會很喜歡這樣的東西,但是對ES6的名稱空間導入來說,要么全有要么全無。
如果你正在使用* as ..
導入的模塊擁有一個默認導出,它會在指定的名稱空間中被命名為default
。你可以在這個名稱空間綁定的外面,作為一個頂層標識符額外地命名這個默認導出。考慮一個被這樣導出的"world"
模塊:
export default function foo() { .. }
export function bar() { .. }
export function baz() { .. }
和這個import
:
import foofn, * as hello from "world";
foofn();
hello.default();
hello.bar();
hello.baz();
雖然這個語法是合法的,但是它可能令人困惑:這個模塊的一個方法(那個默認導出)被綁定到你作用域的頂層,然而其他的命名導出(而且之中之一稱為default
)作為一個不同名稱(hello
)的標識符名稱空間的屬性被綁定。
正如我早先提到的,我的建議是避免這樣設計你的模塊導出,以降低你模塊的用戶受困于這些奇異之處的可能性。
所有被導入的綁定都是不可變和/或只讀的。考慮前面的導入;所有這些后續的賦值嘗試都將拋出TypeError
:
import foofn, * as hello from "world";
foofn = 42; // (運行時)TypeError!
hello.default = 42; // (運行時)TypeError!
hello.bar = 42; // (運行時)TypeError!
hello.baz = 42; // (運行時)TypeError!
回憶早先在“export
API成員”一節中,我們談到bar
和baz
綁定是如何被綁定到"world"
模塊內部的實際標識符上的。它意味著如果模塊改變那些值,hello.bar
和hello.baz
將引用更新后的值。
但是你的本地導入綁定的不可變/只讀的性質強制你不能從被導入的綁定一方改變他們,不然就會發生TypeError
。這很重要,因為如果沒有這種保護,你的修改將會最終影響所有其他該模塊的消費者(記住:單例),這可能會產生一些非常令人吃驚的副作用!
另外,雖然一個模塊 可以 從內部改變它的API成員,但你應當對有意地以這種風格設計你的模塊非常謹慎。ES6模塊 被預計 是靜態的,所以背離這個原則應當是不常見的,而且應當在文檔中被非常小心和詳細地記錄下來。
警告: 存在一些這樣的模塊設計思想,你實際上打算允許一個消費者改變你的API上的一個屬性的值,或者模塊的API被設計為可以通過向API的名稱空間中添加“插件”來“擴展”。但正如我們剛剛斷言的,ES6模塊API應當被認為并設計為靜態的和不可變的,這強烈地約束和不鼓勵那些其他的模塊設計模式。你可以通過導出一個普通對象 —— 它理所當然是可以隨意改變的 —— 來繞過這些限制。但是在選擇這條路之前要三思而后行。
作為一個import
的結果發生的聲明將被“提升”(參見本系列的 作用域與閉包)。考慮如下代碼:
foo();
import { foo } from "foo";
foo()
可以運行是因為import ..
語句的靜態解析不僅在編譯時搞清了foo
是什么,它還將這個聲明“提升”到模塊作用域的頂部,如此使它在模塊中通篇都是可用的。
最后,最基本的import
形式看起來像這樣:
import "foo";
這種形式實際上不會將模塊的任何綁定導入到你的作用域中。它加載(如果還沒被加載過),編譯(如果還沒被編譯過),并對"foo"
模塊求值(如果還沒被運行過)。
一般來說,這種導入可能不會特別有用。可能會有一些模塊的定義擁有副作用(比如向window
/全局對象賦值)的特殊情況。你還可以將import "foo"
用作稍后可能需要的模塊的預加載。
模塊循環依賴
A導入B。B導入A。這將如何工作?
我要立即聲明,一般來說我會避免使用刻意的循環依賴來設計系統。話雖如此,我也認識到人們這么做是有原因的,而且它可以解決一些艱難的設計問題。
讓我們考慮一下ES6如何處理這種情況。首先,模塊"A"
:
import bar from "B";
export default function foo(x) {
if (x > 10) return bar( x - 1 );
return x * 2;
}
現在,是模塊"B"
:
import foo from "A";
export default function bar(y) {
if (y > 5) return foo( y / 2 );
return y * 3;
}
這兩個函數,foo(..)
和bar(..)
,如果它們在相同的作用域中就會像標準的函數聲明那樣工作,因為聲明被“提升”至整個作用域,而因此與它們的編寫順序無關,它們互相是可用的。
在模塊中,你的聲明在完全不同的作用域中,所以ES6必須做一些額外的工作以使這些循環引用工作起來。
在大致的概念上,這就是循環的import
依賴如何被驗證和解析的:
如果模塊
"A"
被首先加載,第一步將是掃描文件并分析所有的導出,這樣就可以為導入注冊所有可用的綁定。然后它處理import .. from "B"
,這指示它需要去取得"B"
。一旦引擎加載了
"B"
,它會做同樣的導出綁定分析。當它看到import .. from "A"
時,它知道"A"
的API已經準備好了,所以它可以驗證這個import
為合法的。現在它知道了"B"
的API,它也可以驗證在模塊"A"
中等待的import .. from "B"
了。
實質上,這種相互導入,連同對兩個import
語句合法性的靜態驗證,虛擬地組合了兩個分離的模塊作用域(通過綁定),因此foo(..)
可以調用bar(..)
或相反。這與我們在相同的作用域中聲明是對稱的。
現在然我們試著一起使用這兩個模塊。首先,我們將試用foo(..)
:
import foo from "foo";
foo( 25 ); // 11
或者我們可以試用bar(..)
:
import bar from "bar";
bar( 25 ); // 11.5
在foo(25)
調用bar(25)
被執行的時刻,所有模塊的所有分析/編譯都已經完成了。這意味著foo(..)
內部地直接知道bar(..)
,而且bar(..)
內部地直接知道foo(..)
。
如果所有我們需要的僅是與foo(..)
互動,那么我們只需要導入"foo"
模塊。bar(..)
和"bar"
模塊也同理。
當然,如果我們想,我們 可以 導入并使用它們兩個:
import foo from "foo";
import bar from "bar";
foo( 25 ); // 11
bar( 25 ); // 11.5
import
語句的靜態加載語義意味著通過import
互相依賴對方的"foo"
和"bar"
將確保在它們運行前被加載,解析,和編譯。所以它們的循環依賴是被靜態地解析的,而且將會如你所愿地工作。
模塊加載
我們在“模塊”這一節的最開始聲稱,import
語句使用了一個由宿主環境(瀏覽器,Node.js,等等)提供的分離的機制,來實際地將模塊指示符字符串解析為一些對尋找和加載所期望模塊的有用的指令。這種機制就是系統 模塊加載器。
由環境提供的默認模塊加載器,如果是在瀏覽器中將會把模塊指示符解釋為一個URL,如果是在服務器端(一般地)將會解釋為一個本地文件系統路徑,比如Node.js。它的默認行為是假定被加載的文件是以ES6標準的模塊格式編寫的。
另外,與當下腳本程序被加載的方式相似,你將可以通過一個HTML標簽將一個模塊加載到瀏覽器中。在本書寫作時,這個標簽將會是<script type="module">
還是<module>
還不完全清楚。ES6沒有控制這個決定,但是在相應的標準化機構中的討論早已隨著ES6開始了。
無論這個標簽看起來什么樣,你可以確信它的內部將會使用默認加載器(或者一個你預先指定好的加載器,就像我們將在下一節中討論的)。
就像你將在標記中使用的標簽一樣,ES6沒有規定模塊加載器本身。它是一個分離的,目前由WHATWG瀏覽器標準化小組控制的平行的標準。(http://whatwg.github.io/loader/)
在本書寫作時,接下來的討論反映了它的API設計的一個早期版本,和一些可能將要改變的東西。
加載模塊之外的模塊
一個與模塊加載器直接交互的用法,是當一個非模塊需要加載一個模塊時。考慮如下代碼:
// 在瀏覽器中通過`<script>`加載的普通script,
// `import`在這里是不合法的
Reflect.Loader.import( "foo" ) // 返回一個`"foo"`的promise
.then( function(foo){
foo.bar();
} );
工具Reflect.Loader.import(..)
將整個模塊導入到命名參數中(作為一個名稱空間),就像我們早先討論過的import * as foo ..
名稱空間導入。
注意: Reflect.Loader.import(..)
返回一個promise,它在模塊準備好時被完成。要導入多個模塊的話,你可以使用Promise.all([ .. ])
將多個Reflect.Loader.import(..)
的promise組合起來。有關Promise的更多信息,參見第四章的“Promise”。
你還可以在一個真正的模塊中使用Reflect.Loader.import(..)
來動態地/條件性地加載一個模塊,這是import
自身無法做到的。例如,你可能在一個特性測試表明某個ES7+特性沒有被當前的引擎所定義的情況下,選擇性地加載一個含有此特性的填補的模塊。
由于性能的原因,你將想要盡量避免動態加載,因為它阻礙了JS引擎從它的靜態分析中提前獲取的能力。
自定義加載
直接與模塊加載器交互的另外一種用法是,你想要通過配置或者甚至是重定義來定制它的行為。
在本書寫作時,有一個被開發好的模塊加載器API的填補(https://github.com/ModuleLoader/es6-module-loader)。雖然關于它的細節非常匱乏,而且很可能改變,但是我們可以通過它來探索最終可能固定下來的東西是什么。
Reflect.Loader.import(..)
調用可能會支持第二個參數,它指定各種選項來定制導入/加載任務。例如:
Reflect.Loader.import( "foo", { address: "/path/to/foo.js" } )
.then( function(foo){
// ..
} )
還有一種預期是,會為一個自定義內容提供某種機制來將之掛鉤到模塊加載的處理過程中,就在翻譯/轉譯可能發生的加載之后,但是在引擎編譯這個模塊之前。
例如,你可能會加載某些還不是ES6兼容的模塊格式的東西(例如,CoffeeScript,TypeScript,CommonJS,AMD)。你的翻譯步驟可能會為了后面的引擎處理而將它轉換為ES6兼容的模塊。
類
幾乎從JavaScript的最開始的那時候起,語法和開發模式都曾努力(讀作:掙扎地)地戴上一個支持面向類的開發的假面具。伴隨著new
和instanceof
和一個.constructor
屬性,誰能不認為JS在它的原型系統的某個地方藏著類機制呢?
當然,JS的“類”與經典的類完全不同。其區別有很好的文檔記錄,所以在此我不會在這一點上花更多力氣。
注意: 要學習更多關于在JS中假冒“類”的模式,以及另一種稱為“委托”的原型的視角,參見本系列的 this與對象原型 的后半部分。
class
雖然JS的原型機制與傳統的類的工作方式不同,但是這并不能阻擋一種強烈的潮流 —— 要求這門語言擴展它的語法糖以便將“類”表達得更像真正的類。讓我們進入ES6class
關鍵字和它相關的機制。
這個特性是一個具有高度爭議、曠日持久的爭論的結果,而且代表了幾種對關于如何處理JS類的強烈反對意見的妥協的一小部分。大多數希望JS擁有完整的類機制的開發者將會發現新語法的一些部分十分吸引人,但是也會發現一些重要的部分仍然缺失了。但不要擔心,TC39已經致力于另外的特性,以求在后ES6時代中增強類機制。
新的ES6類機制的核心是class
關鍵字,它標識了一個 塊,其內容定義了一個函數的原型的成員。考慮如下代碼:
class Foo {
constructor(a,b) {
this.x = a;
this.y = b;
}
gimmeXY() {
return this.x * this.y;
}
}
一些要注意的事情:
-
class Foo
暗示著創建一個(特殊的)名為Foo
的函數,與你在前ES6中所做的非常相似。 -
constructor(..)
表示了這個Foo(..)
函數的簽名,和它的函數體內容。 - 類方法同樣使用對象字面量中可以使用的“簡約方法”語法,正如在第二章中討論過的。這也包括在本章早先討論過的簡約generator,以及ES5的getter/setter語法。但是,類方法是不可枚舉的而對象方法默認是可枚舉的。
- 與對象字面量不同的是,在一個
class
內容的部分沒有逗號分隔各個成員!事實上,這甚至是不允許的。
前一個代碼段的class
語法定義可以大致認為和這個前ES6等價物相同,對于那些以前做過原型風格代碼的人來說可能十分熟悉它:
function Foo(a,b) {
this.x = a;
this.y = b;
}
Foo.prototype.gimmeXY = function() {
return this.x * this.y;
}
不管是前ES6形式還是新的ES6class
形式,這個“類”現在可以被實例化并如你所想地使用了:
var f = new Foo( 5, 15 );
f.x; // 5
f.y; // 15
f.gimmeXY(); // 75
注意!雖然class Foo
看起來很像function Foo()
,但是有一些重要的區別:
-
class Foo
的一個Foo(..)
調用 必須 與new
一起使用,因為前ES6的Foo.call( obj )
方式 不能 工作。 - 雖然
function Foo
會被“提升”(參見本系列的 作用域與閉包),但是class Foo
不會;extends ..
指定的表達式不能被“提升”。所以,在你能夠實例化一個class
之前必須先聲明它。 - 在頂層全局作用域中的
class Foo
在這個作用域中創建了一個詞法標識符Foo
,但與此不同的是function Foo
不會創建一個同名的全局對象屬性。
已經建立的instanceof
操作仍然可以與ES6的類一起工作,因為class
只是創建了一個同名的構造器函數。然而,ES6引入了一個定制instanceof
如何工作的方法,使用Symbol.hasInstance
(參見第七章的“通用Symbol”)。
我發現另一種更方便地考慮class
的方法是,將它作為一個用來自動填充proptotype
對象的 宏。可選的是,如果使用extends
(參見下一節)的話它還能連接[[Prototype]]
關系。
其實一個ES6class
本身不是一個實體,而是一個元概念,它包裹在其他具體實體上,例如函數和屬性,并將它們綁在一起。
提示: 除了這種聲明的形式,一個class
還可以是一個表達式,就像:var x = class Y { .. }
。這主要用于將類的定義(技術上說,是構造器本身)作為函數參數值傳遞,或者將它賦值給一個對象屬性。
extends
和 super
ES6的類還有一種語法糖,用于在兩個函數原型之間建立[[Prototype]]
委托鏈 —— 通常被錯誤地標記為“繼承”或者令人困惑地標記為“原型繼承” —— 使用我們熟悉的面向類的術語extends
:
class Bar extends Foo {
constructor(a,b,c) {
super( a, b );
this.z = c;
}
gimmeXYZ() {
return super.gimmeXY() * this.z;
}
}
var b = new Bar( 5, 15, 25 );
b.x; // 5
b.y; // 15
b.z; // 25
b.gimmeXYZ(); // 1875
一個有重要意義的新增物是super
,它實際上在前ES6中不是直接可能的東西(不付出一些不幸的黑科技的代價的話)。在構造器中,super
自動指向“父構造器”,這在前一個例子中是Foo(..)
。在方法中,它指向“父對象”,如此你就可以訪問它上面的屬性/方法,比如super.gimmeXY()
。
Bar extends Foo
理所當然地意味著將Bar.prototype
的[[Prototype]]
鏈接到Foo.prototype
。所以,在gimmeXYZ()
這樣的方法中的super
特被地意味著Foo.prototype
,而當super
用在Bar
構造器中時意味著Foo
。
注意: super
不僅限于class
聲明。它也可以在對象字面量中工作,其方式在很大程度上與我們在此討論的相同。更多信息參見第二章中的“對象super
”。
super
的坑
注意到super
的行為根據它出現的位置不同而不同是很重要的。公平地說,大多數時候這不是一個問題。但是如果你背離一個狹窄的規范,令人詫異的事情就會等著你。
可能會有這樣的情況,你想在構造器中引用Foo.prototype
,比如直接訪問它的屬性/方法之一。然而,在構造器中的super
不能這樣被使用;super.prototype
將不會工作。super(..)
大致上意味著調用new Foo(..)
,但它實際上不是一個可用的對Foo
本身的引用。
與此對稱的是,你可能想要在一個非構造器方法中引用Foo(..)
函數。super.constructor
將會指向Foo(..)
函數,但是要小心這個函數 只能 與new
一起被調用。new super.constructor(..)
將是合法的,但是在大多數情況下它都不是很有用, 因為你不能使這個調用使用或引用當前的this
對象環境,而這很可能是你想要的。
另外,super
看起來可能就像this
一樣是被函數的環境所驅動的 —— 也就是說,它們都是被動態綁定的。但是,super
不像this
那樣是動態的。當聲明時一個構造器或者方法在它內部使用一個super
引用時(在class
的內容部分),這個super
是被靜態地綁定到這個指定的類階層中的,而且不能被覆蓋(至少是在ES6中)。
這意味著什么?這意味著如果你習慣于從一個“類”中拿來一個方法并通過覆蓋它的this
,比如使用call(..)
或者apply(..)
,來為另一個類而“借用”它的話,那么當你借用的方法中有一個super
時,將很有可能發生令你詫異的事情。考慮這個類階層:
class ParentA {
constructor() { this.id = "a"; }
foo() { console.log( "ParentA:", this.id ); }
}
class ParentB {
constructor() { this.id = "b"; }
foo() { console.log( "ParentB:", this.id ); }
}
class ChildA extends ParentA {
foo() {
super.foo();
console.log( "ChildA:", this.id );
}
}
class ChildB extends ParentB {
foo() {
super.foo();
console.log( "ChildB:", this.id );
}
}
var a = new ChildA();
a.foo(); // ParentA: a
// ChildA: a
var b = new ChildB(); // ParentB: b
b.foo(); // ChildB: b
在前面這個代碼段中一切看起來都相當自然和在意料之中。但是,如果你試著借來b.foo()
并在a
的上下文中使用它的話 —— 通過動態this
綁定的力量,這樣的借用十分常見而且以許多不同的方式被使用,包括最明顯的mixin —— 你可能會發現這個結果出奇地難看:
// 在`a`的上下文環境中借用`b.foo()`
b.foo.call( a ); // ParentB: a
// ChildB: a
如你所見,引用this.id
被動態地重綁定所以在兩種情況下都報告: a
而不是: b
。但是b.foo()
的super.foo()
引用沒有被動態重綁定,所以它依然報告ParentB
而不是期望的ParentA
。
因為b.foo()
引用super
,所以它被靜態地綁定到了ChildB
/ParentB
階層而不能被用于ChildA
/ParentA
階層。在ES6中沒有辦法解決這個限制。
如果你有一個不帶移花接木的靜態類階層,那么super
的工作方式看起來很直觀。但公平地說,實施帶有this
的編碼的一個主要好處正是這種靈活性。簡單地說,class
+ super
要求你避免使用這樣的技術。
你能在對象設計上作出的選擇歸結為兩個:使用這些靜態的階層 —— class
,extends
,和super
將十分不錯 —— 要么放棄所有“山寨”類的企圖,而接受動態且靈活的,沒有類的對象和[[Prototype]]
委托(參見本系列的 this與對象原型)。
子類構造器
對類或子類來說構造器不是必需的;如果構造器被省略,這兩種情況下都會有一個默認構造器頂替上來。但是,對于一個直接的類和一個被擴展的類來說,頂替上來的默認構造器是不同的。
特別地,默認的子類構造器自動地調用父構造器,并且傳遞所有參數值。換句話說,你可以認為默認的子類構造器有些像這樣:
constructor(...args) {
super(...args);
}
這是一個需要注意的重要細節。不是所有支持類的語言的子類構造器都會自動地調用父構造器。C++會,但Java不會。更重要的是,在前ES6的類中,這樣的自動“父構造器”調用不會發生。如果你曾經依賴于這樣的調用 不會 發生,按么當你將代碼轉換為ES6class
時就要小心。
ES6子類構造器的另一個也許令人吃驚的偏差/限制是:在一個子類的構造器中,在super(..)
被調用之前你不能訪問this
。其中的原因十分微妙和復雜,但是可以歸結為是父構造器在實際上創建/初始化你的實例的this
。前ES6中,它相反地工作;this
對象被“子類構造器”創建,然后你使用這個“子類”的this
上下文環境調用“父構造器”。
讓我們展示一下。這是前ES6版本:
function Foo() {
this.a = 1;
}
function Bar() {
this.b = 2;
Foo.call( this );
}
// `Bar` “擴展” `Foo`
Bar.prototype = Object.create( Foo.prototype );
但是這個ES6等價物不允許:
class Foo {
constructor() { this.a = 1; }
}
class Bar extends Foo {
constructor() {
this.b = 2; // 在`super()`之前不允許
super(); // 可以通過調換這兩個語句修正
}
}
在這種情況下,修改很簡單。只要在子類Bar
的構造器中調換兩個語句的位置就行了。但是,如果你曾經依賴于前ES6可以跳過“父構造器”調用的話,就要小心這不再被允許了。
extend
原生類型
新的class
和extend
設計中最值得被歡呼的好處之一,就是(終于!)能夠為內建原生類型,比如Array
,創建子類。考慮如下代碼:
class MyCoolArray extends Array {
first() { return this[0]; }
last() { return this[this.length - 1]; }
}
var a = new MyCoolArray( 1, 2, 3 );
a.length; // 3
a; // [1,2,3]
a.first(); // 1
a.last(); // 3
在ES6之前,可以使用手動的對象創建并將它鏈接到Array.prototype
來制造一個Array
的“子類”的山寨版,但它僅能部分地工作。它缺失了一個真正數組的特殊行為,比如自動地更新length
屬性。ES6子類應該可以如我們盼望的那樣使用“繼承”與增強的行為來完整地工作!
另一個常見的前ES6“子類”的限制與Error
對象有關,在創建自定義的錯誤“子類”時。當純粹的Error
被創建時,它們自動地捕獲特殊的stack
信息,包括錯誤被創建的行號和文件。前ES6的自定義錯誤“子類”沒有這樣的特殊行為,這嚴重地限制了它們的用處。
ES6前來拯救:
class Oops extends Error {
constructor(reason) {
super(reason);
this.oops = reason;
}
}
// 稍后:
var ouch = new Oops( "I messed up!" );
throw ouch;
前面代碼段的ouch
自定義錯誤對象將會向任何其他的純粹錯誤對象那樣動作,包括捕獲stack
。這是一個巨大的改進!
new.target
ES6引入了一個稱為 元屬性 的新概念(見第七章),用new.target
的形式表示。
如果這看起來很奇怪,是的;將一個帶有.
的關鍵字與一個屬性名配成一對,對JS來說絕對是不同尋常的模式。
new.target
是一個在所有函數中可用的“魔法”值,雖然在普通的函數中它總是undefined
。在任意的構造器中,new.target
總是指向new
實際直接調用的構造器,即便這個構造器是在一個父類中,而且是通過一個在子構造器中的super(..)
調用被委托的。
class Foo {
constructor() {
console.log( "Foo: ", new.target.name );
}
}
class Bar extends Foo {
constructor() {
super();
console.log( "Bar: ", new.target.name );
}
baz() {
console.log( "baz: ", new.target );
}
}
var a = new Foo();
// Foo: Foo
var b = new Bar();
// Foo: Bar <-- 遵照`new`的調用點
// Bar: Bar
b.baz();
// baz: undefined
new.target
元屬性在類構造器中沒有太多作用,除了訪問一個靜態屬性/方法(見下一節)。
如果new.target
是undefined
,那么你就知道這個函數不是用new
調用的。然后你就可以強制一個new
調用,如果有必要的話。
static
當一個子類Bar
擴展一個父類Foo
時,我們已經觀察到Bar.prototype
被[[Prototype]]
鏈接到Foo.prototype
。但是額外地,Bar()
被[[Prototype]]
鏈接到Foo()
。這部分可能就沒有那么明顯了。
但是,在你為一個類聲明static
方法(不只是屬性)時它就十分有用,因為這些靜態方法被直接添加到這個類的函數對象上,不是函數對象的prototype
對象上。考慮如下代碼:
class Foo {
static cool() { console.log( "cool" ); }
wow() { console.log( "wow" ); }
}
class Bar extends Foo {
static awesome() {
super.cool();
console.log( "awesome" );
}
neat() {
super.wow();
console.log( "neat" );
}
}
Foo.cool(); // "cool"
Bar.cool(); // "cool"
Bar.awesome(); // "cool"
// "awesome"
var b = new Bar();
b.neat(); // "wow"
// "neat"
b.awesome; // undefined
b.cool; // undefined
小心不要被搞糊涂,認為static
成員是在類的原型鏈上的。它們實際上存在與函數構造器中間的一個雙重/平行鏈條上。
Symbol.species
構造器Getter
一個static
可以十分有用的地方是為一個衍生(子)類設置Symbol.species
getter(在語言規范內部稱為@@species
)。這種能力允許一個子類通知一個父類應當使用什么樣的構造器 —— 當不打算使用子類的構造器本身時 —— 如果有任何父類方法需要產生新的實例的話。
舉個例子,在Array
上的許多方法都創建并返回一個新的Array
實例。如果你從Array
定義一個衍生的類,但你想讓這些方法實際上繼續產生Array
實例,而非從你的衍生類中產生實例,那么這就可以工作:
class MyCoolArray extends Array {
// 強制`species`為父類構造器
static get [Symbol.species]() { return Array; }
}
var a = new MyCoolArray( 1, 2, 3 ),
b = a.map( function(v){ return v * 2; } );
b instanceof MyCoolArray; // false
b instanceof Array; // true
為了展示一個父類方法如何可以有些像Array#map(..)
所做的那樣,使用一個子類型聲明,考慮如下代碼:
class Foo {
// 將`species`推遲到衍生的構造器中
static get [Symbol.species]() { return this; }
spawn() {
return new this.constructor[Symbol.species]();
}
}
class Bar extends Foo {
// 強制`species`為父類構造器
static get [Symbol.species]() { return Foo; }
}
var a = new Foo();
var b = a.spawn();
b instanceof Foo; // true
var x = new Bar();
var y = x.spawn();
y instanceof Bar; // false
y instanceof Foo; // true
父類的Symbol.species
使用return this
來推遲到任意的衍生類,就像你通常期望的那樣。然后Bar
手動地聲明Foo
被用于這樣的實例創建。當然,一個衍生的類依然可以使用new this.constructor(..)
生成它本身的實例。
復習
ES6引入了幾個在代碼組織上提供幫助的新特性:
- 迭代器提供了對數據和操作的序列化訪問。它們可以被
for..of
和...
這樣的新語言特性消費。 - Generator是由一個迭代器控制的能夠在本地暫停/繼續的函數。它們可以被用于程序化地(并且是互動地,通過
yield
/next(..)
消息傳遞) 生成 通過迭代器被消費的值。 - 模塊允許實現的細節的私有封裝帶有一個公開導出的API。模塊定義是基于文件的,單例的實例,并且在編譯時靜態地解析。
- 類為基于原型的編碼提供了更干凈的語法。
super
的到來也解決了在[[Prototype]]
鏈中進行相對引用的刁鉆問題。
在你考慮通過采納ES6來改進你的JS項目體系結構時,這些新工具應當是你的第一站。