[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一起工作。