跳到主要內容
黯羽輕揚每天積累一點點

ES Module

免費2017-09-03#JS#es6 module#es6 import#es module and cjs#es module current state#js模块

等了 2 年的 Demo 終於能跑起來了

零、7 種模塊化方式

1. 分節注釋

<!--html-->
<script>
    // module1 code
    // module2 code
</script>

手動添加注釋來標明模塊範圍,類似於 CSS 裡的分節注釋:

/* -----------------
 * TOOLTIPS
 * ----------------- */

惟一作用是讓瀏覽代碼變得容易一些,迅速找到指定模塊,根本原因是單文件內容太長,已經遇到了維護的麻煩,所以手動插入一些錨點供快速跳轉

非常原始的模塊化方案,沒有實質性的好處(比如模塊作用域,依賴處理,模塊間錯誤隔離等等)

2. 多 script 標籤

<!--html-->
<script type="application/javascript" src="PATH/polyfill-vendor.js" ></script>
<script type="application/javascript" src="PATH/module1.js" ></script>
<script type="application/javascript" src="PATH/module2.js" ></script>
<script type="application/javascript" src="PATH/app.js" ></script>

把各個模塊拆分成獨立文件,有 3 個好處:

  • 通過控制資源加載順序來處理模塊依賴

  • 有模塊間錯誤隔離(module1.js 初始化執行異常不會阻斷 module2.jsapp.js 的執行)

  • 各模塊位於單獨文件,切實提高了維護體驗

但還存在 2 個問題:

  • 沒有模塊作用域

  • 資源請求數量與模塊化粒度相關,需要尋找性能與模塊化收益的平衡

3. IIFE

const myModule = (function (...deps){
   // JavaScript chunk
   return {hello : () => console.log('hello from myModule')};
})(dependencies);

可以作為補丁,配合其他方式使用,提供模塊作用域

4. Asynchronous module definition (AMD)

RequireJS 示例:

// polyfill-vendor.js
define(function () {
    // polyfills-vendor code
});

// module1.js
define(function () {
    //...
    return module1;
});
// module2.js
define(function () {
    //...
    return module2;
});

// app.js
define(['PATH/polyfill-vendor'] , function () {
    define(['PATH/module1', 'PATH/module2'] , function (module1, module2) {
        var APP = {};

        if (isModule1Needed) {
            APP.module1 = module1({param: 1});
        }
        APP.module2 = new module2({a: 42});
    });
});

一套比較完善的模塊定義方案,解決了模塊依賴問題,提供了模塊作用域,錯誤隔離/捕獲等方案。但看起來稍微有些冗餘

P.S. 另外還有 SeaJS(官網都沒了,不做介紹)。社區實現的模塊化補丁都只是過渡產物,目前看來,JS 似乎終將迎來模塊化特性

5. CommonJS

NodeJS 示例:

// polyfill-vendor.js
    // polyfills-vendor code

// module1.js
    // module1 code
    module.exports= module1;
// module2.js
module.exports= module2;

// app.js
require('PATH/polyfill-vendor');

const module1 = require('PATH/module1');
const module2 = require('PATH/module2');

const APP = {};
if(isModule1Needed){
    APP.module1 = module1({param:1});
}
APP.module2 = new module2({a: 42});

NodeJS 遵循 CommonJS 規範,文件即模塊,同樣是一套相對完善的方案,但不適用於瀏覽器環境

6. UMD (Universal Module Dependency)

UMD 示例:

(function (global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined' ? factory() :
    typeof define === 'function' && define.amd ? define(factory) :
(factory());
}(this, function () {
    // JavaScript chunk
    return {
       hello : () => console.log('hello from myModule')
    }
});

同樣是一個補丁,兼容 AMD 和 CommonJS 模塊定義,實現了模塊跨環境通用。出現 UMD 的根本原因是社區模塊定義方式太多了,開源模塊維護變得很麻煩(出現各種 MD issue,只好換上 UMD),所以迫切需要標準化,ES6 肩負著這個使命

P.S. 當然,開源模塊的維護問題還在(為了迎合 ES Module,又添上專門的 ES6 構建版本),但不會加劇,畢竟已經在標準化的路上了

7. ES6 Module

基本用法示例:

// myModule.js
export {fn1, fn2};

function fn1() {
    console.log('fn1');
}
function fn2() {
    console.log('fn2');
}

// app.js
import {fn1, fn2} from './myModule.js';
fn1();
fn2();

// index.html
<script type="module" src="app.js"></script>

注意

  • script 標籤必須聲明 type="module" 表明以 ES Module 方式解析內容,否則不會執行

  • import 模塊文件精確路徑./)、文件後綴名.js)及對應的MIME 類型必須要有,否則引入失敗

目前各大主流瀏覽器都提供了 ES Module 實驗性功能:

  • Safari 10.1.

  • Chrome Canary 60 – behind the Experimental Web Platform flag in chrome:flags.

  • Firefox 54 – behind the dom.moduleScripts.enabled setting in about:config.

  • Edge 15 – behind the Experimental JavaScript Features setting in about:flags.

等了 2 年的 Demo 終於能跑起來了:http://ayqy.net/temp/module/index.html

P.S. 一般都叫 ES Module,因為 Module 特性不存在多個版本,ES Module 指的就是 ES6 引入的 Module 特性

一、語法

export

// 基本語法
export { name1, name2, …, nameN };
export { variable1 as name1, variable2 as name2, …, nameN };
export let name1, name2, …, nameN; // also var, function
export let name1 = …, name2 = …, …, nameN; // also var, const

// 默認導出
export default expression;
export default function (…) { … } // also class, function*
export default function name1(…) { … } // also class, function*
export { name1 as default, … };

// 聚合導出
export * from …;
export { name1, name2, …, nameN } from …;
export { import1 as name1, import2 as name2, …, nameN } from …;

注意 exportexport default 的區別:

  • 每個模塊(/文件)只能有一個 export default,可以有多個 export

  • export default 後面可以接任意表達式,而 export 語法只有 3 種

例如:

// 不合法,語法錯誤
export {
    a: 1
};
// 而應該用 export { name1, name2, …, nameN };
let a = 1;
export {
    a
};
// 或者 export let name1 = …, name2 = …, …, nameN; // also var, const
export let a = 1;

默認導出

默認導出是一種特殊的導出形式,例如:

// module.js
export {fn1, fn2};
function fn1() {
    console.log('fn1');
}
function fn2() {
    console.log('fn2');
}
export default {
    a: 1
};
let b = 2;
export {
    b
};
export let c = 3;

// app.js
import * as m from './module.js';
console.log(m);
// 輸出結果
Module {
    b: 2,
    c: 3,
    default: {
        a: 1
    },
    fn1: ƒn1,
    fn2: ƒn2
}

默認導出被隔離在 Module 對象的 default 屬性裡,與其它 export 待遇不同

聚合導出

相當於 import + export,但不會在當前模塊作用域引入各個 API 變量(導入後直接導出,無法引用),僅起 API 聚合的中轉作用,例如:

// lib.js
let util = {name: 'util'};
let dialog = {name: 'core'};
let modal = {name: 'modal'};

export {
    util,
    dialog,
    modal
}

// module.js
console.log(`before export from lib: ${typeof dialog}`);
export * from './lib.js';
console.log(`after export from lib: ${typeof dialog}`);

前後都是 undefined,因為僅中轉,不在當前模塊作用域引入。而 import + export 會先引入,在當前模塊可用

import

// 引入 default export 內容
import defaultMember from "module-name";
// 引入所有 export 內容,包括 default,並打包到名為 name 的對象
import * as name from "module-name";
// 按名引入指定 export 內容
import { member } from "module-name";
import { member as alias } from "module-name";
import { member1, member2 } from "module-name";
import { member1, member2 as alias2 , [...] } from "module-name";
// 引入 default export 內容,同時按名引入指定 export 內容
import defaultMember, { member [ , [...] ] } from "module-name";
import defaultMember, * as name from "module-name";
// 不引入模塊裡暴露的東西,僅執行該模塊代碼
import "module-name";

最後一種比較有意思,被稱為Import a module for its side effects only,僅執行模塊代碼,不引入任何新東西(只有影響外部狀態的部分會生效,即副作用)

P.S. 關於 ES Module 語法的更多信息,請查看 [module_ES6 筆記 13](/articles/module-es6 筆記 13/),或者參考資料部分的 ES Module Spec

P.S. NodeJS 也在考慮支持 ES Module,但遇到了怎麼區分 CommonJS 模塊和 ES Module 的問題,還在討論中,更多信息請查看 ES Module Detection in Node

二、加載機制

ES6 模塊加載機制示意圖

也就是說:

  • type="module" 的資源相當於自帶 defer 效果(等到 HTML 文檔解析完畢才執行)

  • async 依然有效(資源加載完畢後立即執行,執行完繼續解析 HTML 文檔)

  • import 資源加載是並行的

自帶 defer 效果,與裸 script 默認行為(加載資源立即執行,並且阻塞 HTML 文檔解析)不同。另外,雖然 import 加載同級資源是並行的,但尋找下一級依賴的過程不可避免是順序串行的,這部分性能無法忽略,即便瀏覽器原生支持了 ES Module,也不能肆無忌憚地 import

類似於 CSS 中的 @import 規則,可能會發展出最佳實踐,在模塊化與加載性能之間尋求平衡

三、特點

1. 靜態機制

不能在 iftry-catch 語句,函數或者 eval 等地方使用 import,只能出現在模塊最外層

並且 import提升(Hosting)特性,如同變量聲明被提升到當前作用域頂部一樣,模塊裡聲明的 import 會被提升到模塊頂部

P.S. 靜態模塊機制有利於做解析/執行優化

2. 新 script 類型

需要用新的 script 類型屬性 type="module"。因為解析器沒有辦法推測出內容是不是 ES Module(比如沒有 import, export 關鍵字,也遵循嚴格模式,那麼算不算個模塊?)

另外,根據內容猜測存在多次解析的性能損耗

3. 模塊作用域

每個模塊有自己的作用域,模塊下的變量聲明不會暴露到全局

4. 默認開啟嚴格模式

this 不指向 global,而是 undefined

5. 支持 Data URI 和 Blob URI

import grape from 'data:text/javascript,export default "grape"';

// create an empty ES module
const scriptAsBlob = new Blob([''], {
    type: 'application/javascript'
});
const srcObjectURL = URL.createObjectURL(scriptAsBlob);
 // insert the ES module and listen events on it
const script = document.createElement('script');
script.type = 'module';
document.head.appendChild(script);
// start loading the script
script.src = srcObjectURL;

6. 受 CORS 限制

跨域的模塊資源無法 import 引入,也無法通過 script 標籤以模塊方式加載

7. HTTPS 資源無法 importHTTP 資源

類似於 HTTPS 頁面加載 HTTP 資源,會被 block 掉

8. 模塊是單例

不同於普通 script,引入的模塊是單例(只執行一次),無論是 import 還是通過 type="module"script 標籤引入

9. 請求模塊資源不帶身份憑證(credentials)

Fetch API 脾氣一樣,默認不帶身份證,需要給 script 標籤添上 crossorigin 屬性

四、問題

1. import 報錯

必須要給出精確的模塊文件路徑,否則不會執行模塊內容,並且 Chrome 60 連報錯都沒有

P.S. import 報錯目前各瀏覽器還存在差異

2. 模塊間錯誤隔離仍然是個問題

資源加載錯誤:動態插入 script 加載模塊,onerror 監聽加載異常

模塊初始化錯誤:window.onerror 全局捕獲,嘗試通過錯誤信息找出模塊名,記下模塊初始化失敗

3. 請求數量爆炸

比如 lodash demo,需要加載 600 多個文件

HTTP2 能緩解碎文件的問題,但從根源看,需要一套適用於生產環境的最佳實踐,規範模塊化的粒度

4. 動態 import

目前還沒有實現,import() API 專門解決這個問題,規範還處於草案第 3 階段,更多信息請查看 Native ECMAScript modules: dynamic import()

5. 模塊環境檢測

檢查當前執行環境是不是模塊:

const inModule = this === undefined;

看起來不很靠譜,但似乎只能這麼幹,因為 document.currentScript 在 ES Module 是 null,沒辦法做 type 檢查

五、降級方案

1. 特性檢測

過一遍特性檢測,由環境檢測 util 引入模塊,比較費勁且虧性能,例如 malyw/es-modules-utils

typeof 行不通,因為 import, export 是關鍵字,可以插入 type="module"script 標籤,加載空模塊(可以用 Blob URI 或者 Data URI),觸發 onload 說明支持

另外還有一種取巧的方法

<script type="module">
    window.__browserHasModules = true;
</script>

引入這樣的模塊做特性檢測,但因為 ES Module 自帶 defer 效果,為了保證執行順序,後續所有 JS 資源都要有 defer 屬性(包括用於降級的正常版本)

2. nomodule

nomodule 屬性,作用類似於 noscript 標籤,<script nomodule>console.log('僅在不支持 ES Module 的環境執行')</script>

但依賴瀏覽器支持,在不支持該屬性但支持 ES Module 的環境就有問題了(兩個都執行),已經添到了 HTML 規範,但目前兼容性還比較差

  • Firefox 最新版支持

  • Edge 不支持

  • Safari 10.1 不支持,但有 辦法 解決

  • Chrome 60 支持

關於降級方案的更多信息,請查看 Native ECMAScript modules: nomodule attribute for the migration

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論