ES6中的模塊

前面的話

  JS用"共享一切"的方法加載代碼,這是該語言中最易出錯且容易使人感到困惑的地方。在ES6之前,在應用程序的每個JS中定義的一切都共享一個全局做用域。隨着web應用程序變得更加複雜,JS代碼的使用量也開始增加,這一作法會引發問題,如命名衝突和安全問題。ES6的一個目標是解決做用域問題,也爲了使JS應用程序顯得有序,因而引進了模塊。本文將詳細介紹ES6中的模塊javascript

 

概述

  模塊是自動運行在嚴格模式下而且沒有辦法退出運行的JS代碼。與共享一切架構相反的是,在模塊頂部建立的變量不會自動被添加到全局共享做用域,這個變量僅在模塊的頂級做用域中存在,並且模塊必須導出一些外部代碼能夠訪問的元素,如變量或函數。模塊也能夠從其餘模塊導入綁定前端

  另外兩個模塊的特性與做用域關係不大,但也很重要。首先,在模塊的頂部,this的值是undefined;其次,模塊不支持HTML風格的代碼註釋,這是從早期瀏覽器殘餘下來的JS特性java

  腳本,也就是任何不是模塊的JS代碼,則缺乏這些特性。模塊和其餘JS代碼之間的差別可能乍一看不起眼,可是它們表明了JS代碼加載和求值的一個重要變化。模塊真正的魔力所在是僅導出和導入須要的綁定,而不是將所用東西都放到一個文件。只有很好地理解了導出和導入才能理解模塊與腳本的區別node

 

導出

  能夠用export關鍵字將一部分己發佈的代碼暴露給其餘模塊,在最簡單的用例中,能夠將export放在任何變量、函數或類聲明的前面,以將它們從模塊導出web

// 導出數據
export var color = "red";
export let name = "Nicholas";
export const magicNumber = 7;
// 導出函數
export function sum(num1, num2) {
    return num1 + num1;
}
// 導出類
export class Rectangle {
    constructor(length, width) {
        this.length = length;
        this.width = width;
    }
}
// 此函數爲模塊私有
function subtract(num1, num2) {
    return num1 - num2;
}
// 定義一個函數……
function multiply(num1, num2) {
    return num1 * num2;
}
// ……稍後將其導出
export { multiply };

  在這個示例中須要注意幾個細節,除了export關鍵字外,每個聲明與腳本中的如出一轍。由於導出的函數和類聲明須要有一個名稱,因此代碼中的每個函數或類也確實有這個名稱。除非用default關鍵字,不然不能用這個語法導出匿名函數或類後端

  另外,在定義multiply()函數時沒有立刻導出它。因爲沒必要老是導出聲明,能夠導出引用,所以這段代碼能夠運行。此外,這個示例並未導出subtract()函數,任何未顯式導出的變量、函數或類都是模塊私有的,沒法從模塊外部訪問跨域

 

導入

  從模塊中導出的功能能夠經過import關鍵字在另外一個模塊中訪問,import語句的兩個部分分別是要導入的標識符和標識符應當從哪一個模塊導入數組

  這是該語句的基本形式瀏覽器

import { identifier1, identifier2 } from "./example.js";

  import後面的大括號表示從給定模塊導入的綁定(binding),關鍵字from表示從哪一個模塊導入給定的綁定,該模塊由表示模塊路徑的字符串指定(被稱做模塊說明符)。瀏覽器使用的路徑格式與傳給<script>元素的相同,也就是說,必須把文件擴展名也加上。另外一方面,Nodejs則遵循基於文件系統前綴區分本地文件和包的慣例。例如,example是一個包而./example.js是一個本地文件緩存

  當從模塊中導入一個綁定時,它就好像使用const定義的同樣。沒法定義另外一個同名變量(包括導入另外一個同名綁定),也沒法在import語句前使用標識符或改變綁定的值

【導入單個綁定】

  假設前面的示例在一個名爲"example.js"的模塊中,咱們能夠導入並以多種方式使用這個模塊中的綁定

// 單個導入
import { sum } from "./example.js";
console.log(sum(1, 2)); // 3
sum = 1; // 出錯

  儘管example.js導出的函數不止一個,但這個示例導入的卻只有sum()函數。若是嘗試給sum賦新值,結果是拋出一個錯誤,由於不能給導入的綁定從新賦值

  爲了最好地兼容多個瀏覽器和Node.js環境,必定要在字符串以前包含/、./或../來表示要導入的文件

【導入多個綁定】

  若是想從示例模塊導入多個綁定,則能夠明確地將它們列出以下

// 多個導入
import { sum, multiply, magicNumber } from "./example.js";
console.log(sum(1, magicNumber)); // 8
console.log(multiply(1, 2)); // 2

  在這段代碼中,從example模塊導入3個綁定sum、multiply和magicNumber。以後使用它們,就像它們在本地定義的同樣

【導入整個模塊】

  特殊狀況下,能夠導入整個模塊做爲一個單一的對象。而後全部的導出均可以做爲對象的屬性使用

// 徹底導入
import * as example from "./example.js";
console.log(example.sum(1,example.magicNumber)); // 8
console.log(example.multiply(1, 2)); // 2

  在這段代碼中,從example.js中導出的全部綁定被加載到一個被稱做example的對象中。指定的導出(sum()函數、mutiply()函數和magicNumber)以後會做爲example的屬性被訪問。這種導入格式被稱做命名空間導入(namespaceimport)。由於example.js文件中不存在example對象,故而它做爲example.js中全部導出成員的命名空間對象而被建立

  可是,無論在import語句中把一個模塊寫了多少次,該模塊將只執行一次。導入模塊的代碼執行後,實例化過的模塊被保存在內存中,只要另外一個import語句引用它就能夠重複使用它

import { sum } from "./example.js";
import { multiply } from "./example.js";
import { magicNumber } from "./example.js";

  儘管在這個模塊中有3個import語句,但example加載只執行一次。若是同一個應用程序中的其餘模塊也從example.js導入綁定,那麼那些模塊與此代碼將使用相同的模塊實例

【導入綁定的一個微妙怪異之處】

  ES6的import語句爲變量、函數和類建立的是隻讀綁定,而不是像正常變量同樣簡單地引用原始綁定。標識符只有在被導出的模塊中能夠修改,即使是導入綁定的模塊也沒法更改綁定的值

export var name = "huochai";
export function setName(newName) {
    name = newName;
}

  當導入這兩個綁定後,setName()函數能夠改變name的值

import { name, setName } from "./example.js";
console.log(name); // "huochai"
setName("match");
console.log(name); // "match"
name = "huochai"; // error

  調用setName("match")時會回到導出setName()的模塊中去執行,並將name設置爲"match"。此更改會自動在導入的name綁定上體現。其緣由是,name是導出的name標識符的本地名稱。本段代碼中所使用的name和模塊中導入的name不是同一個

 

重命名

  有時候,從一個模塊導入變量、函數或者類時,可能不但願使用它們的原始名稱。幸運的是,能夠在導出過程和導入過程當中改變導出元素的名稱

  假設要使用不一樣的名稱導出一個函數,則能夠用as關鍵字來指定函數在模塊外的名稱

function sum(num1, num2) {
    return num1 + num2;
}
export { sum as add };

  在這裏,函數sum()是本地名稱,add()是導出時使用的名稱。也就是說,當另外一個模塊要導入這個函數時,必須使用add這個名稱

import { add } from "./example.js";

  若是模塊想使用不一樣的名稱來導入函數,也可使用as關鍵字

import { add as sum } from "./example.js";
console.log(typeof add); // "undefined"
console.log(sum(1, 2)); // 3

  這段代碼導入add()函數時使用了一個導入名稱來重命名sum()函數(當前上下文中的本地名稱)。導入時改變函數的本地名稱意味着即便模塊導入了add()函數,在當前模塊中也沒有add()標識符

 

默認值

  因爲在諸如CommonJS的其餘模塊系統中,從模塊中導出和導入默認值是一個常見的作法,該語法被進行了優化。模塊的默認值指的是經過default關鍵字指定的單個變量、函數或類,只能爲每一個模塊設置一個默認的導出值,導出時屢次使用default關鍵字是一個語法錯誤

【導出默認值】

  下面是一個使用default關鍵字的簡單示例

export default function(num1, num2) {
    return num1 + num2;
}

  這個模塊導出了一個函數做爲它的默認值,default關鍵字表示這是一個默認的導出,因爲函數被模塊所表明,於是它不須要一個名稱

  也能夠在export default以後添加默認導出值的標識符,就像這樣

function sum(num1, num2) {
    return num1 + num2;
}
export default sum;

  先定義sum()函數,而後再將其導出爲默認值,若是須要計算默認值,則可使用這個方法。爲默認導出值指定標識符的第三種方法是使用重命名語法,以下所示

function sum(num1, num2) {
    return num1 + num2;
}
export { sum as default };

  在重命名導出時標識符default具備特殊含義,用來指示模塊的默認值。因爲default是JS中的默認關鍵字,所以不能將其用於變量、函數或類的名稱;可是,能夠將其用做屬性名稱。因此用default來重命名模塊是爲了儘量與非默認導出的定義一致。若是想在一條導出語句中同時指定多個導出(包括默認導出),這個語法很是有用

【導入默認值】

  可使用如下語法從一個模塊導入一個默認值

// 導入默認值
import sum from "./example.js";
console.log(sum(1, 2)); // 3

  這條import語句從模塊example.js中導入了默認值,請注意,這裏沒有使用大括號,與非默認導入的狀況不一樣。本地名稱sum用於表示模塊導出的任何默認函數,這種語法是最純淨的,ES6的建立者但願它可以成爲web上主流的模塊導入形式,而且可使用已有的對象

  對於導出默認值和一或多個非默認綁定的模塊,能夠用一條語句導入全部導出的綁定

export let color = "red";
export default function(num1, num2) {
    return num1 + num2;
}

  能夠用如下這條import語句導入color和默認函數

import sum, { color } from "./example.js";
console.log(sum(1, 2)); // 3
console.log(color); // "red"

  用逗號將默認的本地名稱與大括號包裹的非默認值分隔開

  [注意]在import語句中,默認值必須排在非默認值以前

  與導出默認值同樣,也能夠在導入默認值時使用重命名語法

// 等價於上個例子
import { default as sum, color } from "example";
console.log(sum(1, 2)); // 3
console.log(color); // "red"

  在這段代碼中,默認導出(export)值被重命名爲sum,而且還導入了color

 

靜態加載

   ES6中的模塊與node.js中的模塊加載不一樣,nodeJS中的require語句是運行時加載,而ES6中的import是靜態加載,因此有一些語法限制

  一、不能使用表達式和變量等這些只有在運行時才能獲得結果的語法結構

// 報錯
import { 'f' + 'oo' } from 'my_module';

// 報錯
let module = 'my_module';
import { foo } from module;

  二、importexport命令只能在模塊的頂層,不能在代碼塊之中,如不能在if語句和函數內使用

if (flag) {
    export flag; // 語法錯誤
}

// 報錯
if (x === 1) {
  import { foo } from 'module1';
} else {
  import { foo } from 'module2';
}
function tryImport() {
    import flag from "./example.js"; // 語法錯誤
}

  以上的寫法會報錯,是由於在靜態分析階段,這些語法都是無法獲得值的

  這樣的設計,當然有利於編譯器提升效率,但也致使沒法在運行時加載模塊。在語法上,條件加載就不可能實現。若是import命令要取代 Node 的require方法,這就造成了一個障礙。由於require是運行時加載模塊,import命令沒法取代require的動態加載功能

const path = './' + fileName;
const myModual = require(path);

  上面的語句就是動態加載,require到底加載哪個模塊,只有運行時才知道。import語句作不到這一點

 

從新導出

  可能須要從新導出模塊已經導入的內容

import { sum } from "./example.js";
export { sum }

  雖然這樣能夠運行,但只經過一條語句也能夠完成一樣的任務

export { sum } from "./example.js";

  這種形式的export在指定的模塊中查找sum聲明,而後將其導出。固然,對於一樣的值也能夠不一樣的名稱導出

export { sum as add } from "./example.js";

  這裏的sum是從example.js導入的,而後再用add這個名字將其導出

  若是想導出另外一個模塊中的全部值,則可使用*模式

export * from "./example.js";

  導出一切是指導出默認值及全部命名導出值,這可能會影響能夠從模塊導出的內容。例如,若是example.js有默認的導出值,則使用此語法時將沒法定義一個新的默認導出

 

無綁定導入

  某些模塊可能不導出任何東西,相反,它們可能只修改全局做用域中的對象。儘管模塊中的頂層變量、函數和類不會自動地出如今全局做用域中,但這並不意味着模塊沒法訪問全局做用域。內建對象(如Array和Object)的共享定義能夠在模塊中訪問,對這些對象所作的更改將反映在其餘模塊中

  例如,要向全部數組添加pushAll()方法,則能夠定義以下所示的模塊

// 沒有導出與導入的模塊
Array.prototype.pushAll = function(items) {
    // items 必須是一個數組
    if (!Array.isArray(items)) {
        throw new TypeError("Argument must be an array.");
    }
    // 使用內置的 push() 與擴展運算符
    return this.push(...items);
};

  即便沒有任何導出或導入的操做,這也是一個有效的模塊。這段代碼既能夠用做模塊也能夠用做腳本。因爲它不導出任何東西,於是可使用簡化的導入操做來執行模塊代碼,並且不導入任何的綁定

import "./example.js";
let colors = ["red", "green", "blue"];
let items = [];
items.pushAll(colors);

  這段代碼導入並執行了模塊中包含的pushAll()方法,因此pushAll()被添加到數組的原型,也就是說如今模塊中的全部數組均可以使用pushAll()方法了

  [注意]無綁定導入最有可能被應用於建立polyfill和Shim

 

加載模塊

  雖然ES6定義了模塊的語法,但它並無定義如何加載這些模塊。這正是規範複雜性的一個體現,應由不一樣的實現環境來決定。ES6沒有嘗試爲全部JS環境建立一套統一的標準,它只規定了語法,並將加載機制抽象到一個未定義的內部方法HostResolveImportedModule中。Web瀏覽器和Node.js開發者能夠經過對各自環境的認知來決定如何實現HostResolveImportedModule 

【在Web瀏覽器中使用模塊】

  即便在ES6出現之前,Web瀏覽器也有多種方式能夠將JS包含在Web應用程序中,這些腳本加載的方法分別是

  一、在<script>元素中經過src屬性指定一個加載代碼的地址來加載JS代碼文件

  二、將JS代碼內嵌到沒有src屬性的<script>元素中

  三、經過Web Worker或Service Worker的方法加載並執行JS代碼文件

  爲了徹底支持模塊功能,Web瀏覽器必須更新這些機制

在<script>中使用模塊

  <script>元素的默認行爲是將JS文件做爲腳本加載,而非做爲模塊加載,當type屬性缺失或包含一個JS內容類型(如"text/javascript")時就會發生這種狀況。<script>元素能夠執行內聯代碼或加載src中指定的文件,當type屬性的值爲"module"時支持加載模塊。將type設置爲"module"可讓瀏覽器將全部內聯代碼或包含在src指定的文件中的代碼按照模塊而非腳本的方式加載

<!-- load a module JavaScript file -->
<script type="module" src="module.js"></script>
<!-- include a module inline -->
<script type="module">
  import { sum } from "./example.js";
  let result = sum(1, 2);
</script>

  此示例中的第一個<script>元素使用src屬性加載了一個外部的模塊文件,它與加載腳本之間的惟一區別是type的值是"module"。第二個<script>元素包含了直接嵌入在網頁中的模塊。變量result沒有暴露到全局做用域,它只存在於模塊中(由<script>元素定義),所以不會被添加到window做爲它的屬性

  在Web頁面中引入模塊的過程相似於引入腳本,至關簡單。可是,模塊實際的加載過程卻有一些不一樣

  "module"與"text/javascript"這樣的內容類型並不相同。JS模塊文件與JS腳本文件具備相同的內容類型,所以沒法僅根據內容類型進行區分。此外,當沒法識別type的值時,瀏覽器會忽略<script>元素,所以不支持模塊的瀏覽器將自動忽略<script type="module">來提供良好的向後兼容性

Web瀏覽器中的模塊加載順序

  模塊與腳本不一樣,它是獨一無二的,能夠經過import關鍵字來指明其所依賴的其餘文件,而且這些文件必須被加載進該模塊才能正確執行。爲了支持該功能,<script type="module">執行時自動應用defer屬性

  加載腳本文件時,defer是可選屬性加載模塊時,它就是必需屬性。一旦HTML解析器遇到具備src屬性的<script type="module">,模塊文件便開始下載,直到文檔被徹底解析模塊纔會執行。模塊按照它們出如今HTML文件中的順序執行,也就是說,不管模塊中包含的是內聯代碼仍是指定src屬性,第一個<scpipt type="module">老是在第二個以前執行

<!-- this will execute first -->
<script type="module" src="module1.js"></script>
<!-- this will execute second -->
<script type="module">
import { sum } from "./example.js";
let result = sum(1, 2);
</script>
<!-- this will execute third -->
<script type="module" src="module2.js"></script>

  這3個<script>元素按照它們被指定的順序執行,因此模塊module1.js保證會在內聯模塊前執行,而內聯模塊保證會在module2.js模塊以前執行

  每一個模塊均可以從一個或多個其餘的模塊導入,這會使問題複雜化。所以,首先解析模塊以識別全部導入語句;而後,每一個導入語句都觸發一次獲取過程(從網絡或從緩存),而且在全部導入資源都被加載和執行後纔會執行當前模塊

  用<script type="module">顯式引入和用import隱式導入的全部模塊都是按需加載並執行的。在這個示例中,完整的加載順序以下

  一、下載並解析module1.js

  二、遞歸下載並解析module1.js中導入的資源

  三、解析內聯模塊

  四、遞歸下載並解析內聯模塊中導入的資源

  五、下載並解析module2.js

  六、遞歸下載並解析module2.js中導入的資源

  加載完成後,只有當文檔徹底被解析以後纔會執行其餘操做。文檔解析完成後,會發生如下操做

  一、遞歸執行module1.js中導入的資源

  二、執行module1.js

  三、遞歸執行內聯模塊中導入的資源

  四、執行內聯模塊

  五、遞歸執行module2.js中導入的資源

  六、執行module2.js

  內聯模塊與其餘兩個模塊惟一的不一樣是,它沒必要先下載模塊代碼。不然,加載導入資源和執行模塊的順序就是同樣的

  [注意]<script type="module">元素會忽略defer屬性,由於它執行時defer屬性默認是存在的

Web瀏覽器中的異步模塊加載

  <script>元素上的async屬性應用於腳本時,腳本文件將在文件徹底下載並解析後執行。可是,文檔中async腳本的順序不會影響腳本執行的順序,腳本在下載完成後當即執行,而沒必要等待包含的文檔完成解析

  async屬性也能夠應用在模塊上,在<script type="module">元素上應用async屬性會讓模塊以相似於腳本的方式執行,惟一的區別是,在模塊執行前,模塊中全部的導入資源都必須下載下來。這能夠確保只有當模塊執行所需的全部資源都下載完成後才執行模塊,但不能保證的是模塊的執行時機

<!-- no guarantee which one of these will execute first -->
<script type="module" async src="module1.js"></script>
<script type="module" async src="module2.js"></script>

  在這個示例中,兩個模塊文件被異步加載。只是簡單地看這個代碼判斷不出哪一個模塊先執行,若是module1.js首先完成下載(包括其全部的導入資源),它將先執行;若是module2.js首先完成下載,那麼它將先執行

將模塊做爲Woker加載

  Worker,例如Web Worker和Service Woker,能夠在網頁上下文以外執行JS代碼。建立新Worker的步驟包括建立一個新的Worker實例(或其餘的類),傳入JS文件的地址。默認的加載機制是按照腳本的方式加載文件

// 用腳本方式加載 script.js
let worker = new Worker("script.js");

  爲了支持加載模塊,HTML標準的開發者向這些構造函數添加了第二個參數,第二個參數是一個對象,其type屬性的默認值爲"script"。能夠將type設置爲"module"來加載模塊文件

// 用模塊方式加載 module.js
let worker = new Worker("module.js", { type: "module" });

  在此示例中,給第二個參數傳入一個對象,其type屬性的值爲"module",即按照模塊而不是腳本的方式加載module.js。(這裏的type屬性是爲了模仿<script>標籤的type屬性,用以區分模塊和腳本)全部瀏覽器中的Worker類型都支持第二個參數

  Worker模塊一般與Worker腳本一塊兒使用,但也有一些例外。首先,Worker腳本只能從與引用的網頁相同的源加載,可是Worker模塊不會徹底受限,雖然Worker模塊具備相同的默認限制,但它們仍是能夠加載並訪問具備適當的跨域資源共享(CORS)頭的文件;其次,儘管Worker腳本可使用self.importScripts()方法將其餘腳本加載到Worker中,但self.importScripts()卻始終沒法加載Worker模塊,由於應該使用import來導入

【瀏覽器模塊說明符解析】

  瀏覽器要求模塊說明符具備如下幾種格式之一

  一、以/開頭的解析爲從根目錄開始

  二、以./開頭的解析爲從當前目錄開始

  三、以../開頭的解析爲從父目錄開始

  四、URL格式

  例如,假設有一個模塊文件位於https://www.example.com/modules/modules.js,其中包含如下代碼

// 從 https://www.example.com/modules/example1.js 導入
import { first } from "./example1.js";
// 從 from https://www.example.com/example2.js 導入
import { second } from "../example2.js";
// 從 from https://www.example.com/example3.js 導入
import { third } from "/example3.js";
// 從 from https://www2.example.com/example4.js 導入
import { fourth } from "https://www2.example.com/example4.js";

  此示例中的每一個模塊說明符都適用於瀏覽器,包括最後一行中的那個完整的URL(爲了支持跨域加載,只需確保www2.example.com的CORS頭的配置是正確的)儘管還沒有完成的模塊加載器規範將提供解析其餘格式的方法,但目前,這些是瀏覽器默認狀況下惟一能夠解析的模塊說明符的格式

  所以,一些看起來正常的模塊說明符在瀏覽器中其實是無效的,而且會致使錯誤

// 無效:沒有以 / 、 ./ 或 ../ 開始
import { first } from "example.js";
// 無效:沒有以 / 、 ./ 或 ../ 開始
import { second } from "example/index.js";

  因爲這兩個模塊說明符的格式不正確(缺乏正確的起始字符),所以它們沒法被瀏覽器加載,即便在<script>標籤中用做src的值時兩者均可以正常工做。<script>標籤和import之間的這種行爲差別是有意爲之

 

總結

  下面對AMD、CMD、CommonJS和ES6的module進行總結對比

  AMD是requireJS在推廣過程當中對模塊定義的規範化產出。AMD是一個規範,只定義語法API,而requireJS是具體的實現。相似於ECMAScript和javascript的關係

  由下面代碼可知,AMD的特色是依賴前置,對於依賴的模塊提早執行

// AMD
define(['./a', './b'], function(a, b) {  // 依賴必須一開始就寫好
    a.doSomething()    
    // 此處略去 n 行    
    b.doSomething()    
    ...
})

  CMD 是 SeaJS 在推廣過程當中對模塊定義的規範化產出,它的特色是依賴就近,對於依賴的模塊延遲執行

// CMD
define(function(require, exports, module) { 
    var a = require('./a')
     a.doSomething()  
    // 此處略去 n 行   
    var b = require('./b') // 依賴能夠就近書寫  
    b.doSomething()   
    // ... 
})

  CommonJS規範主要在NodeJS後端使用,前端瀏覽器不支持該規範

// math.js
exports.add = function () {
    var sum = 0, i = 0,args = arguments, l = args.length;
    while (i < l) {
        sum += args[i++];
    }
    return sum;
};
// program.js
var math = require('math');
exports.increment = function (val) {
    return math.add(val, 1);
};

  ES6的Module模塊主要經過export和import來進行模塊的導入和導出

//example.js
export default function(num1, num2) {
    return num1 + num2;
}
// 導入默認值
import sum from "./example.js";
console.log(sum(1, 2)); // 3
相關文章
相關標籤/搜索