1.瀏覽器加載
傳統方法
HTML 網頁中,瀏覽器通過
1 <!-- 頁面內嵌的腳本 --> 2 <script type="application/javascript"> 3 // module code 4 </script> 5 6 <!-- 外部腳本 --> 7 <script type="application/javascript" src="path/to/myModule.js"> 8 </script>
默認情況下,瀏覽器是同步加載 JavaScript 腳本,即渲染引擎遇到
如果腳本體積很大,下載和執行的時間就會很長,因此造成瀏覽器堵塞,用戶會感覺到瀏覽器“卡死”了,沒有任何響應。這顯然是很不好的體驗,所以瀏覽器允許腳本異步加載,下面就是兩種異步加載的語法
<script src="path/to/myModule.js" defer></script> <script src="path/to/myModule.js" async></script>
上面代碼中,<script>標簽打開defer或async屬性,腳本就會異步加載。渲染引擎遇到這一行命令,就會開始下載外部腳本,但不會等它下載和執行,而是直接執行后面的命令
defer與async的區別是:defer要等到整個頁面在內存中正常渲染結束(DOM 結構完全生成,以及其他腳本執行完成),才會執行;async一旦下載完,渲染引擎就會中斷渲染,執行這個腳本以后,再繼續渲染。一句話,defer是“渲染完再執行”,async是“下載完就執行”。另外,如果有多個defer腳本,會按照它們在頁面出現的順序加載,而多個async腳本是不能保證加載順序的
加載規則
瀏覽器加載 ES6 模塊,也使用<script>標簽,但是要加入type="module"屬性
<script type="module" src="./foo.js"></script>
瀏覽器對於帶有type="module"的<script>,都是異步加載,不會造成堵塞瀏覽器,即等到整個頁面渲染完,再執行模塊腳本,等同於打開了<script>標簽的defer屬性。<script>標簽的async屬性也可以打開
ES6 模塊也允許內嵌在網頁中,語法行為與加載外部腳本完全一致
1 <script type="module"> 2 import utils from "./utils.js"; 3 // other code 4 </script>
對於外部的模塊腳本(上例是foo.js),有幾點需要注意
代碼是在模塊作用域之中運行,而不是在全局作用域運行。模塊內部的頂層變量,外部不可見
- 模塊腳本自動采用嚴格模式,不管有沒有聲明use strict
- 模塊之中,可以使用import命令加載其他模塊(.js后綴不可省略,需要提供絕對 URL 或相對
URL),也可以使用export命令輸出對外接口 - 模塊之中,頂層的this關鍵字返回undefined,而不是指向window。也就是說,在模塊頂層使用this關鍵字,是無意義的
- 同一個模塊如果加載多次,將只執行一次
利用頂層的this等於undefined這個語法點,可以偵測當前代碼是否在 ES6 模塊之中
2.ES6 模塊與 CommonJS 模塊的差異
討論 Node 加載 ES6 模塊之前,必須了解 ES6 模塊與 CommonJS 模塊完全不同
- CommonJS 模塊輸出的是一個值的拷貝,ES6 模塊輸出的是值的引用
- CommonJS 模塊是運行時加載,ES6 模塊是編譯時輸出接口
第二個差異是因為 CommonJS 加載的是一個對象(即module.exports屬性),該對象只有在腳本運行完才會生成。而 ES6 模塊不是對象,它的對外接口只是一種靜態定義,在代碼靜態解析階段就會生成
下面重點解釋第一個差異
CommonJS 模塊輸出的是值的拷貝,也就是說,一旦輸出一個值,模塊內部的變化就影響不到這個值。請看下面這個模塊文件lib.js的例子
1 // lib.js 2 var counter = 3; 3 function incCounter() { 4 counter++; 5 } 6 module.exports = { 7 counter: counter, 8 incCounter: incCounter, 9 };
上面代碼輸出內部變量counter和改寫這個變量的內部方法incCounter。然后,在main.js里面加載這個模塊
1 // main.js 2 var mod = require('./lib'); 3 4 console.log(mod.counter); // 3 5 mod.incCounter(); 6 console.log(mod.counter); // 3
上面代碼說明,lib.js模塊加載以后,它的內部變化就影響不到輸出的mod.counter了。這是因為mod.counter是一個原始類型的值,會被緩存。除非寫成一個函數,才能得到內部變動后的值
1 // lib.js 2 var counter = 3; 3 function incCounter() { 4 counter++; 5 } 6 module.exports = { 7 get counter() { 8 return counter 9 }, 10 incCounter: incCounter, 11 };
上面代碼中,輸出的counter屬性實際上是一個取值器函數。現在再執行main.js,就可以正確讀取內部變量counter的變動了
$ node main.js
3
4
ES6 模塊的運行機制與 CommonJS 不一樣。JS 引擎對腳本靜態分析的時候,遇到模塊加載命令import,就會生成一個只讀引用。等到腳本真正執行時,再根據這個只讀引用,到被加載的那個模塊里面去取值。換句話說,ES6 的import有點像 Unix 系統的“符號連接”,原始值變了,import加載的值也會跟着變。因此,ES6 模塊是動態引用,並且不會緩存值,模塊里面的變量綁定其所在的模塊
由於 ES6 輸入的模塊變量,只是一個“符號連接”,所以這個變量是只讀的,對它進行重新賦值會報錯
最后,export通過接口,輸出的是同一個值。不同的腳本加載這個接口,得到的都是同樣的實例
3.Node 加載
概述
Node 對 ES6 模塊的處理比較麻煩,因為它有自己的 CommonJS 模塊格式,與 ES6 模塊格式是不兼容的。目前的解決方案是,將兩者分開,ES6 模塊和 CommonJS 采用各自的加載方案
Node 要求 ES6 模塊采用.mjs后綴文件名。也就是說,只要腳本文件里面使用import或者export命令,那么就必須采用.mjs后綴名。require命令不能加載.mjs文件,會報錯,只有import命令才可以加載.mjs文件。反過來,.mjs文件里面也不能使用require命令,必須使用import
目前,這項功能還在試驗階段。安裝 Node v8.5.0 或以上版本,要用–experimental-modules參數才能打開該功能
$ node --experimental-modules my-app.mjs
為了與瀏覽器的import加載規則相同,Node 的.mjs文件支持 URL 路徑
import './foo?query=1'; // 加載 ./foo 傳入參數 ?query=1
上面代碼中,腳本路徑帶有參數?query=1,Node 會按 URL 規則解讀。同一個腳本只要參數不同,就會被加載多次,並且保存成不同的緩存。由於這個原因,只要文件名中含有:、%、#、?等特殊字符,最好對這些字符進行轉義
目前,Node 的import命令只支持加載本地模塊(file:協議),不支持加載遠程模塊
如果模塊名不含路徑,那么import命令會去node_modules目錄尋找這個模塊
如果模塊名包含路徑,那么import命令會按照路徑去尋找這個名字的腳本文件
如果腳本文件省略了后綴名,比如import ‘./foo’,Node 會依次嘗試四個后綴名:./foo.mjs、./foo.js、./foo.json、./foo.node。如果這些腳本文件都不存在,Node 就會去加載./foo/package.json的main字段指定的腳本。如果./foo/package.json不存在或者沒有main字段,那么就會依次加載./foo/index.mjs、./foo/index.js、./foo/index.json、./foo/index.node。如果以上四個文件還是都不存在,就會拋出錯誤
最后,Node 的import命令是異步加載,這一點與瀏覽器的處理方法相同
內部變量
ES6 模塊應該是通用的,同一個模塊不用修改,就可以用在瀏覽器環境和服務器環境。為了達到這個目標,Node 規定 ES6 模塊之中不能使用 CommonJS 模塊的特有的一些內部變量
首先,就是this關鍵字。ES6 模塊之中,頂層的this指向undefined;CommonJS 模塊的頂層this指向當前模塊,這是兩者的一個重大差異
其次,以下這些頂層變量在 ES6 模塊之中都是不存在的
- arguments
- require
- module
- exports
- __filename
- __dirname
ES6 模塊加載 CommonJS 模塊
CommonJS 模塊的輸出都定義在module.exports這個屬性上面。Node 的import命令加載 CommonJS 模塊,Node 會自動將module.exports屬性,當作模塊的默認輸出,即等同於export default xxx
下面是一個 CommonJS 模塊
1 // a.js 2 module.exports = { 3 foo: 'hello', 4 bar: 'world' 5 }; 6 7 // 等同於 8 export default { 9 foo: 'hello', 10 bar: 'world' 11 };
import命令實際上輸入的是這樣一個對象{ default: module.exports }
下面是一些例子
1 // b.js 2 module.exports = null; 3 4 // es.js 5 import foo from './b'; 6 // foo = null; 7 8 import * as bar from './b'; 9 // bar = { default:null };
上面代碼中,es.js采用第二種寫法時,要通過bar.default這樣的寫法,才能拿到module.exports
1 // c.js 2 module.exports = function two() { 3 return 2; 4 }; 5 6 // es.js 7 import foo from './c'; 8 foo(); // 2 9 10 import * as bar from './c'; 11 bar.default(); // 2 12 bar(); // throws, bar is not a function
上面代碼中,bar本身是一個對象,不能當作函數調用,只能通過bar.default調用。
CommonJS 模塊的輸出緩存機制,在 ES6 加載方式下依然有效
CommonJS 模塊加載 ES6 模塊
CommonJS 模塊加載 ES6 模塊,不能使用require命令,而要使用import()函數。ES6 模塊的所有輸出接口,會成為輸入對象的屬性
4.循環加載
“循環加載”(circular dependency)指的是,a腳本的執行依賴b腳本,而b腳本的執行又依賴a腳本
1 // a.js 2 var b = require('b'); 3 4 // b.js 5 var a = require('a');
CommonJS 模塊的加載原理
CommonJS 的一個模塊,就是一個腳本文件。require命令第一次加載該腳本,就會執行整個腳本,然后在內存生成一個對象
1 { 2 id: '...', 3 exports: { ... }, 4 loaded: true, 5 ... 6 }
上面代碼就是 Node 內部加載模塊后生成的一個對象。該對象的id屬性是模塊名,exports屬性是模塊輸出的各個接口,loaded屬性是一個布爾值,表示該模塊的腳本是否執行完畢。其他還有很多屬性,這里都省略了。
以后需要用到這個模塊的時候,就會到exports屬性上面取值。即使再次執行require命令,也不會再次執行該模塊,而是到緩存之中取值。也就是說,CommonJS 模塊無論加載多少次,都只會在第一次加載時運行一次,以后再加載,就返回第一次運行的結果,除非手動清除系統緩存
CommonJS 模塊的循環加載
CommonJS 模塊的重要特性是加載時執行,即腳本代碼在require的時候,就會全部執行。一旦出現某個模塊被"循環加載",就只輸出已經執行的部分,還未執行的部分不會輸出
ES6 模塊的循環加載
ES6 處理“循環加載”與 CommonJS 有本質的不同。ES6 模塊是動態引用,如果使用import從一個模塊加載變量(即import foo from ‘foo’),那些變量不會被緩存,而是成為一個指向被加載模塊的引用,需要開發者自己保證,真正取值的時候能夠取到值
5.ES6 模塊的轉碼
瀏覽器目前還不支持 ES6 模塊,為了現在就能使用,可以將其轉為 ES5 的寫法。除了 Babel 可以用來轉碼之外,還有以下兩個方法,也可以用來轉碼
ES6 module transpiler
ES6 module transpiler是 square 公司開源的一個轉碼器,可以將 ES6 模塊轉為 CommonJS 模塊或 AMD 模塊的寫法,從而在瀏覽器中使用
首先,安裝這個轉碼器
$ npm install -g es6-module-transpiler
然后,使用compile-modules convert命令,將 ES6 模塊文件轉碼
$ compile-modules convert file1.js file2.js
-o參數可以指定轉碼后的文件名
$ compile-modules convert -o out.js file1.js
SystemJS
另一種解決方法是使用 SystemJS。它是一個墊片庫(polyfill),可以在瀏覽器內加載 ES6 模塊、AMD 模塊和 CommonJS 模塊,將其轉為 ES5 格式。它在后台調用的是 Google 的 Traceur 轉碼器
使用時,先在網頁內載入system.js文件
<script src="system.js"></script>
然后,使用System.import方法加載模塊文件
<script> System.import('./app.js'); </script>
上面代碼中的./app,指的是當前目錄下的 app.js 文件。它可以是 ES6 模塊文件,System.import會自動將其轉碼。
需要注意的是,System.import使用異步加載,返回一個 Promise 對象,可以針對這個對象編程。下面是一個模塊文件
1 // app/es6-file.js: 2 export class q { 3 constructor() { 4 this.es6 = 'hello'; 5 } 6 }
然后,在網頁內加載這個模塊文件
1 <script> 2 3 System.import('app/es6-file').then(function(m) { 4 console.log(new m.q().es6); // hello 5 }); 6 7 </script>
上面代碼中,System.import方法返回的是一個 Promise 對象,所以可以用then方法指定回調函數