# Module 的加載實現
上一章介紹了模塊的語法,本章介紹如何在瀏覽器和 Node.js 之中加載 ES6 模塊,以及實際開發中經常遇到的一些問題(比如循環加載)。
## 瀏覽器加載
### 傳統方法
HTML 網頁中,瀏覽器通過`<script>`標簽加載 JavaScript 腳本。
```html
<!-- 頁面內嵌的腳本 -->
<script type="application/javascript">
// module code
</script>
<!-- 外部腳本 -->
<script type="application/javascript" src="path/to/myModule.js">
</script>
```
上面代碼中,由于瀏覽器腳本的默認語言是 JavaScript,因此`type="application/javascript"`可以省略。
默認情況下,瀏覽器是同步加載 JavaScript 腳本,即渲染引擎遇到`<script>`標簽就會停下來,等到執行完腳本,再繼續向下渲染。如果是外部腳本,還必須加入腳本下載的時間。
如果腳本體積很大,下載和執行的時間就會很長,因此造成瀏覽器堵塞,用戶會感覺到瀏覽器“卡死”了,沒有任何響應。這顯然是很不好的體驗,所以瀏覽器允許腳本異步加載,下面就是兩種異步加載的語法。
```html
<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"`屬性。
```html
<script type="module" src="./foo.js"></script>
```
上面代碼在網頁中插入一個模塊`foo.js`,由于`type`屬性設為`module`,所以瀏覽器知道這是一個 ES6 模塊。
瀏覽器對于帶有`type="module"`的`<script>`,都是異步加載,不會造成堵塞瀏覽器,即等到整個頁面渲染完,再執行模塊腳本,等同于打開了`<script>`標簽的`defer`屬性。
```html
<script type="module" src="./foo.js"></script>
<!-- 等同于 -->
<script type="module" src="./foo.js" defer></script>
```
如果網頁有多個`<script type="module">`,它們會按照在頁面出現的順序依次執行。
`<script>`標簽的`async`屬性也可以打開,這時只要加載完成,渲染引擎就會中斷渲染立即執行。執行完成后,再恢復渲染。
```html
<script type="module" src="./foo.js" async></script>
```
一旦使用了`async`屬性,`<script type="module">`就不會按照在頁面出現的順序執行,而是只要該模塊加載完成,就執行該模塊。
ES6 模塊也允許內嵌在網頁中,語法行為與加載外部腳本完全一致。
```html
<script type="module">
import utils from "./utils.js";
// other code
</script>
```
舉例來說,jQuery 就支持模塊加載。
```html
<script type="module">
import $ from "./jquery/src/jquery.js";
$('#message').text('Hi from jQuery!');
</script>
```
對于外部的模塊腳本(上例是`foo.js`),有幾點需要注意。
- 代碼是在模塊作用域之中運行,而不是在全局作用域運行。模塊內部的頂層變量,外部不可見。
- 模塊腳本自動采用嚴格模式,不管有沒有聲明`use strict`。
- 模塊之中,可以使用`import`命令加載其他模塊(`.js`后綴不可省略,需要提供絕對 URL 或相對 URL),也可以使用`export`命令輸出對外接口。
- 模塊之中,頂層的`this`關鍵字返回`undefined`,而不是指向`window`。也就是說,在模塊頂層使用`this`關鍵字,是無意義的。
- 同一個模塊如果加載多次,將只執行一次。
下面是一個示例模塊。
```javascript
import utils from 'https://example.com/js/utils.js';
const x = 1;
console.log(x === window.x); //false
console.log(this === undefined); // true
```
利用頂層的`this`等于`undefined`這個語法點,可以偵測當前代碼是否在 ES6 模塊之中。
```javascript
const isNotModuleScript = this !== undefined;
```
## ES6 模塊與 CommonJS 模塊的差異
討論 Node.js 加載 ES6 模塊之前,必須了解 ES6 模塊與 CommonJS 模塊完全不同。
它們有三個重大差異。
- CommonJS 模塊輸出的是一個值的拷貝,ES6 模塊輸出的是值的引用。
- CommonJS 模塊是運行時加載,ES6 模塊是編譯時輸出接口。
- CommonJS 模塊的`require()`是同步加載模塊,ES6 模塊的`import`命令是異步加載,有一個獨立的模塊依賴的解析階段。
第二個差異是因為 CommonJS 加載的是一個對象(即`module.exports`屬性),該對象只有在腳本運行完才會生成。而 ES6 模塊不是對象,它的對外接口只是一種靜態定義,在代碼靜態解析階段就會生成。
下面重點解釋第一個差異。
CommonJS 模塊輸出的是值的拷貝,也就是說,一旦輸出一個值,模塊內部的變化就影響不到這個值。請看下面這個模塊文件`lib.js`的例子。
```javascript
// lib.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
counter: counter,
incCounter: incCounter,
};
```
上面代碼輸出內部變量`counter`和改寫這個變量的內部方法`incCounter`。然后,在`main.js`里面加載這個模塊。
```javascript
// main.js
var mod = require('./lib');
console.log(mod.counter); // 3
mod.incCounter();
console.log(mod.counter); // 3
```
上面代碼說明,`lib.js`模塊加載以后,它的內部變化就影響不到輸出的`mod.counter`了。這是因為`mod.counter`是一個原始類型的值,會被緩存。除非寫成一個函數,才能得到內部變動后的值。
```javascript
// lib.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
get counter() {
return counter
},
incCounter: incCounter,
};
```
上面代碼中,輸出的`counter`屬性實際上是一個取值器函數。現在再執行`main.js`,就可以正確讀取內部變量`counter`的變動了。
```bash
$ node main.js
3
4
```
ES6 模塊的運行機制與 CommonJS 不一樣。JS 引擎對腳本靜態分析的時候,遇到模塊加載命令`import`,就會生成一個只讀引用。等到腳本真正執行時,再根據這個只讀引用,到被加載的那個模塊里面去取值。換句話說,ES6 的`import`有點像 Unix 系統的“符號連接”,原始值變了,`import`加載的值也會跟著變。因此,ES6 模塊是動態引用,并且不會緩存值,模塊里面的變量綁定其所在的模塊。
還是舉上面的例子。
```javascript
// lib.js
export let counter = 3;
export function incCounter() {
counter++;
}
// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4
```
上面代碼說明,ES6 模塊輸入的變量`counter`是活的,完全反應其所在模塊`lib.js`內部的變化。
再舉一個出現在`export`一節中的例子。
```javascript
// m1.js
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);
// m2.js
import {foo} from './m1.js';
console.log(foo);
setTimeout(() => console.log(foo), 500);
```
上面代碼中,`m1.js`的變量`foo`,在剛加載時等于`bar`,過了 500 毫秒,又變為等于`baz`。
讓我們看看,`m2.js`能否正確讀取這個變化。
```bash
$ babel-node m2.js
bar
baz
```
上面代碼表明,ES6 模塊不會緩存運行結果,而是動態地去被加載的模塊取值,并且變量總是綁定其所在的模塊。
由于 ES6 輸入的模塊變量,只是一個“符號連接”,所以這個變量是只讀的,對它進行重新賦值會報錯。
```javascript
// lib.js
export let obj = {};
// main.js
import { obj } from './lib';
obj.prop = 123; // OK
obj = {}; // TypeError
```
上面代碼中,`main.js`從`lib.js`輸入變量`obj`,可以對`obj`添加屬性,但是重新賦值就會報錯。因為變量`obj`指向的地址是只讀的,不能重新賦值,這就好比`main.js`創造了一個名為`obj`的`const`變量。
最后,`export`通過接口,輸出的是同一個值。不同的腳本加載這個接口,得到的都是同樣的實例。
```javascript
// mod.js
function C() {
this.sum = 0;
this.add = function () {
this.sum += 1;
};
this.show = function () {
console.log(this.sum);
};
}
export let c = new C();
```
上面的腳本`mod.js`,輸出的是一個`C`的實例。不同的腳本加載這個模塊,得到的都是同一個實例。
```javascript
// x.js
import {c} from './mod';
c.add();
// y.js
import {c} from './mod';
c.show();
// main.js
import './x';
import './y';
```
現在執行`main.js`,輸出的是`1`。
```bash
$ babel-node main.js
1
```
這就證明了`x.js`和`y.js`加載的都是`C`的同一個實例。
## Node.js 的模塊加載方法
### 概述
JavaScript 現在有兩種模塊。一種是 ES6 模塊,簡稱 ESM;另一種是 CommonJS 模塊,簡稱 CJS。
CommonJS 模塊是 Node.js 專用的,與 ES6 模塊不兼容。語法上面,兩者最明顯的差異是,CommonJS 模塊使用`require()`和`module.exports`,ES6 模塊使用`import`和`export`。
它們采用不同的加載方案。從 Node.js v13.2 版本開始,Node.js 已經默認打開了 ES6 模塊支持。
Node.js 要求 ES6 模塊采用`.mjs`后綴文件名。也就是說,只要腳本文件里面使用`import`或者`export`命令,那么就必須采用`.mjs`后綴名。Node.js 遇到`.mjs`文件,就認為它是 ES6 模塊,默認啟用嚴格模式,不必在每個模塊文件頂部指定`"use strict"`。
如果不希望將后綴名改成`.mjs`,可以在項目的`package.json`文件中,指定`type`字段為`module`。
```javascript
{
"type": "module"
}
```
一旦設置了以后,該目錄里面的 JS 腳本,就被解釋用 ES6 模塊。
```bash
# 解釋成 ES6 模塊
$ node my-app.js
```
如果這時還要使用 CommonJS 模塊,那么需要將 CommonJS 腳本的后綴名都改成`.cjs`。如果沒有`type`字段,或者`type`字段為`commonjs`,則`.js`腳本會被解釋成 CommonJS 模塊。
總結為一句話:`.mjs`文件總是以 ES6 模塊加載,`.cjs`文件總是以 CommonJS 模塊加載,`.js`文件的加載取決于`package.json`里面`type`字段的設置。
注意,ES6 模塊與 CommonJS 模塊盡量不要混用。`require`命令不能加載`.mjs`文件,會報錯,只有`import`命令才可以加載`.mjs`文件。反過來,`.mjs`文件里面也不能使用`require`命令,必須使用`import`。
### package.json 的 main 字段
`package.json`文件有兩個字段可以指定模塊的入口文件:`main`和`exports`。比較簡單的模塊,可以只使用`main`字段,指定模塊加載的入口文件。
```javascript
// ./node_modules/es-module-package/package.json
{
"type": "module",
"main": "./src/index.js"
}
```
上面代碼指定項目的入口腳本為`./src/index.js`,它的格式為 ES6 模塊。如果沒有`type`字段,`index.js`就會被解釋為 CommonJS 模塊。
然后,`import`命令就可以加載這個模塊。
```javascript
// ./my-app.mjs
import { something } from 'es-module-package';
// 實際加載的是 ./node_modules/es-module-package/src/index.js
```
上面代碼中,運行該腳本以后,Node.js 就會到`./node_modules`目錄下面,尋找`es-module-package`模塊,然后根據該模塊`package.json`的`main`字段去執行入口文件。
這時,如果用 CommonJS 模塊的`require()`命令去加載`es-module-package`模塊會報錯,因為 CommonJS 模塊不能處理`export`命令。
### package.json 的 exports 字段
`exports`字段的優先級高于`main`字段。它有多種用法。
(1)子目錄別名
`package.json`文件的`exports`字段可以指定腳本或子目錄的別名。
```javascript
// ./node_modules/es-module-package/package.json
{
"exports": {
"./submodule": "./src/submodule.js"
}
}
```
上面的代碼指定`src/submodule.js`別名為`submodule`,然后就可以從別名加載這個文件。
```javascript
import submodule from 'es-module-package/submodule';
// 加載 ./node_modules/es-module-package/src/submodule.js
```
下面是子目錄別名的例子。
```javascript
// ./node_modules/es-module-package/package.json
{
"exports": {
"./features/": "./src/features/"
}
}
import feature from 'es-module-package/features/x.js';
// 加載 ./node_modules/es-module-package/src/features/x.js
```
如果沒有指定別名,就不能用“模塊+腳本名”這種形式加載腳本。
```javascript
// 報錯
import submodule from 'es-module-package/private-module.js';
// 不報錯
import submodule from './node_modules/es-module-package/private-module.js';
```
(2)main 的別名
`exports`字段的別名如果是`.`,就代表模塊的主入口,優先級高于`main`字段,并且可以直接簡寫成`exports`字段的值。
```javascript
{
"exports": {
".": "./main.js"
}
}
// 等同于
{
"exports": "./main.js"
}
```
由于`exports`字段只有支持 ES6 的 Node.js 才認識,所以可以用來兼容舊版本的 Node.js。
```javascript
{
"main": "./main-legacy.cjs",
"exports": {
".": "./main-modern.cjs"
}
}
```
上面代碼中,老版本的 Node.js (不支持 ES6 模塊)的入口文件是`main-legacy.cjs`,新版本的 Node.js 的入口文件是`main-modern.cjs`。
**(3)條件加載**
利用`.`這個別名,可以為 ES6 模塊和 CommonJS 指定不同的入口。目前,這個功能需要在 Node.js 運行的時候,打開`--experimental-conditional-exports`標志。
```javascript
{
"type": "module",
"exports": {
".": {
"require": "./main.cjs",
"default": "./main.js"
}
}
}
```
上面代碼中,別名`.`的`require`條件指定`require()`命令的入口文件(即 CommonJS 的入口),`default`條件指定其他情況的入口(即 ES6 的入口)。
上面的寫法可以簡寫如下。
```javascript
{
"exports": {
"require": "./main.cjs",
"default": "./main.js"
}
}
```
注意,如果同時還有其他別名,就不能采用簡寫,否則或報錯。
```javascript
{
// 報錯
"exports": {
"./feature": "./lib/feature.js",
"require": "./main.cjs",
"default": "./main.js"
}
}
```
### CommonJS 模塊加載 ES6 模塊
CommonJS 的`require()`命令不能加載 ES6 模塊,會報錯,只能使用`import()`這個方法加載。
```javascript
(async () => {
await import('./my-app.mjs');
})();
```
上面代碼可以在 CommonJS 模塊中運行。
`require()`不支持 ES6 模塊的一個原因是,它是同步加載,而 ES6 模塊內部可以使用頂層`await`命令,導致無法被同步加載。
### ES6 模塊加載 CommonJS 模塊
ES6 模塊的`import`命令可以加載 CommonJS 模塊,但是只能整體加載,不能只加載單一的輸出項。
```javascript
// 正確
import packageMain from 'commonjs-package';
// 報錯
import { method } from 'commonjs-package';
```
這是因為 ES6 模塊需要支持靜態代碼分析,而 CommonJS 模塊的輸出接口是`module.exports`,是一個對象,無法被靜態分析,所以只能整體加載。
加載單一的輸出項,可以寫成下面這樣。
```javascript
import packageMain from 'commonjs-package';
const { method } = packageMain;
```
還有一種變通的加載方法,就是使用 Node.js 內置的`module.createRequire()`方法。
```javascript
// cjs.cjs
module.exports = 'cjs';
// esm.mjs
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const cjs = require('./cjs.cjs');
cjs === 'cjs'; // true
```
上面代碼中,ES6 模塊通過`module.createRequire()`方法可以加載 CommonJS 模塊。但是,這種寫法等于將 ES6 和 CommonJS 混在一起了,所以不建議使用。
### 同時支持兩種格式的模塊
一個模塊同時要支持 CommonJS 和 ES6 兩種格式,也很容易。
如果原始模塊是 ES6 格式,那么需要給出一個整體輸出接口,比如`export default obj`,使得 CommonJS 可以用`import()`進行加載。
如果原始模塊是 CommonJS 格式,那么可以加一個包裝層。
```javascript
import cjsModule from '../index.js';
export const foo = cjsModule.foo;
```
上面代碼先整體輸入 CommonJS 模塊,然后再根據需要輸出具名接口。
你可以把這個文件的后綴名改為`.mjs`,或者將它放在一個子目錄,再在這個子目錄里面放一個單獨的`package.json`文件,指明`{ type: "module" }`。
另一種做法是在`package.json`文件的`exports`字段,指明兩種格式模塊各自的加載入口。
```javascript
"exports":{
"require": "./index.js",
"import": "./esm/wrapper.js"
}
```
上面代碼指定`require()`和`import`,加載該模塊會自動切換到不一樣的入口文件。
### Node.js 的內置模塊
Node.js 的內置模塊可以整體加載,也可以加載指定的輸出項。
```javascript
// 整體加載
import EventEmitter from 'events';
const e = new EventEmitter();
// 加載指定的輸出項
import { readFile } from 'fs';
readFile('./foo.txt', (err, source) => {
if (err) {
console.error(err);
} else {
console.log(source);
}
});
```
### 加載路徑
ES6 模塊的加載路徑必須給出腳本的完整路徑,不能省略腳本的后綴名。`import`命令和`package.json`文件的`main`字段如果省略腳本的后綴名,會報錯。
```javascript
// ES6 模塊中將報錯
import { something } from './index';
```
為了與瀏覽器的`import`加載規則相同,Node.js 的`.mjs`文件支持 URL 路徑。
```javascript
import './foo.mjs?query=1'; // 加載 ./foo 傳入參數 ?query=1
```
上面代碼中,腳本路徑帶有參數`?query=1`,Node 會按 URL 規則解讀。同一個腳本只要參數不同,就會被加載多次,并且保存成不同的緩存。由于這個原因,只要文件名中含有`:`、`%`、`#`、`?`等特殊字符,最好對這些字符進行轉義。
目前,Node.js 的`import`命令只支持加載本地模塊(`file:`協議)和`data:`協議,不支持加載遠程模塊。另外,腳本路徑只支持相對路徑,不支持絕對路徑(即以`/`或`//`開頭的路徑)。
### 內部變量
ES6 模塊應該是通用的,同一個模塊不用修改,就可以用在瀏覽器環境和服務器環境。為了達到這個目標,Node.js 規定 ES6 模塊之中不能使用 CommonJS 模塊的特有的一些內部變量。
首先,就是`this`關鍵字。ES6 模塊之中,頂層的`this`指向`undefined`;CommonJS 模塊的頂層`this`指向當前模塊,這是兩者的一個重大差異。
其次,以下這些頂層變量在 ES6 模塊之中都是不存在的。
- `arguments`
- `require`
- `module`
- `exports`
- `__filename`
- `__dirname`
## 循環加載
“循環加載”(circular dependency)指的是,`a`腳本的執行依賴`b`腳本,而`b`腳本的執行又依賴`a`腳本。
```javascript
// a.js
var b = require('b');
// b.js
var a = require('a');
```
通常,“循環加載”表示存在強耦合,如果處理不好,還可能導致遞歸加載,使得程序無法執行,因此應該避免出現。
但是實際上,這是很難避免的,尤其是依賴關系復雜的大項目,很容易出現`a`依賴`b`,`b`依賴`c`,`c`又依賴`a`這樣的情況。這意味著,模塊加載機制必須考慮“循環加載”的情況。
對于 JavaScript 語言來說,目前最常見的兩種模塊格式 CommonJS 和 ES6,處理“循環加載”的方法是不一樣的,返回的結果也不一樣。
### CommonJS 模塊的加載原理
介紹 ES6 如何處理“循環加載”之前,先介紹目前最流行的 CommonJS 模塊格式的加載原理。
CommonJS 的一個模塊,就是一個腳本文件。`require`命令第一次加載該腳本,就會執行整個腳本,然后在內存生成一個對象。
```javascript
{
id: '...',
exports: { ... },
loaded: true,
...
}
```
上面代碼就是 Node 內部加載模塊后生成的一個對象。該對象的`id`屬性是模塊名,`exports`屬性是模塊輸出的各個接口,`loaded`屬性是一個布爾值,表示該模塊的腳本是否執行完畢。其他還有很多屬性,這里都省略了。
以后需要用到這個模塊的時候,就會到`exports`屬性上面取值。即使再次執行`require`命令,也不會再次執行該模塊,而是到緩存之中取值。也就是說,CommonJS 模塊無論加載多少次,都只會在第一次加載時運行一次,以后再加載,就返回第一次運行的結果,除非手動清除系統緩存。
### CommonJS 模塊的循環加載
CommonJS 模塊的重要特性是加載時執行,即腳本代碼在`require`的時候,就會全部執行。一旦出現某個模塊被"循環加載",就只輸出已經執行的部分,還未執行的部分不會輸出。
讓我們來看,Node [官方文檔](https://nodejs.org/api/modules.html#modules_cycles)里面的例子。腳本文件`a.js`代碼如下。
```javascript
exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 執行完畢');
```
上面代碼之中,`a.js`腳本先輸出一個`done`變量,然后加載另一個腳本文件`b.js`。注意,此時`a.js`代碼就停在這里,等待`b.js`執行完畢,再往下執行。
再看`b.js`的代碼。
```javascript
exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 執行完畢');
```
上面代碼之中,`b.js`執行到第二行,就會去加載`a.js`,這時,就發生了“循環加載”。系統會去`a.js`模塊對應對象的`exports`屬性取值,可是因為`a.js`還沒有執行完,從`exports`屬性只能取回已經執行的部分,而不是最后的值。
`a.js`已經執行的部分,只有一行。
```javascript
exports.done = false;
```
因此,對于`b.js`來說,它從`a.js`只輸入一個變量`done`,值為`false`。
然后,`b.js`接著往下執行,等到全部執行完畢,再把執行權交還給`a.js`。于是,`a.js`接著往下執行,直到執行完畢。我們寫一個腳本`main.js`,驗證這個過程。
```javascript
var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);
```
執行`main.js`,運行結果如下。
```bash
$ node main.js
在 b.js 之中,a.done = false
b.js 執行完畢
在 a.js 之中,b.done = true
a.js 執行完畢
在 main.js 之中, a.done=true, b.done=true
```
上面的代碼證明了兩件事。一是,在`b.js`之中,`a.js`沒有執行完畢,只執行了第一行。二是,`main.js`執行到第二行時,不會再次執行`b.js`,而是輸出緩存的`b.js`的執行結果,即它的第四行。
```javascript
exports.done = true;
```
總之,CommonJS 輸入的是被輸出值的拷貝,不是引用。
另外,由于 CommonJS 模塊遇到循環加載時,返回的是當前已經執行的部分的值,而不是代碼全部執行后的值,兩者可能會有差異。所以,輸入變量的時候,必須非常小心。
```javascript
var a = require('a'); // 安全的寫法
var foo = require('a').foo; // 危險的寫法
exports.good = function (arg) {
return a.foo('good', arg); // 使用的是 a.foo 的最新值
};
exports.bad = function (arg) {
return foo('bad', arg); // 使用的是一個部分加載時的值
};
```
上面代碼中,如果發生循環加載,`require('a').foo`的值很可能后面會被改寫,改用`require('a')`會更保險一點。
### ES6 模塊的循環加載
ES6 處理“循環加載”與 CommonJS 有本質的不同。ES6 模塊是動態引用,如果使用`import`從一個模塊加載變量(即`import foo from 'foo'`),那些變量不會被緩存,而是成為一個指向被加載模塊的引用,需要開發者自己保證,真正取值的時候能夠取到值。
請看下面這個例子。
```javascript
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';
// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';
```
上面代碼中,`a.mjs`加載`b.mjs`,`b.mjs`又加載`a.mjs`,構成循環加載。執行`a.mjs`,結果如下。
```bash
$ node --experimental-modules a.mjs
b.mjs
ReferenceError: foo is not defined
```
上面代碼中,執行`a.mjs`以后會報錯,`foo`變量未定義,這是為什么?
讓我們一行行來看,ES6 循環加載是怎么處理的。首先,執行`a.mjs`以后,引擎發現它加載了`b.mjs`,因此會優先執行`b.mjs`,然后再執行`a.mjs`。接著,執行`b.mjs`的時候,已知它從`a.mjs`輸入了`foo`接口,這時不會去執行`a.mjs`,而是認為這個接口已經存在了,繼續往下執行。執行到第三行`console.log(foo)`的時候,才發現這個接口根本沒定義,因此報錯。
解決這個問題的方法,就是讓`b.mjs`運行的時候,`foo`已經有定義了。這可以通過將`foo`寫成函數來解決。
```javascript
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
function foo() { return 'foo' }
export {foo};
// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo());
function bar() { return 'bar' }
export {bar};
```
這時再執行`a.mjs`就可以得到預期結果。
```bash
$ node --experimental-modules a.mjs
b.mjs
foo
a.mjs
bar
```
這是因為函數具有提升作用,在執行`import {bar} from './b'`時,函數`foo`就已經有定義了,所以`b.mjs`加載的時候不會報錯。這也意味著,如果把函數`foo`改寫成函數表達式,也會報錯。
```javascript
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
const foo = () => 'foo';
export {foo};
```
上面代碼的第四行,改成了函數表達式,就不具有提升作用,執行就會報錯。
我們再來看 ES6 模塊加載器[SystemJS](https://github.com/ModuleLoader/es6-module-loader/blob/master/docs/circular-references-bindings.md)給出的一個例子。
```javascript
// even.js
import { odd } from './odd'
export var counter = 0;
export function even(n) {
counter++;
return n === 0 || odd(n - 1);
}
// odd.js
import { even } from './even';
export function odd(n) {
return n !== 0 && even(n - 1);
}
```
上面代碼中,`even.js`里面的函數`even`有一個參數`n`,只要不等于 0,就會減去 1,傳入加載的`odd()`。`odd.js`也會做類似操作。
運行上面這段代碼,結果如下。
```javascript
$ babel-node
> import * as m from './even.js';
> m.even(10);
true
> m.counter
6
> m.even(20)
true
> m.counter
17
```
上面代碼中,參數`n`從 10 變為 0 的過程中,`even()`一共會執行 6 次,所以變量`counter`等于 6。第二次調用`even()`時,參數`n`從 20 變為 0,`even()`一共會執行 11 次,加上前面的 6 次,所以變量`counter`等于 17。
這個例子要是改寫成 CommonJS,就根本無法執行,會報錯。
```javascript
// even.js
var odd = require('./odd');
var counter = 0;
exports.counter = counter;
exports.even = function (n) {
counter++;
return n == 0 || odd(n - 1);
}
// odd.js
var even = require('./even').even;
module.exports = function (n) {
return n != 0 && even(n - 1);
}
```
上面代碼中,`even.js`加載`odd.js`,而`odd.js`又去加載`even.js`,形成“循環加載”。這時,執行引擎就會輸出`even.js`已經執行的部分(不存在任何結果),所以在`odd.js`之中,變量`even`等于`undefined`,等到后面調用`even(n - 1)`就會報錯。
```bash
$ node
> var m = require('./even');
> m.even(10)
TypeError: even is not a function
```
- 前言
- ECMAScript 6簡介
- let 和 const 命令
- 變量的解構賦值
- 字符串的擴展
- 字符串的新增方法
- 正則的擴展
- 數值的擴展
- 函數的擴展
- 數組的擴展
- 對象的擴展
- 對象的新增方法
- Symbol
- Set 和 Map 數據結構
- Proxy
- Reflect
- Promise 對象
- Iterator 和 for...of 循環
- Generator 函數的語法
- Generator 函數的異步應用
- async 函數
- Class 的基本語法
- Class 的繼承
- Module 的語法
- Module 的加載實現
- 編程風格
- 讀懂規格
- 異步遍歷器
- ArrayBuffer
- 最新提案
- Decorator
- 參考鏈接