文章出處

前面的話

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

 

概述

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

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

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

 

導出

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

// 導出數據
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是靜態加載,所以有一些語法限制

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

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

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

  2、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應用程序中,這些腳本加載的方法分別是

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

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

  3、通過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隱式導入的所有模塊都是按需加載并執行的。在這個示例中,完整的加載順序如下

  1、下載并解析module1.js

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

  3、解析內聯模塊

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

  5、下載并解析module2.js

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

  加載完成后,只有當文檔完全被解析之后才會執行其他操作。文檔解析完成后,會發生以下操作

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

  2、執行module1.js

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

  4、執行內聯模塊

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

  6、執行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來導入

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

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

  1、以/開頭的解析為從根目錄開始

  2、以./開頭的解析為從當前目錄開始

  3、以../開頭的解析為從父目錄開始

  4、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

  


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()