[ES6深度解析]15:模組 Module

JavaScript項目已經發展到令人瞠目結舌的規模,社區已經開發了用於大規模工作的工具。你需要的最基本的東西之一是一個模組系統,這是一種將你的工作分散到多個文件和目錄的方法——但仍然要確保你的所有程式碼片段可以根據需要相互訪問——而且還要能夠有效地載入所有程式碼。所以很自然,JavaScript有一個模組系統。實際上,有不少模組系統。還有一些包管理器,用於安裝所有這些軟體和處理高級依賴關係的工具。你可能會認為,擁有新的模組語法的ES6有點晚了。

今天我們將看到ES6是否會在這些現有系統中添加任何東西,以及未來的標準和工具是否能夠在它的基礎上構建。但首先,讓我們深入了解一下ES6模組是什麼樣子的。

Module 基礎知識

ES6模組是一個包含JS程式碼的文件。沒有特殊的module關鍵字;模組讀起來就像腳本。有兩個區別。

  • ES6模組是自動的嚴格模式程式碼,即使你沒有寫use strict
  • 可以在模組中使用importexport

讓我們先談談export。默認情況下,在模組中聲明的所有內容都是該模組的局部內容。如果你希望在模組中聲明的某些特性是公共的,以便其他模組可以使用它,則必須export該特性。有幾種方法可以做到這一點。最簡單的方法是添加export關鍵字。

// kittydar.js - Find the locations of all the cats in an image.

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

這就是編寫模組所需要知道的全部內容!你不需要把所有的東西都放到IIFE回調中。去聲明你需要的東西吧。由於程式碼是一個模組,而不是一個腳本,所以所有的聲明都將作用域限定在該模組,而不是在所有腳本和模組中全局可見

除了export之外,模組中的程式碼基本上都是普通程式碼。它可以使用全局變數,如ObjectArray。如果您的模組在web瀏覽器中運行,它可以使用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 { ... }

export列表不一定是文件中的第一項內容;它可以出現在模組文件的頂級作用域中的任何地方。您可以有多個export列表,或者將export列表與其他export聲明混合在一起,只要沒有名稱被多次重複導出。

重命名導入和導出

偶爾,導入的名稱會與你需要使用的其他名稱發生衝突。所以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
};

默認導出

新標準旨在與現有的CommonJSAMD模組互操作。假設你有一個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 export,這和你在require()函數中調用該模組時得到的是一樣的,也就是exports對象

ES6模組被設計成允許你導出多個東西,但是對於現有的CommonJS模組,你只能得到默認的導出。例如,在撰寫本文時,據我所知,著名的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後面可以跟任何值:函數、類、對象字面量。

模組對象

import * as cows from "cows";

當你import *時,所導入的是一個模組名稱空間對象(module namespace object)。它的屬性是模組的exports。因此,如果cows模組導出了一個名為moo()的函數,那麼在以這種方式導入cows之後,你可以寫:cows.moo()

聚合模組

有時候,一個包的主模組比導入包的所有其他模組並以統一的方式導出它們大不了多少。為了簡化這類程式碼,有一種一體化的import-and-export簡寫:

// 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-food.js中編寫一些利用Tea的程式碼,請不要使用這種簡寫。你會發現它並不存在。

如果singapore輸出的任何名稱與其他輸出名稱發生衝突,那將是一個錯誤,因此要小心使用export *

語法已經講完了!現在說說有趣的部分。

import實際上是做什麼的?

你會相信…什麼都不沒做嗎?

哦,你沒那麼容易上當。你能相信標準並沒有說import到底做了哪些事情嗎?這是件好事嗎?

ES6將模組載入的細節完全留給了實現。模組執行方式是詳細指定的。粗略地說,當你告訴JS引擎運行一個模組時,它必須表現得像以下四個步驟正在發生:

  1. 解析(Parsing):
    實現讀取模組的源程式碼並檢查語法錯誤。

  2. 載入(Loading):
    實現載入所有導入的模組(遞歸地)。這部分還沒有標準化。

  3. 鏈接(Linking):
    對於每個新載入的模組,實現創建一個模組作用域,並用該模組中聲明的所有綁定填充它,包括從其他模組導入的內容。
    如果你試圖import {cake} from "paleo",但是paleo模組實際上沒有導出任何名為cake的東西,你會得到一個錯誤。這太糟糕了,因為你離真正運行一些JS程式碼已經很近了。

  4. 運行時(Runtime):
    最後,實現運行每個新載入模組的程式碼體中的語句。此時,導入處理已經完成,所以當執行到有import聲明的程式碼行時……什麼也沒有發生!

看到了嗎?我告訴過你答案是”import什麼都沒做”,關於程式語言,我沒有撒謊。

現在我們來看看這個系統中有趣的部分。有一個很酷的技巧。因為系統不指定載入是如何工作的,因為你可以提前通過查看源程式碼import聲明算出所有的依賴關係,一種載入的實現方式是在編譯時完成所有的工作,你所有的模組打包成一個文件,並把它放在網路上傳輸!像webpack這樣的工具可以做到這一點。

這是一件大事,因為通過網路載入腳本需要花費時間,而且每次獲取一個腳本時,您可能會發現它包含需要載入幾十個以上的導入聲明。一個簡單的載入器將需要大量的網路往返通訊。但是有了webpack,你現在不僅可以使用帶有模組的ES6,還可以在不影響運行時性能的情況下獲得所有的軟體工程好處。

ES6中模組載入的詳細規範最初是計劃並構建的。它沒有出現在最終標準中的一個原因是,對於如何實現這個捆綁特性沒有達成共識。我希望有人能解決這個問題,因為我們將看到,模組載入確實應該標準化。捆綁銷售太好了,不能放棄。

靜態 vs 動態,或者:規則以及如何打破規則

作為一種動態語言,JavaScript讓自己擁有了一個令人驚訝的靜態模組系統。

  • 在一個模組中,所有類型的導入和導出都只允許在頂層。沒有條件導入或導出,並且不能在函數內使用導入。
  • 所有導出的標識符必須在源程式碼中按名稱顯式導出。你無法通過編程方式遍曆數組並以數據驅動的方式導出一組名稱。
  • 模組對象被凍結(無法修改)。沒有辦法將一個新特性hack到一個模組對象中,polyfill風格。
  • 在任何模組程式碼運行之前,模組的所有依賴項都必須被載入、解析和鏈接。沒有語法可以實現按需惰性載入的導入
  • 導入發生錯誤沒有錯誤恢復。一個應用程式可能包含數百個模組,如果有任何模組無法載入或鏈接,就無法運行。不能把import包裹在try/catch塊中。(這裡的好處是,因為系統是靜態的,所以webpack可以在編譯時檢測到這些錯誤。)
  • 沒有鉤子允許模組在依賴項載入之前運行一些程式碼。這意味著模組無法控制它們的依賴項是如何載入的。

只要你的需求是靜態的,系統就相當不錯。但你難免有時候需要做一點訂製,對吧?

這就是為什麼無論你使用什麼模組載入系統,都會有一個編程API來配合ES6的靜態import/export語法。例如,webpack包含一個API,你可以用它來「分割程式碼」,按需惰性載入一些模組包。同樣的API可以幫助您打破上面列出的大多數規則。

ES6模組語法是非常靜態的,這很好——它以強大的編譯時工具的形式得到了回報。但是這種靜態語法被設計為與豐富的動態、程式化載入器API一起工作。