ECMAScript 6 Module的語法和加載實現

Module的語法和加載實現

一、ES6模塊的簡單介紹

歷史上,JavaScript 一直沒有模塊(module)體系,無法將一個大程序拆分成互相依賴的小文件,再用簡單的方法拼裝起來。其他語言都有這項功能,比如 Ruby 的require、Python 的import,甚至就連 CSS 都有@import,但是 JavaScript 任何這方面的支持都沒有,這對開發大型的、複雜的項目形成了巨大障礙。

在 ES6 之前,社區制定了一些模塊加載方案,最主要的有 CommonJS 和 AMD 兩種。前者用於服務器,後者用於瀏覽器。ES6 在語言標準的層面上,實現了模塊功能,而且實現得相當簡單,完全可以取代 CommonJS 和 AMD 規範,成爲瀏覽器和服務器通用的模塊解決方案。

ES6 模塊的設計思想是儘量的靜態化,使得編譯時就能確定模塊的依賴關係,以及輸入和輸出的變量。CommonJS 和 AMD 模塊,都只能在運行時確定這些東西。比如,CommonJS 模塊就是對象,輸入時必須查找對象屬性。

ES6模塊封裝的一個小示例

將常用的、公共的數據封裝在a.js中:

// 使用export關鍵詞導出封裝的數據
export let str = '當你凝望着深淵時,深淵也在凝望你';
export let num = 100;
export let bol = true;
export function fn() {
    console.log('i am a fn');
}

在b.js中直接導入a.js中的數據使用,無須將兩個JS文件引入到同一個HTML文件中:

// 使用import關鍵詞按需引入你需要的數據
import {str, num, bol, fn} from './a.js';
console.log(str, num, bol);

fn();

但是我們發現,在任意一個HTML中,通過傳統方式引入b.js,並不能直接使用,而是會直接報錯。

<html>
    <head></head>
    <body>
        <!-- 報錯 -->
        <script src="b.js"></script>
    </body>
</html>

ES6的模塊需要使用特殊方式調用纔可以生效。

二、ES6模塊的加載實現

<html>
    <head></head>
    <body>
        <script type="module" src="b.js"></script>
        <!-- 也可以將module代碼內嵌在網頁中 -->
        <script type="module">
            import { str } from './a.js';
            console.log(str);
        </script>
    </body>
</html>

當把script標籤的type屬性設置爲module時,瀏覽器知道這是一個 ES6 模塊,會按照ES6模塊的規則解析其中的代碼,模塊代碼就能正常執行了。

注:

  • 瀏覽器對於帶有type="module"的<script>,都是異步加載,不會造成堵塞瀏覽器,即等到整個頁面渲染完,再執行模塊腳本,等同於打開了<script>標籤的defer屬性。
  • 如果網頁有多個<script type="module">,它們會按照在頁面出現的順序依次執行。
  • <script>標籤的async屬性也可以打開,這時只要加載完成,渲染引擎就會中斷渲染立即執行。執行完成後,再恢復渲染。
  • 一旦使用了async屬性,<script type="module">就不會按照在頁面出現的順序執行,而是隻要該模塊加載完成,就執行該模塊。
  • 一旦使用了async屬性,<script type="module">就不會按照在頁面出現的順序執行,而是隻要該模塊加載完成,就執行該模塊。
  • ES6 模塊也允許內嵌在網頁中,語法行爲與加載外部腳本完全一致。

Node環境也可以使用ES6的模塊,具體使用在框架期詳解。

三、嚴格模式

ES6 的模塊自動採用嚴格模式,不管你有沒有在模塊頭部加上"use strict";。

嚴格模式主要有以下限制:

  • 變量必須聲明後再使用
  • 函數的參數不能有同名屬性,否則報錯
  • 不能使用with語句
  • 不能對只讀屬性賦值,否則報錯
  • 不能使用前綴 0 表示八進制數,否則報錯
  • 不能刪除不可刪除的屬性,否則報錯
  • 不能刪除變量delete prop,會報錯,只能刪除屬性delete global[prop]
  • eval不會在它的外層作用域引入變量
  • eval和arguments不能被重新賦值
  • arguments不會自動反映函數參數的變化
  • 不能使用arguments.callee
  • 不能使用arguments.caller
  • 禁止this指向全局對象
  • 不能使用fn.caller和fn.arguments獲取函數調用的堆棧
  • 增加了保留字(比如protected、static和interface)

上面這些限制,模塊都必須遵守。其中,尤其需要注意this的限制。ES6 模塊之中,頂層的this指向undefined,即不應該在頂層代碼使用this。

四、export、export default、import、import()

模塊功能主要由兩個命令構成:export(導出)和import(導入)。export命令用於規定模塊的對外接口,import命令用於輸入其他模塊提供的功能。

1. export命令

一個模塊就是一個獨立的文件。該文件內部的所有變量,外部無法獲取。如果你希望外部能夠讀取模塊內部的某個變量,就必須使用export關鍵字輸出該變量。下面是一個 JS 文件,裏面使用export命令輸出變量。

// module.js
export let str = 'Michael';
export let num = 108;
export let year = 2019;

export除了可以單個導出變量外,它還可以導出一個列表,在列表中一一列出要導出的數據。

let str = 'Michael';
let num = 108;
let year = 2019;

export {
    str,
    num,
    year
}

建議優先考慮使用這種寫法。因爲這樣就可以在腳本尾部,一眼看清楚輸出了哪些變量。

export命令除了輸出變量,還可以輸出函數或類(class)。

function sum(x, y) {
    return x + y;
}

class Point {}

export {
    sum,
    Point
}

通常情況下,export輸出的變量就是本來的名字,但是可以使用as關鍵字重命名。

// module.js
let str = '我本將心付明月,奈何明月照溝渠';
function sum(x, y) {
    return x + y;
}

export {
    str as anotherStr,
    sum as getSum
}

在其他文件中引用:

import {getSum, anotherStr} from './module.js';
console.log(getSum(34, 56));
console.log(anotherStr);

需要特別注意的是,export命令規定的是對外的接口,必須與模塊內部的變量建立一一對應關係。

export的錯誤使用:

// 報錯
export 1;

// 報錯
let m = 1;
export m;

上面兩種寫法都會報錯,因爲沒有提供對外的接口。第一種寫法直接輸出 1,第二種寫法通過變量m,還是直接輸出 1。1只是一個值,不是接口。正確的寫法是下面這樣。

// 寫法一
export let m = 1;

// 寫法二
let n = 1;
export {n};

// 寫法三
let a = 1;
export {a as b};

上面三種寫法都是正確的,規定了對外的接口m。其他腳本可以通過這個接口,取到值1。它們的實質是,在接口名與模塊內部變量之間,建立了一一對應的關係。

另外,export語句輸出的接口,與其對應的值是動態綁定關係,即通過該接口,可以取到模塊內部實時的值。

// module.js中,export一個變量,並且在500ms後改變其值
export let num = 100;
setTimeout(() => num = 303, 500);

在另一個JS中使用module.js:

import { num } from './m1.js';
console.log(num); // 100

setTimeout(function (){
   console.log(foo); // 303
}, 600);

最後,export命令可以出現在模塊的任何位置,只要處於模塊頂層就可以。如果處於塊級作用域內,就會報錯,下一節的import命令也是如此。

2. import命令

使用export命令定義了模塊的對外接口以後,其他 JS 文件就可以通過import命令加載這個模塊。

// module.js
let str = 'Michael';
let num = 108;
let year = 2019;

export {
    str,
    num,
    year
}
// index.js
import { str, num } from './module.js';

上面代碼的import命令,用於加載module.js文件,並從中輸入變量。import命令接受一對大括號,裏面指定要從其他模塊導入的變量名。大括號裏面的變量名,必須與被導入模塊(module.js)對外接口的名稱相同。

如果想爲輸入的變量重新取一個名字,import命令要使用as關鍵字,將輸入的變量重命名。

import { str as anotherStr } from './module.js';
console.log(str);

import命令輸入的變量都是隻讀的,因爲它的本質是輸入接口。也就是說,不允許在加載模塊的腳本里面,改寫接口,但是如果是爲一個被導入的對象改寫屬性,是可以的。

import { str } from './module.js';

// 報錯
str = 'hello';

import後面的from指定模塊文件的位置,可以是相對路徑,也可以是絕對路徑,.js後綴可以省略。如果只是模塊名,不帶有路徑,那麼必須有配置文件,告訴 JavaScript 引擎該模塊的位置。

由於import是靜態執行,所以不能使用表達式和變量,這些只有在運行時才能得到結果的語法結構。

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

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

// 報錯
if (x === 1) {
  import { foo } from 'module1';
} else {
  import { foo } from 'module2';
}

上面三種寫法都會報錯,因爲它們用到了表達式、變量和if結構。在靜態分析階段,這些語法都是沒法得到值的。

最後,import語句會執行所加載的模塊,因此可以有下面的寫法。

// Math.js
function fn(x, y) {
    return x + y;
}
let res = fn(23, 4);
console.log(res);
// index.js
import './Math.js'; // Math.js中的代碼會被執行

如果多次重複執行同一句import語句,那麼只會執行一次,而不會執行多次。

3. 模塊的整體加載

除了指定加載某個輸出值,還可以使用整體加載,即用星號(*)指定一個對象,所有輸出值都加載在這個對象上面。

// module.js
export function sum(x, y) {
    return x + y;
}

export function rn(x, y) {
    return Math.round(Math.random() * (y - x) + x);
}
// index.js
import * as Math from './module.js'

let a = Math.sum(23, 67);
let b = Math.rn(34, 99);

console.log(a, b);

4. export default 命令

使用import命令的時候,用戶需要知道所要加載的變量名或函數名,否則無法加載。但是這種方式需要花費更多時間去閱讀別人封裝的JS文檔,所以ES提供export default命令,爲模塊指定默認輸出。

// module.js
export default function(x, y) {
    return x + y;
}

其他模塊加載該模塊時,import命令可以爲該匿名函數指定任意名字。

// index.js
import sum from './module.js';

let a = sum(34, 67);
console.log(a);

export default命令用在非匿名函數前,也是可以的。

// module.js
export default function sum(x, y) {
    return x + y;
}
let a = sum(23, 45); 
console.log(a);

在module.js中,sum可以被正常調用,但是導出時,函數名sum,在模塊外部是無效的,加載的時候,視同匿名函數加載。

// index.js
import fn from './module.js'
let a = fn(34, 5);
console.log(a);

注:
export default命令用於指定模塊的默認輸出。一個模塊只能有一個默認輸出,因此export default命令只能使用一次。所以,import命令不用加大括號,因爲只可能唯一對應export default命令。

本質上,export default就是輸出一個叫做default的變量或方法,然後系統允許你爲它取任意名字。所以,下面的寫法是有效的。

let obj = {};
export { obj as default }

import {default as a} from './module.js';

export default命令其實只是輸出一個叫做default的變量,所以它後面不能跟變量聲明語句。

// 正確
export let a = 1;

// 正確
let a = 1;
export default a;

// 錯誤
export default let a = 1;

export default命令和export可以同時使用。

// module.js
export default {
    str: 'hello',
    num: 108,
    fn() {
        console.log(this.str);
    }
}
let bol = true;
function sum(x, y) {
    return x + y;
}
export {
    bol,
    sum
}

在導入時,如果導入默認內容,就不加大括號,其餘的加大括號。

只引入默認模塊。

// index.js
import obj from './module.js';
console.log(obj.num);
obj.fn();

只引入非默認模塊。

// index.js
import {bol, sum} from './module.js';
console.log(bol);
sum(23, 78);

引入module.js中所有的導出。

// index.js
import obj, {bol, sum} from './module.js';

5. export和import同時使用

一個JS文件中,除了可以導出其文件中原本就聲明的一些變量之外,還可以導出這個文件導入的數據。

// a.js
let str = 'hello';
let num = 108;
let bol = true;

export {str, num, bol}
// b.js
import {str, num, bol} from "./a.js";
let obj = {
    name: 'tom'
};

export {str, num, bol, obj}
// c.js
import {str, num, bol, obj} from "./b.js";

b.js 中的代碼還可以簡化,將import和export結合在一起使用。

// b.js
let obj = {
   name: 'tom'
};

export {str, num, bol} from "./a.js"; // 相當於先從a.js中導入,再導出
export {obj}; // 由於obj是本文件的數據,所以不能在上一行一起導出,需要單獨導出

c.js 對於 b.js 的使用不變。

6. 模塊的繼承

像上面那樣,如果一個模塊B導入了另一個模塊A中的數據,又將其導出,我們就說模塊B繼承了模塊A。模塊B可以繼承模塊A的所有數據,也可以有選擇性的繼承模塊B的部分數據。

// a.js
let str = 'hello';
let num = 108;
let bol = true;

export {str, num, bol}
// 繼承所有
export * from './a.js';
// 繼承部分併爲繼承的部分單獨命名使用
export {str as anotherStr} from './a.js'

注:

  • 模塊B繼承模塊A的所有時,不包含模塊A的的default變量。

7. import()

import命令會被 JavaScript 引擎靜態分析,先於模塊內的其他語句執行(import命令叫做“連接” binding 其實更合適)。但是如果代碼邏輯需要在導入數據之前做一些判斷或者其他代碼約束,import命令會報錯。

// module.js
export let num = 100;
// index.js
if(true) { 
    // 報錯
    import { num } from './module.js';
}

引擎處理import語句是在編譯時,這時不會去分析或執行if語句,所以import語句放在if代碼塊之中毫無意義,因此會報句法錯誤,而不是執行時錯誤。也就是說,import和export命令只能在模塊的頂層,不能在代碼塊之中。

這樣雖然有利於編譯器提高效率,但也導致無法在運行時加載模塊。在語法上,條件加載就不可能實現。

ES6提供import()函數來解決這個問題,可以實現條件加載或按需加載。

// index.js
if(true) {
    let res = import('./module.js');
    // res是一個promise對象,想要從中獲取數據需要,使用到then函數。
    res.then((module) => {
    console.log(module); // Module
    console.log(a.num); // 100
    });
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章