【翻譯】深入理解ES6的模組

Shenfq發表於2017-11-13

回想2007年,那時候我剛加入Mozilla's JavaScript團隊,那時候的一個典型的JavaScript程式只需要一行程式碼,聽起來像個笑話。

兩年後,Google Maps釋出。在這之前,JavaScript主要用來做表單的驗證,你用來處理<input onchange=>這個程式當然只需要一行。

時過境遷,JavaScript專案已經發展到讓人歎為觀止,社群湧現了許多幫助開發的工具。但是最迫切需要的是一個模組系統,它能將你的工作分散到不同的檔案與目錄中,在需要的時候他們能彼此之間相互訪問,並且可以有效的載入所有程式碼。所以JavaScript有模組系統這很正常,而且還有多個模組系統(CommonJS、AMD、CMD、UMD)。不僅如此,它還有幾個包管理器(npm、bower),用來安裝軟體還能拷貝一些深度依賴。你可能認為ES6和它的新模組系統出現得有點晚。

那我們來看看ES6為現存的模組系統新增了什麼,以及未來的標準和工具能否建立在這個系統上。首先,讓我們看看ES6的模組是什麼樣子的。

模組的基礎知識

ES6模組是一個包含了JS程式碼的檔案。沒有所謂的module關鍵詞,一個模組看起來和一個指令碼檔案沒什麼不一樣,除了一下兩個區別:

  • ES6的模組自動開啟嚴格模式,即使你沒有寫"use strict";
  • 在模組中,你可以使用importexprot

先來談談export。在預設情況下,模組中所有的宣告都是私有的,如果你希望模組中的某些宣告是公開的,並在其他模組中使用它們,你就必須匯出它們。這裡有一些實現方法,最簡單的是新增export關鍵字

// kittydar.js - Find the locations of all the cats in an image.
// (Heather Arthur wrote this library for real)
// (but she didn't use modules, because it was 2013)

export function detectCats(canvas, options) {
  var kittydar = new Kittydar(options);
  return kittydar.detectCats(canvas);
}

export class Kittydar {
  ... several methods doing image processing ...
}

// This helper function isn't exported.
function resizeCanvas() {
  ...
}
...複製程式碼

你可以export任何的頂級變數:functionclassvarletconst

你如果要寫一個模組知道這麼多就夠了!你不必再把所有的東西放到一個立即執行函式或者回撥函式裡面,只需要在你需要的地方進行宣告。由於這個程式碼是一個模組,而不是一個指令碼,所有的宣告的作用域都只屬於這個模組,而不是所有指令碼和模組都能全域性訪問的。你只要把模組中的宣告匯出成一組公共模組的API就足夠了。

除了匯出,模組裡的程式碼和其他普通程式碼沒有什麼區別。它可以訪問全域性變數,像ObjectArray。如果你的模組在瀏覽器執行,還能夠使用documentXMLHttpRequest

在另一個檔案中,我們可以匯入並使用detectCats()函式:

// demo.js - Kittydar demo program

import {detectCats} from "kittydar.js";

function go() {
    var canvas = document.getElementById("catpix");
    var cats = detectCats(canvas);
    drawRectangles(canvas, cats);
}複製程式碼

要從一個模組匯入多個變數,你可以這樣寫:

import {detectCats, Kittydar} from "kittydar.js";複製程式碼

當你執行一個包含import宣告的模組,會先匯入要匯入的模組並載入,然後根據深度優先的原則遍歷依賴圖譜來執行對應模組,並跳過已經執行的模組,來避免迴圈。

這就是模組基礎知識,這真的很簡單。;-)

匯出列表

你可以把你要匯出的功能名寫在一個列表裡,然後用大括號括起來,這樣就不用在每個要匯出的功能前面加上export標記。

 export {detectCats, Kittydar};

// no `export` keyword required here
function detectCats(canvas, options) { ... }
class Kittydar { ... }複製程式碼

匯出列表並不需要寫在檔案的第一行,它可以出現在模組檔案的頂級作用域的任何位置。你可以有多個匯出列表,或者將匯出列表與匯出宣告混合使用,只要不重複匯出同一個變數名就行。

重新命名匯出和匯入

有時,匯入的變數名碰巧與你需要使用的一些變數名衝突了,ES6允許你重新命名匯入的變數。

// suburbia.js

// Both these modules export something named `flip`.
// To import them both, we must rename at least one.
import {flip as flipOmelet} from "eggs.js";
import {flip as flipHouse} from "real-estate.js";
...複製程式碼

同樣,你在匯出變數的時候也可以重新命名它們。這在你想使用不同名字匯出相同功能的時候十分方便。

// unlicensed_nuclear_accelerator.js - media streaming without drm
// (not a real library, but maybe it should be)

function v1() { ... }
function v2() { ... }

export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};複製程式碼

預設的匯出

新的標準在設計上是相容已經存在的CommonJS和AMD模組的。如果你有一個Node專案,並且你已經執行了npm install lodash。你使用ES6程式碼能夠單獨引入Lodash中的函式:

import {each, map} from "lodash";

each([3, 2, 1], x => console.log(x));複製程式碼

如果你已經習慣使用_.each而不是each,你依然想像以前一樣使用它。或者, 你想把_當成一個函式來使用,因為這才是Lodash。

這種情況下,你只要稍微改變下你的寫法:不使用花括號來匯入模組。

import _ from "lodash";複製程式碼

這個寫法等同於import {default as _} from "lodash";。所有的CommonJS 和AMD模組在ES6中都能被當作default匯出,這個匯出和你在CommonJS中使用require()匯出得到東西一樣,即exports物件。

ES6模組在設計上可以讓你匯出更多的東西,但對於現在的CommonJS模組,匯出的default模組就是能匯出的全部東西了。例如,在寫這篇文章時,據我所知,著名的colors模組沒有特意去支援ES6語法,這是一個CommonJS模組組成的包,就像npm上的那些包一樣,但是你可以直接引入到你的ES6程式碼中。

// ES6 equivalent of `var colors = require("colors/safe");`
import colors from "colors/safe";複製程式碼

如果你希望自己ES6模組也具有預設匯出,這很簡單。預設的匯出方式並沒有什麼魔力;他就像其他匯出一樣,除了它的匯出名為default。你可以使用我們之前提到的重新命名語法:

let myObject = {
  field1: value1,
  field2: value2
};
export {myObject as default};複製程式碼

或者使用簡寫:

export default {
  field1: value1,
  field2: value2
};複製程式碼

export default關鍵詞後面可以跟任何值:一個函式、一個類、一個物件,所有能被命名的變數。

模組物件

不好意思,這篇文章有點長。JavaScript並不孤獨:因為一些原因,所有的語言中都有模組系統,並且傾向於設計大量雜亂而又無聊的小特性。幸運的是我們只剩下一個話題,噢,不對,是兩個。

import * as cows from "cows";複製程式碼

當你使用import *的時候,被引入的是一個模組名稱空間物件(module namespace object),它的屬性是模組的輸出。如果“cows”模組匯出一個名為moo()的函式,那麼在匯入“cows”之後,你可以使用cows.moo()來進行呼叫。

聚合模組

有時候一個包的主模組只不過是匯入包其他所有的模組,並用統一的方式匯出。為了簡化這種程式碼,有一種將匯入匯出全部合一的簡寫:

// world-foods.js - good stuff from all over

// import "sri-lanka" and re-export some of its exports
export {Tea, Cinnamon} from "sri-lanka";

// import "equatorial-guinea" and re-export some of its exports
export {Coffee, Cocoa} from "equatorial-guinea";

// import "singapore" and export ALL of its exports
export * from "singapore";複製程式碼

這種export-from表示式類似於import-from後面跟了一個export。這和真正的匯入有一些區別,它不會在當前作用域中繫結將要匯出的變數。如果你打算在world-foods.js中使用Tea來編寫一些程式碼,請不要使用這種簡寫,你會發現Tea為定義。

如果“singapore”匯出的命名與其他匯出發生了衝突,那就會出現錯誤,所以請謹慎使用。

呼,我們已經把語法介紹完了!下面來談談一些有趣的事情。

import到底做了什麼?

不管你信不信,它什麼都沒做。

噢,你看起來沒那麼好騙。那麼你會相信標準幾乎沒有說import到底該怎麼做嗎?這是件好事嗎?(作者貌似很愛開玩笑。)

ES6將模組的載入細節完全交給了實現,其餘的模組執行部分卻規定得非常詳細

簡單來說,當你告訴JS引擎執行一個模組的時候,它的行為可以歸納為以下四部:

  1. 解析:讀取模組的原始碼,並檢查語法錯誤。
  2. 載入:載入所有的匯入模組(遞迴進行),這是還未標準化的部分。
  3. 連結:對於每個新載入的模組,在實現上都會建立一個作用域,並把模組中宣告的所有變數都繫結在這個作用域上,包括從其他模組匯入的變數。
    如果你想試試import {cake} from "paleo",但是“paleo”模組沒真正匯出名為cake的變數,你會得到一個錯誤。這很糟糕,因為你離執行js並品嚐蛋糕只有一步之遙。
  4. 執行時間:最後,開始執行載入進來的新的模組中的程式碼。這時,整個import過程已經完成了,所以前面說程式碼執行到import這一行宣告時,什麼都沒有發生。

看到沒?我說了什麼都不會發生,在程式語言這件事上,我從來都不說慌。

現在我們可以開始介紹這個系統中有趣的部分了。這有一個非常炫酷的技巧。由於系統沒有指定如何載入的這方面的細節,並且你可以通過檢視原始碼中匯入的宣告,提前計算出所有的依賴項,所以ES6的實現可以通過前處理器完成所有的工作,然後把所有的模組打包到一個檔案中,最後通過網路進行請求一次即可。像webpack這樣的工具就是這麼做的。

這是一個優雅的解決方案,因為通過網路載入所有的指令碼檔案很耗時,假如你請求一個資源後,發現裡面有import宣告,然後你又得請求更多資源。一個載入器需要非常多的網路請求來回傳輸。通過webpack,你不僅能在今天就使用ES6的模組話,你還能獲得很多好處,並且不需要擔心會造成執行時的效能下降。

原本是有計劃制定一個ES6中模組載入的詳細規範的,並且已經初步成型。它沒有成為標準的原因之一是不知道如何與打包這一特性進行整合。我希望模組化的載入會更加標準化,也希望打包工具會越來越好。

靜態 VS 動態,或者說:規則如何打破規則

作為一個動態編譯語言,令人驚奇的是JavaScript擁有一個靜態的模組系統。

  • 所有的importexport只能寫在頂級作用域中。你不能在條件判斷語句和函式作用域內使用import
  • 所有匯出的變數名必須是顯式的,你不能通過遍歷一個陣列,動態生成一組匯出名進行匯出。
  • 模組物件都是被凍結的,不能通過polyfill為它新增新的特性。
  • 在所有模組執行之前, 其依賴的模組都必須經過載入、解析、連結的過程,目前沒有import懶載入相關的語法。(現在import()方法已經在提案中了)
  • 對於import的錯誤,無法進行recovery。一個應用可能依賴許多的模組,一旦有一個模組載入失敗,這個應用都不會執行。你不能在try/catch中使用import。正是因為es6的模組表現得如此靜態,webpack才能在編譯的時候檢測出程式碼中的錯誤。
  • 你沒法為一個模組在載入所有依賴項之前新增鉤子,這意味著一個模組沒有辦法控制其依賴項的載入方式。

如果你的需求是靜態的,ES6的模組系統還是相當不錯的。但是你有時候你還是向進行一些hack,對吧?

這就是為什麼你使用的模組載入系統會提供一些系統層次的API來配合ES6的靜態的import/export語法。例如,webpack有一個API能進行程式碼的分割,按照你的需求對一些模組進行懶載入。這個API能夠打破之前列出的規矩。

ES6的模組語法非常靜態,這很好-在使用一些編譯工具時我們都能嚐到一些甜頭。
靜態語法的設計可以讓它與動態載入器豐富的API進行工作。

我什麼時候才能使用ES6模組?

如果你今天就想使用,你需要一個預編譯器,如 TraceurBabel 。這個系列之前也有相關文章,Gastón I. Silva:如何使用 Babel 和 Broccoli 編譯 ES6 程式碼為 web 可用。Gastón也將案例放在了 GitHub 上。另外這篇文章也介紹瞭如何使用 Babel 和 webpack。

ES6 模組系統由 Dave Herman 和 Sam Tobin-Hochstadt進行設計,他們不顧多人(包括我)的反對,多年來始終堅持模組系統是靜態的。JonCoppeard正在Firefox上實現ES6的模組化功能。JavaScript Loader的相關標準的工作也正在進行中,預計在HTML中將會被新增類似<script type=module> 這樣的東西。

這便是 ES6 了。

這太有趣了,我不希望現在就結束。也許我們還能再說一會。我們還能夠討論一些關於ES6規範中零零碎碎的東西,但這些又不足夠寫成文章。也行會有一些關於ES6未來特性的一些東西,盡請期待下週的ES6 In Depth


原文連結:ES6 In Depth: Modules

相關文章