ES6 Module 的加載實(shí)現(xiàn)

2020-06-11 15:03 更新

1. 瀏覽器加載

傳統(tǒng)方法

HTML 網(wǎng)頁中,瀏覽器通過<script>標(biāo)簽加載 JavaScript 腳本。

<!-- 頁面內(nèi)嵌的腳本 -->
<script type="application/javascript">
  // module code
</script>


<!-- 外部腳本 -->
<script type="application/javascript" src="path/to/myModule.js">
</script>

上面代碼中,由于瀏覽器腳本的默認(rèn)語言是 JavaScript,因此 type="application/javascript"可以省略。

默認(rèn)情況下,瀏覽器是同步加載 JavaScript 腳本,即渲染引擎遇到 標(biāo)簽就會(huì)停下來,等到執(zhí)行完腳本,再繼續(xù)向下渲染。如果是外部腳本,還必須加入腳本下載的時(shí)間。

如果腳本體積很大,下載和執(zhí)行的時(shí)間就會(huì)很長,因此造成瀏覽器堵塞,用戶會(huì)感覺到瀏覽器“卡死”了,沒有任何響應(yīng)。這顯然是很不好的體驗(yàn),所以瀏覽器允許腳本異步加載,下面就是兩種異步加載的語法。

<script src="path/to/myModule.js" defer></script>
<script src="path/to/myModule.js" async></script>

上面代碼中,<script>標(biāo)簽打開 deferasync 屬性,腳本就會(huì)異步加載。渲染引擎遇到這一行命令,就會(huì)開始下載外部腳本,但不會(huì)等它下載和執(zhí)行,而是直接執(zhí)行后面的命令。

deferasync 的區(qū)別是: defer要等到整個(gè)頁面在內(nèi)存中正常渲染結(jié)束(DOM 結(jié)構(gòu)完全生成,以及其他腳本執(zhí)行完成),才會(huì)執(zhí)行;async一旦下載完,渲染引擎就會(huì)中斷渲染,執(zhí)行這個(gè)腳本以后,再繼續(xù)渲染。一句話, defer是“渲染完再執(zhí)行”, async 是“下載完就執(zhí)行”。另外,如果有多個(gè) defer 腳本,會(huì)按照它們在頁面出現(xiàn)的順序加載,而多個(gè)async 腳本是不能保證加載順序的。

加載規(guī)則

瀏覽器加載 ES6 模塊,也使用<script>標(biāo)簽,但是要加入type="module" 屬性。

<script type="module" src="./foo.js"></script>

上面代碼在網(wǎng)頁中插入一個(gè)模塊foo.js ,由于 type 屬性設(shè)為 module,所以瀏覽器知道這是一個(gè) ES6 模塊。

瀏覽器對(duì)于帶有 type="module"的 ,都是異步加載,不會(huì)造成堵塞瀏覽器,即等到整個(gè)頁面渲染完,再執(zhí)行模塊腳本,等同于打開了<script>標(biāo)簽的 defer屬性。

<script type="module" src="./foo.js"></script>
<!-- 等同于 -->
<script type="module" src="./foo.js" defer></script>

如果網(wǎng)頁有多個(gè)<script type="module"> ,它們會(huì)按照在頁面出現(xiàn)的順序依次執(zhí)行。

<script>標(biāo)簽的async屬性也可以打開,這時(shí)只要加載完成,渲染引擎就會(huì)中斷渲染立即執(zhí)行。執(zhí)行完成后,再恢復(fù)渲染。

<script type="module" src="./foo.js" async></script>

一旦使用了async 屬性,<script type="module">就不會(huì)按照在頁面出現(xiàn)的順序執(zhí)行,而是只要該模塊加載完成,就執(zhí)行該模塊。

ES6 模塊也允許內(nèi)嵌在網(wǎng)頁中,語法行為與加載外部腳本完全一致。

<script type="module">
  import utils from "./utils.js";


  // other code
</script>

舉例來說,jQuery 就支持模塊加載。

<script type="module">
  import $ from "./jquery/src/jquery.js";
  $('#message').text('Hi from jQuery!');
</script>

對(duì)于外部的模塊腳本(上例是 foo.js),有幾點(diǎn)需要注意。

  • 代碼是在模塊作用域之中運(yùn)行,而不是在全局作用域運(yùn)行。模塊內(nèi)部的頂層變量,外部不可見。
  • 模塊腳本自動(dòng)采用嚴(yán)格模式,不管有沒有聲明 use strict。
  • 模塊之中,可以使用 import命令加載其他模塊( .js 后綴不可省略,需要提供絕對(duì) URL 或相對(duì) URL),也可以使用 export 命令輸出對(duì)外接口。
  • 模塊之中,頂層的 this 關(guān)鍵字返回 undefined,而不是指向 window 。也就是說,在模塊頂層使用this關(guān)鍵字,是無意義的。
  • 同一個(gè)模塊如果加載多次,將只執(zhí)行一次。

下面是一個(gè)示例模塊。

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這個(gè)語法點(diǎn),可以偵測當(dāng)前代碼是否在 ES6 模塊之中。

const isNotModuleScript = this !== undefined;

2. ES6 模塊與 CommonJS 模塊的差異

討論 Node.js 加載 ES6 模塊之前,必須了解 ES6 模塊與 CommonJS 模塊完全不同。

它們有兩個(gè)重大差異。

  • CommonJS 模塊輸出的是一個(gè)值的拷貝,ES6 模塊輸出的是值的引用。
  • CommonJS 模塊是運(yùn)行時(shí)加載,ES6 模塊是編譯時(shí)輸出接口。

第二個(gè)差異是因?yàn)?CommonJS 加載的是一個(gè)對(duì)象(即 module.exports 屬性),該對(duì)象只有在腳本運(yùn)行完才會(huì)生成。而 ES6 模塊不是對(duì)象,它的對(duì)外接口只是一種靜態(tài)定義,在代碼靜態(tài)解析階段就會(huì)生成。

下面重點(diǎn)解釋第一個(gè)差異。

CommonJS 模塊輸出的是值的拷貝,也就是說,一旦輸出一個(gè)值,模塊內(nèi)部的變化就影響不到這個(gè)值。請(qǐng)看下面這個(gè)模塊文件 lib.js的例子。

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

上面代碼輸出內(nèi)部變量 counter 和改寫這個(gè)變量的內(nèi)部方法 incCounter。然后,在main.js 里面加載這個(gè)模塊。

// main.js
var mod = require('./lib');


console.log(mod.counter);  // 3
mod.incCounter();
console.log(mod.counter); // 3

上面代碼說明,lib.js模塊加載以后,它的內(nèi)部變化就影響不到輸出的mod.counter了。這是因?yàn)?mod.counter 是一個(gè)原始類型的值,會(huì)被緩存。除非寫成一個(gè)函數(shù),才能得到內(nèi)部變動(dòng)后的值。

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  get counter() {
    return counter
  },
  incCounter: incCounter,
};

上面代碼中,輸出的 counter 屬性實(shí)際上是一個(gè)取值器函數(shù)?,F(xiàn)在再執(zhí)行 main.js ,就可以正確讀取內(nèi)部變量counter的變動(dòng)了。

$ node main.js
3
4

ES6 模塊的運(yùn)行機(jī)制與 CommonJS 不一樣。JS 引擎對(duì)腳本靜態(tài)分析的時(shí)候,遇到模塊加載命令import ,就會(huì)生成一個(gè)只讀引用。等到腳本真正執(zhí)行時(shí),再根據(jù)這個(gè)只讀引用,到被加載的那個(gè)模塊里面去取值。換句話說,ES6 的import有點(diǎn)像 Unix 系統(tǒng)的“符號(hào)連接”,原始值變了,import 加載的值也會(huì)跟著變。因此,ES6 模塊是動(dòng)態(tài)引用,并且不會(huì)緩存值,模塊里面的變量綁定其所在的模塊。

還是舉上面的例子。

// 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 是活的,完全反應(yīng)其所在模塊lib.js內(nèi)部的變化。

再舉一個(gè)出現(xiàn)在export一節(jié)中的例子。

// 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,在剛加載時(shí)等于 bar,過了 500 毫秒,又變?yōu)榈扔?baz 。`

讓我們看看, m2.js 能否正確讀取這個(gè)變化。

$ babel-node m2.js


bar
baz

上面代碼表明,ES6 模塊不會(huì)緩存運(yùn)行結(jié)果,而是動(dòng)態(tài)地去被加載的模塊取值,并且變量總是綁定其所在的模塊。

由于 ES6 輸入的模塊變量,只是一個(gè)“符號(hào)連接”,所以這個(gè)變量是只讀的,對(duì)它進(jìn)行重新賦值會(huì)報(bào)錯(cuò)。

// lib.js
export let obj = {};


// main.js
import { obj } from './lib';


obj.prop = 123; // OK
obj = {}; // TypeError

上面代碼中, main.js 從 lib.js 輸入變量 obj ,可以對(duì) obj 添加屬性,但是重新賦值就會(huì)報(bào)錯(cuò)。因?yàn)樽兞?obj 指向的地址是只讀的,不能重新賦值,這就好比 main.js 創(chuàng)造了一個(gè)名為 obj 的 const 變量。

最后, export 通過接口,輸出的是同一個(gè)值。不同的腳本加載這個(gè)接口,得到的都是同樣的實(shí)例。

// 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 ,輸出的是一個(gè) C 的實(shí)例。不同的腳本加載這個(gè)模塊,得到的都是同一個(gè)實(shí)例。

// x.js
import {c} from './mod';
c.add();


// y.js
import {c} from './mod';
c.show();


// main.js
import './x';
import './y';

現(xiàn)在執(zhí)行 main.js ,輸出的是 1 。

$ babel-node main.js
1

這就證明了 x.js 和 y.js 加載的都是 C 的同一個(gè)實(shí)例。

3. Node.js 加載

概述

Node.js 對(duì) ES6 模塊的處理比較麻煩,因?yàn)樗凶约旱?CommonJS 模塊格式,與 ES6 模塊格式是不兼容的。目前的解決方案是,將兩者分開,ES6 模塊和 CommonJS 采用各自的加載方案。從 v13.2 版本開始,Node.js 已經(jīng)默認(rèn)打開了 ES6 模塊支持。

Node.js 要求 ES6 模塊采用 .mjs 后綴文件名。也就是說,只要腳本文件里面使用 import或者 export 命令,那么就必須采用 .mjs后綴名。Node.js 遇到 .mjs文件,就認(rèn)為它是 ES6 模塊,默認(rèn)啟用嚴(yán)格模式,不必在每個(gè)模塊文件頂部指定 "use strict"。

如果不希望將后綴名改成 .mjs ,可以在項(xiàng)目的 package.json 文件中,指定 type 字段為 module 。

{
   "type": "module"
}

一旦設(shè)置了以后,該目錄里面的 JS 腳本,就被解釋用 ES6 模塊。

## 解釋成 ES6 模塊
$ node my-app.js

如果這時(shí)還要使用 CommonJS 模塊,那么需要將 CommonJS 腳本的后綴名都改成 .cjs 。如果沒有 type 字段,或者 type 字段為 commonjs ,則 .js 腳本會(huì)被解釋成 CommonJS 模塊。

總結(jié)為一句話: .mjs 文件總是以 ES6 模塊加載, .cjs 文件總是以 CommonJS 模塊加載, .js 文件的加載取決于 package.json 里面 type 字段的設(shè)置。

注意,ES6 模塊與 CommonJS 模塊盡量不要混用。 require 命令不能加載 .mjs 文件,會(huì)報(bào)錯(cuò),只有 import 命令才可以加載 .mjs 文件。反過來, .mjs 文件里面也不能使用 require 命令,必須使用 import 。

main 字段

package.json文件有兩個(gè)字段可以指定模塊的入口文件:mainexports 。比較簡單的模塊,可以只使用main 字段,指定模塊加載的入口文件。

// ./node_modules/es-module-package/package.json
{
  "type": "module",
  "main": "./src/index.js"
}

上面代碼指定項(xiàng)目的入口腳本為 ./src/index.js ,它的格式為 ES6 模塊。如果沒有 type 字段, index.js 就會(huì)被解釋為 CommonJS 模塊。

然后, import 命令就可以加載這個(gè)模塊。

// ./my-app.mjs


import { something } from 'es-module-package';
// 實(shí)際加載的是 ./node_modules/es-module-package/src/index.js

上面代碼中,運(yùn)行該腳本以后,Node.js 就會(huì)到 ./node_modules 目錄下面,尋找 es-module-package 模塊,然后根據(jù)該模塊 package.json 的 main 字段去執(zhí)行入口文件。

這時(shí),如果用 CommonJS 模塊的 require() 命令去加載 es-module-package 模塊會(huì)報(bào)錯(cuò),因?yàn)?CommonJS 模塊不能處理 export 命令。

exports 字段

exports字段的優(yōu)先級(jí)高于main字段。它有多種用法。

(1)子目錄別名

package.json 文件的 exports 字段可以指定腳本或子目錄的別名。

// ./node_modules/es-module-package/package.json
{
  "exports": {
    "./submodule": "./src/submodule.js"
  }
}

上面的代碼指定 src/submodule.js 別名為 submodule ,然后就可以從別名加載這個(gè)文件。

import submodule from 'es-module-package/submodule';
// 加載 ./node_modules/es-module-package/src/submodule.js

下面是子目錄別名的例子。

// ./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

如果沒有指定別名,就不能用“模塊+腳本名”這種形式加載腳本。

// 報(bào)錯(cuò)
import submodule from 'es-module-package/private-module.js';


// 不報(bào)錯(cuò)
import submodule from './node_modules/es-module-package/private-module.js';

(2)main 的別名

exports 字段的別名如果是 . ,就代表模塊的主入口,優(yōu)先級(jí)高于 main 字段,并且可以直接簡寫成 exports 字段的值。

{
  "exports": {
    ".": "./main.js"
  }
}


// 等同于
{
  "exports": "./main.js"
}

由于 exports 字段只有支持 ES6 的 Node.js 才認(rèn)識(shí),所以可以用來兼容舊版本的 Node.js。

{
  "main": "./main-legacy.cjs",
  "exports": {
    ".": "./main-modern.cjs"
  }
}

上面代碼中,老版本的 Node.js (不支持 ES6 模塊)的入口文件是 main-legacy.cjs ,新版本的 Node.js 的入口文件是 main-modern.cjs 。

(3)條件加載

利用 . 這個(gè)別名,可以為 ES6 模塊和 CommonJS 指定不同的入口。目前,這個(gè)功能需要在 Node.js 運(yùn)行的時(shí)候,打開 --experimental-conditional-exports 標(biāo)志。

{
  "type": "module",
  "exports": {
    ".": {
      "require": "./main.cjs",
      "default": "./main.js"
    }
  }
}

上面代碼中,別名 . 的 require 條件指定 require() 命令的入口文件(即 CommonJS 的入口), default 條件指定其他情況的入口(即 ES6 的入口)。

上面的寫法可以簡寫如下。

{
  "exports": {
    "require": "./main.cjs",
    "default": "./main.js"
  }
}

注意,如果同時(shí)還有其他別名,就不能采用簡寫,否則或報(bào)錯(cuò)。

{
  // 報(bào)錯(cuò)
  "exports": {
    "./feature": "./lib/feature.js",
    "require": "./main.cjs",
    "default": "./main.js"
  }
}

ES6 模塊加載 CommonJS 模塊

目前,一個(gè)模塊同時(shí)支持 ES6 和 CommonJS 兩種格式的常見方法是,package.json文件的 main字段指定 CommonJS 入口,給 Node.js 使用; module 字段指定 ES6 模塊入口,給打包工具使用,因?yàn)?Node.js 不認(rèn)識(shí) module 字段。

有了上一節(jié)的條件加載以后,Node.js 本身就可以同時(shí)處理兩種模塊。

// ./node_modules/pkg/package.json
{
  "type": "module",
  "main": "./index.cjs",
  "exports": {
    "require": "./index.cjs",
    "default": "./wrapper.mjs"
  }
}

上面代碼指定了 CommonJS 入口文件 index.cjs ,下面是這個(gè)文件的代碼。

// ./node_modules/pkg/index.cjs
exports.name = 'value';

然后,ES6 模塊可以加載這個(gè)文件。

// ./node_modules/pkg/wrapper.mjs
import cjsModule from './index.cjs';
export const name = cjsModule.name;

注意, import 命令加載 CommonJS 模塊,只能整體加載,不能只加載單一的輸出項(xiàng)。

// 正確
import packageMain from 'commonjs-package';


// 報(bào)錯(cuò)
import { method } from 'commonjs-package';

還有一種變通的加載方法,就是使用 Node.js 內(nèi)置的 module.createRequire() 方法。

// 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 模塊

CommonJS 模塊加載 ES6 模塊

CommonJSrequire 命令不能加載ES6模塊,會(huì)報(bào)錯(cuò),只能使用 import()這個(gè)方法加載。

(async () => {
  await import('./my-app.mjs');
})();

上面代碼可以在 CommonJS 模塊中運(yùn)行。

Node.js 的內(nèi)置模塊

Node.js 的內(nèi)置模塊可以整體加載,也可以加載指定的輸出項(xiàng)。

// 整體加載
import EventEmitter from 'events';
const e = new EventEmitter();


// 加載指定的輸出項(xiàng)
import { readFile } from 'fs';
readFile('./foo.txt', (err, source) => {
  if (err) {
    console.error(err);
  } else {
    console.log(source);
  }
});

加載路徑

ES6 模塊的加載路徑必須給出腳本的完整路徑,不能省略腳本的后綴名。 import命令和 package.json 文件的 main 字段如果省略腳本的后綴名,會(huì)報(bào)錯(cuò)。

// ES6 模塊中將報(bào)錯(cuò)
import { something } from './index';

為了與瀏覽器的import 加載規(guī)則相同,Node.js 的 .mjs文件支持 URL 路徑。

import './foo.mjs?query=1'; // 加載 ./foo 傳入?yún)?shù) ?query=1

上面代碼中,腳本路徑帶有參數(shù) ?query=1 ,Node 會(huì)按 URL 規(guī)則解讀。同一個(gè)腳本只要參數(shù)不同,就會(huì)被加載多次,并且保存成不同的緩存。由于這個(gè)原因,只要文件名中含有 : 、 % 、 # 、 ? 等特殊字符,最好對(duì)這些字符進(jìn)行轉(zhuǎn)義。

目前,Node.js 的 import 命令只支持加載本地模塊( file: 協(xié)議)和 data: 協(xié)議,不支持加載遠(yuǎn)程模塊。另外,腳本路徑只支持相對(duì)路徑,不支持絕對(duì)路徑(即以 / 或 // 開頭的路徑)。

最后,Node 的` import ``命令是異步加載,這一點(diǎn)與瀏覽器的處理方法相同。

內(nèi)部變量

ES6 模塊應(yīng)該是通用的,同一個(gè)模塊不用修改,就可以用在瀏覽器環(huán)境和服務(wù)器環(huán)境。為了達(dá)到這個(gè)目標(biāo),Node 規(guī)定 ES6 模塊之中不能使用 CommonJS 模塊的特有的一些內(nèi)部變量。

首先,就是 this 關(guān)鍵字。ES6 模塊之中,頂層的 this 指向 undefined ;CommonJS 模塊的頂層 this 指向當(dāng)前模塊,這是兩者的一個(gè)重大差異。

其次,以下這些頂層變量在 ES6 模塊之中都是不存在的。

  • arguments
  • require
  • module
  • exports
  • __filename
  • __dirname

4. 循環(huán)加載

“循環(huán)加載”(circular dependency)指的是, a 腳本的執(zhí)行依賴 b 腳本,而 b 腳本的執(zhí)行又依賴 a 腳本。

// a.js
var b = require('b');


// b.js
var a = require('a');

通常,“循環(huán)加載”表示存在強(qiáng)耦合,如果處理不好,還可能導(dǎo)致遞歸加載,使得程序無法執(zhí)行,因此應(yīng)該避免出現(xiàn)。

但是實(shí)際上,這是很難避免的,尤其是依賴關(guān)系復(fù)雜的大項(xiàng)目,很容易出現(xiàn) a 依賴 b , b 依賴 c , c 又依賴 a 這樣的情況。這意味著,模塊加載機(jī)制必須考慮“循環(huán)加載”的情況。

對(duì)于 JavaScript 語言來說,目前最常見的兩種模塊格式 CommonJS 和 ES6,處理“循環(huán)加載”的方法是不一樣的,返回的結(jié)果也不一樣。

CommonJS 模塊的加載原理

介紹 ES6 如何處理“循環(huán)加載”之前,先介紹目前最流行的 CommonJS 模塊格式的加載原理。

CommonJS 的一個(gè)模塊,就是一個(gè)腳本文件。 require 命令第一次加載該腳本,就會(huì)執(zhí)行整個(gè)腳本,然后在內(nèi)存生成一個(gè)對(duì)象。

{
  id: '...',
  exports: { ... },
  loaded: true,
  ...
}

上面代碼就是 Node 內(nèi)部加載模塊后生成的一個(gè)對(duì)象。該對(duì)象的 id 屬性是模塊名, exports 屬性是模塊輸出的各個(gè)接口, loaded 屬性是一個(gè)布爾值,表示該模塊的腳本是否執(zhí)行完畢。其他還有很多屬性,這里都省略了。

以后需要用到這個(gè)模塊的時(shí)候,就會(huì)到 exports 屬性上面取值。即使再次執(zhí)行 require 命令,也不會(huì)再次執(zhí)行該模塊,而是到緩存之中取值。也就是說,CommonJS 模塊無論加載多少次,都只會(huì)在第一次加載時(shí)運(yùn)行一次,以后再加載,就返回第一次運(yùn)行的結(jié)果,除非手動(dòng)清除系統(tǒng)緩存。

CommonJS 模塊的循環(huán)加載

CommonJS 模塊的重要特性是加載時(shí)執(zhí)行,即腳本代碼在require的時(shí)候,就會(huì)全部執(zhí)行。一旦出現(xiàn)某個(gè)模塊被"循環(huán)加載",就只輸出已經(jīng)執(zhí)行的部分,還未執(zhí)行的部分不會(huì)輸出。

讓我們來看,Node 官方文檔里面的例子。腳本文件 a.js 代碼如下。

exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 執(zhí)行完畢');

上面代碼之中, a.js 腳本先輸出一個(gè) done 變量,然后加載另一個(gè)腳本文件 b.js 。注意,此時(shí) a.js 代碼就停在這里,等待 b.js 執(zhí)行完畢,再往下執(zhí)行。

再看 b.js 的代碼。

exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 執(zhí)行完畢');

上面代碼之中, b.js 執(zhí)行到第二行,就會(huì)去加載 a.js ,這時(shí),就發(fā)生了“循環(huán)加載”。系統(tǒng)會(huì)去 a.js 模塊對(duì)應(yīng)對(duì)象的 exports 屬性取值,可是因?yàn)?a.js 還沒有執(zhí)行完,從 exports 屬性只能取回已經(jīng)執(zhí)行的部分,而不是最后的值。

a.js 已經(jīng)執(zhí)行的部分,只有一行。

exports.done = false;

因此,對(duì)于 b.js 來說,它從 a.js 只輸入一個(gè)變量 done ,值為 false 。

然后, b.js 接著往下執(zhí)行,等到全部執(zhí)行完畢,再把執(zhí)行權(quán)交還給 a.js 。于是, a.js 接著往下執(zhí)行,直到執(zhí)行完畢。我們寫一個(gè)腳本 main.js ,驗(yàn)證這個(gè)過程。

var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);

執(zhí)行 main.js ,運(yùn)行結(jié)果如下。

$ node main.js


在 b.js 之中,a.done = false
b.js 執(zhí)行完畢
在 a.js 之中,b.done = true
a.js 執(zhí)行完畢
在 main.js 之中, a.done=true, b.done=true

上面的代碼證明了兩件事。一是,在 b.js 之中, a.js 沒有執(zhí)行完畢,只執(zhí)行了第一行。二是, main.js 執(zhí)行到第二行時(shí),不會(huì)再次執(zhí)行 b.js ,而是輸出緩存的 b.js 的執(zhí)行結(jié)果,即它的第四行。

exports.done = true;

總之,CommonJS 輸入的是被輸出值的拷貝,不是引用。

另外,由于 CommonJS 模塊遇到循環(huán)加載時(shí),返回的是當(dāng)前已經(jīng)執(zhí)行的部分的值,而不是代碼全部執(zhí)行后的值,兩者可能會(huì)有差異。所以,輸入變量的時(shí)候,必須非常小心。

var a = require('a'); // 安全的寫法
var foo = require('a').foo; // 危險(xiǎn)的寫法


exports.good = function (arg) {
  return a.foo('good', arg); // 使用的是 a.foo 的最新值
};


exports.bad = function (arg) {
  return foo('bad', arg); // 使用的是一個(gè)部分加載時(shí)的值
};

上面代碼中,如果發(fā)生循環(huán)加載, require('a').foo 的值很可能后面會(huì)被改寫,改用 require('a') 會(huì)更保險(xiǎn)一點(diǎn)。

ES6 模塊的循環(huán)加載

ES6 處理“循環(huán)加載”與 CommonJS 有本質(zhì)的不同。ES6 模塊是動(dòng)態(tài)引用,如果使用 import從一個(gè)模塊加載變量(即import foo from 'foo' ),那些變量不會(huì)被緩存,而是成為一個(gè)指向被加載模塊的引用,需要開發(fā)者自己保證,真正取值的時(shí)候能夠取到值。

請(qǐng)看下面這個(gè)例子。

// 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 ,構(gòu)成循環(huán)加載。執(zhí)行 a.mjs ,結(jié)果如下。

$ node --experimental-modules a.mjs
b.mjs
ReferenceError: foo is not defined

上面代碼中,執(zhí)行 a.mjs 以后會(huì)報(bào)錯(cuò), foo 變量未定義,這是為什么?

讓我們一行行來看,ES6 循環(huán)加載是怎么處理的。首先,執(zhí)行 a.mjs 以后,引擎發(fā)現(xiàn)它加載了 b.mjs ,因此會(huì)優(yōu)先執(zhí)行 b.mjs ,然后再執(zhí)行 a.mjs 。接著,執(zhí)行 b.mjs 的時(shí)候,已知它從 a.mjs 輸入了 foo 接口,這時(shí)不會(huì)去執(zhí)行 a.mjs ,而是認(rèn)為這個(gè)接口已經(jīng)存在了,繼續(xù)往下執(zhí)行。執(zhí)行到第三行 console.log(foo) 的時(shí)候,才發(fā)現(xiàn)這個(gè)接口根本沒定義,因此報(bào)錯(cuò)。

解決這個(gè)問題的方法,就是讓 b.mjs 運(yùn)行的時(shí)候, foo 已經(jīng)有定義了。這可以通過將 foo 寫成函數(shù)來解決。

// 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};

這時(shí)再執(zhí)行 a.mjs 就可以得到預(yù)期結(jié)果。

$ node --experimental-modules a.mjs
b.mjs
foo
a.mjs
bar

這是因?yàn)楹瘮?shù)具有提升作用,在執(zhí)行 import {bar} from './b' 時(shí),函數(shù) foo 就已經(jīng)有定義了,所以 b.mjs 加載的時(shí)候不會(huì)報(bào)錯(cuò)。這也意味著,如果把函數(shù) foo 改寫成函數(shù)表達(dá)式,也會(huì)報(bào)錯(cuò)。

// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
const foo = () => 'foo';
export {foo};

上面代碼的第四行,改成了函數(shù)表達(dá)式,就不具有提升作用,執(zhí)行就會(huì)報(bào)錯(cuò)。

我們再來看 ES6 模塊加載器SystemJS給出的一個(gè)例子。

// 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 里面的函數(shù) even 有一個(gè)參數(shù) n ,只要不等于 0,就會(huì)減去 1,傳入加載的 odd() 。 odd.js 也會(huì)做類似操作。

運(yùn)行上面這段代碼,結(jié)果如下。

$ babel-node
> import * as m from './even.js';
> m.even(10);
true
> m.counter
6
> m.even(20)
true
> m.counter
17

上面代碼中,參數(shù) n 從 10 變?yōu)?0 的過程中, even() 一共會(huì)執(zhí)行 6 次,所以變量 counter 等于 6。第二次調(diào)用 even() 時(shí),參數(shù) n 從 20 變?yōu)?0, even() 一共會(huì)執(zhí)行 11 次,加上前面的 6 次,所以變量 counter 等于 17。

這個(gè)例子要是改寫成 CommonJS,就根本無法執(zhí)行,會(huì)報(bào)錯(cuò)。

// 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 ,形成“循環(huán)加載”。這時(shí),執(zhí)行引擎就會(huì)輸出 even.js 已經(jīng)執(zhí)行的部分(不存在任何結(jié)果),所以在 odd.js 之中,變量 even 等于 undefined ,等到后面調(diào)用 even(n - 1) 就會(huì)報(bào)錯(cuò)。

$ node
> var m = require('./even');
> m.even(10)
TypeError: even is not a function
以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)