分而治之-前端模組化

安穩.發表於2019-03-20

Created By JishuBao on 2019-03-20 12:38:22
Recently revised in 2019-03-20 12:38:22

 

  歡迎大家來到技術寶的掘金世界,您的star是我寫文章最大的動力!GitHub地址     

文章簡介:

1、前端模組化發展簡述

2、模組的演變

3、CommonJs概述

4、AMD概述

5、CMD概述

6、AMD與CMD的區別

7、嚴格模式

8、ES6的Module

9、Moudle的載入實現

10、module.exports與exports、export、export default、exports.default區別

一、前端模組化發展簡述

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

 在JavaScript發展初期就是為了實現簡單的頁面互動邏輯,寥寥數語即可;如今CPU、瀏覽器效能得到了極大的提升,很多頁面邏輯遷移到了客戶端(表單驗證等),隨著web2.0時代的到來,Ajax技術得到廣泛應用,jQuery等前端庫層出不窮,前端程式碼日益膨脹。

 這時候JavaScript作為嵌入式的指令碼語言的定位動搖了,JavaScript卻沒有為組織程式碼提供任何明顯幫助,甚至沒有類的概念,更不用說模組module了,JavaScript極其簡單的程式碼組織規範不足以駕馭如此龐大規模的程式碼。

二、模組的演變

 既然JavaScript不能handle如此大規模的程式碼,我們可以借鑑一下其它語言是怎麼處理大規模程式設計的,在Java中有一個重要帶概念——package,邏輯上相關的程式碼組織到同一個包內,包內是一個相對獨立的王國,不用擔心命名衝突什麼的,那麼外部如果使用呢?直接import對應的package即可

import java.util.ArrayList;
複製程式碼

 遺憾的是JavaScript在設計時定位原因,沒有提供類似的功能,開發者需要模擬出類似的功能,來隔離、組織複雜的JavaScript程式碼,我們稱為模組化。

 一個模組就是實現特定功能的檔案,有了模組,我們就可以更方便地使用別人的程式碼,想要什麼功能,就載入什麼模組。模組開發需要遵循一定的規範,各行其是就都亂套了。

規範形成的過程是痛苦的,前端的先驅在刀耕火種、茹毛飲血的階段開始,發展到現在初具規模,簡單瞭解一下這段不凡的歷程

1.函式封裝

 我們在講函式的時候提到,函式一個功能就是實現特定邏輯的一組語句打包,而且JavaScript的作用域就是基於函式的,所以把函式作為模組化的第一步是很自然的事情,在一個檔案裡面編寫幾個相關函式就是最開始的模組了。

function fn1(){
    statement
    ......
}

function fn2(){
    statement
    ......
}
複製程式碼

 這樣在需要的以後夾在函式所在檔案,呼叫函式就可以了。

 這種做法的缺點很明顯:汙染了全域性變數,無法保證不與其他模組發生變數名衝突,而且模組成員之間沒什麼關係。

2.物件

 為了解決上面問題,物件的寫法應運而生,可以把所有的模組成員封裝在一個物件中

var myModule = {
    var1: 1,

    var2: 2,

    fn1: function(){

    },

    fn2: function(){

    }
}
複製程式碼

 這樣我們在希望呼叫模組的時候引用對應檔案,然後

myModule.fn2();
複製程式碼

 這樣避免了變數汙染,只要保證模組名唯一即可,同時同一模組內的成員也有了關係

 看似不錯的解決方案,但是也有缺陷,外部可以隨意修改內部成員

myModel.var1 = 100;
複製程式碼

 這樣就會產生意外的安全問題

3.立即執行函式

 可以通過立即執行函式,來達到隱藏細節的目的。

var myModule = (function(){
    var var1 = 1;
    var var2 = 2;

    function fn1(){

    }

    function fn2(){

    }

    return {
        fn1: fn1,
        fn2: fn2
    };
})();
複製程式碼

 這樣在模組外部無法修改我們沒有暴露出來的變數、函式

 上述做法就是我們模組化的基礎,目前,通行的JavaScript模組規範主要有兩種:CommonJSAMD

三、CommonJs概述

 我們先從CommonJS談起,因為在網頁端沒有模組化程式設計只是頁面JavaScript邏輯複雜,但也可以工作下去,在伺服器端卻一定要有模組,所以雖然JavaScript在web端發展這麼多年,第一個流行的模組化規範卻由伺服器端的JavaScript應用帶來,CommonJS規範是由NodeJS發揚光大,這標誌著JavaScript模組化程式設計正式登上舞臺

  • 定義模組:根據CommonJS規範,一個單獨的檔案就是一個模組每一個模組都是一個單獨的作用域,也就是說,在該模組內部定義的變數,無法被其他模組讀取,除非定義為global物件的屬性。
  • 模組輸出:模組只有一個出口,module.exports物件,我們需要把模組希望輸出的內容放入該物件
  • 載入模組:載入模組使用require方法,該方法讀取一個檔案並執行,返回檔案內部的module.exports物件
//模組定義 myModel.js

var name = 'Byron';

function printName(){
    console.log(name);
}

function printFullName(firstName){
    console.log(firstName + name);
}

module.exports = {
    printName: printName,
    printFullName: printFullName
}

//載入模組

var nameModule = require('./myModel.js');

nameModule.printName();
複製程式碼

 不同的實現對require時的路徑有不同要求,一般情況可以省略js擴充名,可以使用相對路徑,也可以使用絕對路徑,甚至可以省略路徑直接使用模組名(前提是該模組是系統內建模組)

尷尬的瀏覽器

 仔細看上面的程式碼,會發現require是同步的。模組系統需要同步讀取模組檔案內容,並編譯執行以得到模組介面

 這在伺服器端實現很簡單,也很自然,然而, 想在瀏覽器端實現問題卻很多。

瀏覽器端,載入JavaScript最佳、最容易的方式是在document中插入script 標籤。但指令碼標籤天生非同步,傳統CommonJS模組在瀏覽器環境中無法正常載入

 解決思路之一是,開發一個伺服器端元件,對模組程式碼作靜態分析,將模組與它的依賴列表一起返回給瀏覽器端。 這很好使,但需要伺服器安裝額外的元件,並因此要調整一系列底層架構。

 另一種解決思路是,用一套標準模板來封裝模組定義,但是對於模組應該怎麼定義和怎麼載入,又產生的分歧

四、AMD概述

 AMD 即Asynchronous Module Definition,中文名是非同步模組定義的意思。它是一個在瀏覽器端模組化開發的規範

 由於不是JavaScript原生支援使用AMD規範進行頁面開發需要用到對應的庫函式,也就是大名鼎鼎RequireJS,實際上AMD 是 RequireJS 在推廣過程中對模組定義的規範化的產出

requireJS主要解決兩個問題

  • 多個js檔案可能有依賴關係,被依賴的檔案需要早於依賴它的檔案載入到瀏覽器。
  • js載入的時候瀏覽器會停止頁面渲染,載入檔案越多,頁面失去響應時間越長
// 定義模組 myModule.js
define(['myModule'], function(){
    var name = 'Byron';
    function printName(){
        console.log(name);
    }

    return {
        printName: printName
    };
});

// 載入模組
require(['myModule'], function (my){
    my.printName();
});
複製程式碼

語法

 requireJS定義了一個函式 define,它是全域性變數,用來定義模組

 define(id?, dependencies?, factory);

  • id:可選引數,用來定義模組的標識,如果沒有提供該引數,指令碼檔名(去掉擴充名)

  • dependencies:是一個當前模組依賴的模組名稱陣列

  • factory:工廠方法,模組初始化要執行的函式或物件。如果為函式,它應該只被執行一次。如果是物件,此物件應該為模組的輸出值

 在頁面上使用require函式載入模組

 require([dependencies], function(){});

 require()函式接受兩個引數

  • 第一個引數是一個陣列,表示所依賴的模組
  • 第二個引數是一個回撥函式,當前面指定的模組都載入成功後,它將被呼叫。載入的模組會以引數形式傳入該函式,從而在回撥函式內部就可以使用這些模組

require()函式在載入依賴的函式的時候是非同步載入的,這樣瀏覽器不會失去響應,它指定的回撥函式,只有前面的模組都載入成功後,才會執行,解決了依賴性的問題。

五、CMD概述

 CMD 即Common Module Definition通用模組定義,CMD規範是國內發展出來的,就像AMD有個requireJS,CMD有個瀏覽器的實現SeaJSSeaJS要解決的問題和requireJS一樣,只不過在模組定義方式和模組載入(可以說執行、解析)時機上有所不同。

語法

Sea.js 推崇一個模組一個檔案,遵循統一的寫法

define

define(id?, deps?, factory)

 因為CMD推崇

  • 一個檔案一個模組,所以經常就用檔名作為模組id
  • CMD推崇依賴就近,所以一般不在define的引數中寫依賴,在factory中寫

 factory有三個引數

function(require, exports, module)
複製程式碼

require

 require 是 factory 函式的第一個引數

 require(id)

 require 是一個方法,接受 模組標識 作為唯一引數,用來獲取其他模組提供的介面

exports

 exports 是一個物件,用來向外提供模組介面

module

 module 是一個物件,上面儲存了與當前模組相關聯的一些屬性和方法

// 定義模組  myModule.js
define(function(require, exports, module) {
  var $ = require('jquery.js')
  $('div').addClass('active');
});

// 載入模組
seajs.use(['myModule.js'], function(my){

});
複製程式碼

六、AMD與CMD的區別

 最明顯的區別就是在模組定義時對依賴的處理不同

  • AMD推崇依賴前置,在定義模組的時候就要宣告其依賴的模組
  • CMD推崇就近依賴,只有在用到某個模組的時候再去require

 這種區別各有優劣,只是語法上的差距,而且requireJS和SeaJS都支援對方的寫法。

 最的區別是對依賴模組的執行時機處理不同,注意不是載入的時機或者方式不同

 很多人說requireJS是非同步載入模組,SeaJS是同步載入模組,這麼理解實際上是不準確的,其實載入模組都是非同步的,只不過AMD依賴前置,js可以方便知道依賴模組是誰,立即載入,而CMD就近依賴,需要使用把模組變為字串解析一遍才知道依賴了那些模組,這也是很多人詬病CMD的一點,犧牲效能來帶來開發的便利性,實際上解析模組用的時間短到可以忽略

 為什麼我們說兩個的區別是依賴模組執行時機不同,為什麼很多人認為ADM是非同步的,CMD是同步的(除了名字的原因。。。)

 同樣都是非同步載入模組,AMD在載入模組完成後就會執行改模組,所有模組都載入執行完後會進入require的回撥函式,執行主邏輯,這樣的效果就是依賴模組的執行順序和書寫順序不一定一致,看網路速度,哪個先下載下來,哪個先執行,但是主邏輯一定在所有依賴載入完成後才執行。

 CMD載入完某個依賴模組後並不執行,只是下載而已,在所有依賴模組載入完成後進入主邏輯,遇到require語句的時候才執行對應的模組,這樣模組的執行順序和書寫順序是完全一致的。

 這也是很多人說AMD使用者體驗好,因為沒有延遲,依賴模組提前執行了,CMD效能好,因為只有使用者需要的時候才執行的原因。

七、嚴格模式

 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

八、ES6的Module

1.export命令

 模組功能主要由兩個命令構成:exportimportexport命令用於規定模組的對外介面,import命令用於輸入其他模組提供的功能。

 一個模組就是一個獨立的檔案。該檔案內部的所有變數,外部無法獲取。如果你希望外部能夠讀取模組內部的某個變數,就必須使用export關鍵字輸出該變數

// profile.js
//第一種寫法
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;

//第二種寫法
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;
export {firstName,lastName,year}
複製程式碼

 上面程式碼是profile.js檔案,儲存了使用者資訊。ES6 將其視為一個模組,裡面用export命令對外部輸出了三個變數

 它與前一種寫法(直接放置在var語句前)是等價的,但是應該優先考慮使用這種寫法。因為這樣就可以在指令碼尾部,一眼看清楚輸出了哪些變數

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

export function multiply(x, y) {
  return x * y;
};
複製程式碼

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

function v1() { ... }
function v2() { ... }

export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};
複製程式碼

 上面程式碼使用as關鍵字,重新命名了函式v1和v2的對外介面。重新命名後,v2可以用不同的名字輸出兩次

 需要特別注意的是,export命令規定的是對外的介面,必須與模組內部的變數建立一一對應關係

// 報錯
export 1;

// 報錯
var m = 1;
export m;
複製程式碼

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

// 寫法一
export var m = 1;

// 寫法二
var m = 1;
export {m};

// 寫法三
var n = 1;
export {n as m};
複製程式碼

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

 同樣的,functionclass的輸出,也必須遵守這樣的寫法。

// 報錯
function f() {}
export f;

// 正確
export function f() {};

// 正確
function f() {}
export {f};
複製程式碼

 另外,export語句輸出的介面,與其對應的值是動態繫結關係,即通過該介面,可以取到模組內部實時的值。

export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);
複製程式碼

 上面程式碼輸出變數foo,值為bar,500 毫秒之後變成baz

 最後,export命令可以出現在模組的任何位置,只要處於模組頂層就可以。如果處於塊級作用域內,就會報錯,下一節的import命令也是如此。這是因為處於條件程式碼塊之中,就沒法做靜態優化了,違背了 ES6 模組的設計初衷。

function foo() {
  export default 'bar' // SyntaxError
}
foo()
複製程式碼

 上面程式碼中,export語句放在函式之中,結果報錯

2.import命令

 使用export命令定義了模組的對外介面以後,其他JS檔案就可以通過import命令載入這個模組

// main.js
import {firstName, lastName, year} from './profile.js';

function setName(element) {
  element.textContent = firstName + ' ' + lastName;
}
複製程式碼

 上面程式碼的import命令,用於載入profile.js檔案,並從中輸入變數。import命令接受一對大括號,裡面指定要從其他模組匯入的變數名。大括號裡面的變數名,必須與被匯入模組(profile.js)對外介面的名稱相同。

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

import { lastName as surname } from './profile.js';
複製程式碼

 import命令輸入的變數都是只讀的,因為它的本質是輸入介面。也就是說,不允許在載入模組的指令碼里面,改寫介面。

import {a} from './xxx.js'

a = {}; // Syntax Error : 'a' is read-only;
複製程式碼

 上面程式碼中,指令碼載入了變數a,對其重新賦值就會報錯,因為a是一個只讀的介面。但是,如果a是一個物件,改寫a的屬性是允許的

import {a} from './xxx.js'

a.foo = 'hello'; // 合法操作
複製程式碼

 上面程式碼中,a的屬性可以成功改寫,並且其他模組也可以讀到改寫後的值。不過,這種寫法很難查錯,建議凡是輸入的變數,都當作完全只讀,輕易不要改變它的屬性。

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

import {myMethod} from 'util';
複製程式碼

 上面程式碼中,util是模組檔名,由於不帶有路徑,必須通過配置,告訴引擎怎麼取到這個模組。

 注意,import命令具有提升效果,會提升到整個模組的頭部,首先執行

foo();

import { foo } from 'my_module';
複製程式碼

 上面的程式碼不會報錯,因為import的執行早於foo的呼叫。這種行為的本質是,import命令是編譯階段執行的,在程式碼執行之前。

 由於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語句會執行所載入的模組,因此可以有下面的寫法。

import 'lodash';
複製程式碼

 上面程式碼僅僅執行lodash模組,但是不輸入任何值。如果多次重複執行同一句import語句,那麼只會執行一次,而不會執行多次

import 'lodash';
import 'lodash';
複製程式碼

 上面程式碼載入了兩次lodash,但是隻會執行一次。

import { foo } from 'my_module';
import { bar } from 'my_module';

// 等同於
import { foo, bar } from 'my_module';
複製程式碼

 上面程式碼中,雖然foo和bar在兩個語句中載入,但是它們對應的是同一個my_module例項。也就是說,import語句是 Singleton 模式。

 目前階段,通過Babel 轉碼,CommonJS 模組的require命令和 ES6模組的import命令,可以寫在同一個模組裡面,但是最好不要這樣做。因為import在靜態解析階段執行,所以它是一個模組之中最早執行的。下面的程式碼可能不會得到預期結果

require('core-js/modules/es6.symbol');
require('core-js/modules/es6.promise');
import React from 'React';
複製程式碼

3.模組的整體載入

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

 下面是一個circle.js檔案,它輸出兩個方法area和circumference。

// circle.js
export function area(radius) {
  return Math.PI * radius * radius;
}

export function circumference(radius) {
  return 2 * Math.PI * radius;
}
複製程式碼

 現在,載入這個模組。

// main.js

import { area, circumference } from './circle';

console.log('圓面積:' + area(4));
console.log('圓周長:' + circumference(14));
複製程式碼

 上面寫法是逐一指定要載入的方法,整體載入的寫法如下。

import * as circle from './circle';

console.log('圓面積:' + circle.area(4));
console.log('圓周長:' + circle.circumference(14));
複製程式碼

 注意,模組整體載入所在的那個物件(上例是circle),應該是可以靜態分析的,所以不允許執行時改變。下面的寫法都是不允許的。

import * as circle from './circle';

// 下面兩行都是不允許的
circle.foo = 'hello';
circle.area = function () {};
複製程式碼

4.export default命令

 從前面的例子可以看出,使用import命令的時候,使用者需要知道所要載入的變數名或函式名,否則無法載入。但是,使用者肯定希望快速上手,未必願意閱讀文件,去了解模組有哪些屬性和方法。

 為了給使用者提供方便,讓他們不用閱讀文件就能載入模組,就要用到export default命令,為模組指定預設輸出。

// export-default.js
export default function () {
  console.log('foo');
}
複製程式碼

 上面程式碼是一個模組檔案export-default.js,它的預設輸出是一個函式

 其他模組載入該模組時,import命令可以為該匿名函式指定任意名字

// import-default.js
import customName from './export-default';
customName(); // 'foo'
複製程式碼

 上面程式碼的import命令,可以用任意名稱指向export-default.js輸出的方法,這時就不需要知道原模組輸出的函式名。需要注意的是,這時import命令後面,不使用大括號

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

// export-default.js
export default function foo() {
  console.log('foo');
}

// 或者寫成

function foo() {
  console.log('foo');
}

export default foo;
複製程式碼

 上面程式碼中,foo函式的函式名foo,在模組外部是無效的。載入的時候,視同匿名函式載入。

 下面比較一下預設輸出和正常輸出。

// 第一組
export default function crc32() { // 輸出
  // ...
}

import crc32 from 'crc32'; // 輸入

// 第二組
export function crc32() { // 輸出
  // ...
};

import {crc32} from 'crc32'; // 輸入
複製程式碼

 上面程式碼的兩組寫法,第一組是使用export default時,對應的import語句不需要使用大括號;第二組是不使用export default時,對應的import語句需要使用大括號。

 export default命令用於指定模組的預設輸出。顯然,一個模組只能有一個預設輸出,因此export default命令只能使用一次。所以,import命令後面才不用加大括號,因為只可能唯一對應export default命令。

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

// modules.js
function add(x, y) {
  return x * y;
}
export {add as default};
// 等同於
// export default add;

// app.js
import { default as foo } from 'modules';
// 等同於
// import foo from 'modules';
複製程式碼

 正是因為export default命令其實只是輸出一個叫做default的變數,所以它後面不能跟變數宣告語句。

// 正確
export var a = 1;

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

// 錯誤
export default var a = 1;
複製程式碼

 上面程式碼中,export default a的含義是將變數a的值賦給變數default。所以,最後一種寫法會報錯。

 同樣地,因為export default命令的本質是將後面的值,賦給default變數,所以可以直接將一個值寫在export default之後。

// 正確
export default 42;

// 報錯
export 42;
複製程式碼

 上面程式碼中,後一句報錯是因為沒有指定對外的介面,而前一句指定對外介面為default。

 有了export default命令,輸入模組時就非常直觀了,以輸入 lodash 模組為例。

import _ from 'lodash';
複製程式碼

 如果想在一條import語句中,同時輸入預設方法和其他介面,可以寫成下面這樣。

import _, { each, forEach } from 'lodash';
複製程式碼

 對應上面程式碼的export語句如下。

export default function (obj) {
  // ···
}

export function each(obj, iterator, context) {
  // ···
}

export { each as forEach };
複製程式碼

 上面程式碼的最後一行的意思是,暴露出forEach介面,預設指向each介面,即forEach和each指向同一個方法。

 export default也可以用來輸出類。

// MyClass.js
export default class { ... }

// main.js
import MyClass from 'MyClass';
let o = new MyClass();
複製程式碼

5.export 與 import的複合寫法

 如果在一個模組之中,先輸入後輸出同一個模組import語句可以與export語句寫在一起。

export { foo, bar } from 'my_module';

// 可以簡單理解為
import { foo, bar } from 'my_module';
export { foo, bar };
複製程式碼

 上面程式碼中,export和import語句可以結合在一起,寫成一行。但需要注意的是,寫成一行以後,foo和bar實際上並沒有被匯入當前模組,只是相當於對外轉發了這兩個介面,導致當前模組不能直接使用foo和bar

 模組的介面改名和整體輸出,也可以採用這種寫法。

// 介面改名
export { foo as myFoo } from 'my_module';

// 整體輸出
export * from 'my_module';
複製程式碼

 預設介面的寫法如下。

export { default } from 'foo';
複製程式碼

 具名介面改為預設介面的寫法如下。

export { es6 as default } from './someModule';

// 等同於
import { es6 } from './someModule';
export default es6;
複製程式碼

 同樣地,預設介面也可以改名為具名介面。

export { default as es6 } from './someModule';
複製程式碼

 下面三種import語句,沒有對應的複合寫法。

import * as someIdentifier from "someModule";
import someIdentifier from "someModule";
import someIdentifier, { namedIdentifier } from "someModule";
複製程式碼

 為了做到形式的對稱,現在有提案,提出補上這三種複合寫法。

export * as someIdentifier from "someModule";
export someIdentifier from "someModule";
export someIdentifier, { namedIdentifier } from "someModule";
複製程式碼

6.模組的繼承

 模組之間也可以繼承。

 假設有一個circleplus模組,繼承了circle模組。

// circleplus.js

export * from 'circle';
export var e = 2.71828182846;
export default function(x) {
  return Math.exp(x);
}
複製程式碼

 上面程式碼中的export *,表示再輸出circle模組的所有屬性和方法。注意,export *命令會忽略circle模組的default方法。然後,上面程式碼又輸出了自定義的e變數和預設方法

 這時,也可以將circle的屬性或方法,改名後再輸出。

// circleplus.js
export { area as circleArea } from 'circle';
複製程式碼

 上面程式碼表示,只輸出circle模組的area方法,且將其改名為circleArea

 載入上面模組的寫法如下。

// main.js

import * as math from 'circleplus';
import exp from 'circleplus';
console.log(exp(math.e));
複製程式碼

 上面程式碼中的import exp表示,將circleplus模組的預設方法載入為exp方法。

7.跨模組的常量

 本書介紹const命令的時候說過,const宣告的常量只在當前程式碼塊有效。如果想設定跨模組的常量(即跨多個檔案),或者說一個值要被多個模組共享,可以採用下面的寫法。

// constants.js 模組
export const A = 1;
export const B = 3;
export const C = 4;

// test1.js 模組
import * as constants from './constants';
console.log(constants.A); // 1
console.log(constants.B); // 3

// test2.js 模組
import {A, B} from './constants';
console.log(A); // 1
console.log(B); // 3
複製程式碼

 如果要使用的常量非常多,可以建一個專門的constants目錄,將各種常量寫在不同的檔案裡面,儲存在該目錄下。

// constants/db.js
export const db = {
  url: 'http://my.couchdbserver.local:5984',
  admin_username: 'admin',
  admin_password: 'admin password'
};

// constants/user.js
export const users = ['root', 'admin', 'staff', 'ceo', 'chief', 'moderator'];
複製程式碼

 然後,將這些檔案輸出的常量,合併在index.js裡面。

// constants/index.js
export {db} from './db';
export {users} from './users';
複製程式碼

 使用的時候,直接載入index.js就可以了。

// script.js
import {db, users} from './constants/index';
複製程式碼

8.import()

 前面介紹過,import命令會被 JavaScript 引擎靜態分析,先於模組內的其他語句執行(import命令叫做“連線” binding 其實更合適)。所以,下面的程式碼會報錯。

// 報錯
if (x === 2) {
  import MyModual from './myModual';
}
複製程式碼

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

 這樣的設計,固然有利於編譯器提高效率,但也導致無法在執行時載入模組。在語法上,條件載入就不可能實現。如果import命令要取代 Node 的require方法,這就形成了一個障礙。因為require是執行時載入模組,import命令無法取代require的動態載入功能。

const path = './' + fileName;
const myModual = require(path);
複製程式碼

 上面的語句就是動態載入,require到底載入哪一個模組,只有執行時才知道。import命令做不到這一點。

 因此,有一個提案,建議引入import()函式,完成動態載入

import(specifier)
複製程式碼

 上面程式碼中,import函式的引數specifier,指定所要載入的模組的位置。import命令能夠接受什麼引數,import()函式就能接受什麼引數,兩者區別主要是後者為動態載入

const main = document.querySelector('main');

import(`./section-modules/${someVariable}.js`)
  .then(module => {
    module.loadPageInto(main);
  })
  .catch(err => {
    main.textContent = err.message;
 });
複製程式碼

import()函式可以用在任何地方,不僅僅是模組,非模組的指令碼也可以使用。它是執行時執行,也就是說,什麼時候執行到這一句,就會載入指定的模組。另外,import()函式與所載入的模組沒有靜態連線關係,這點也是與import語句不相同。import()類似於 Node 的require方法,區別主要是前者是非同步載入,後者是同步載入

適用場景

 下面是import()的一些適用場合。

  • 按需載入

import()可以在需要的時候,再載入某個模組

button.addEventListener('click', event => {
  import('./dialogBox.js')
  .then(dialogBox => {
    dialogBox.open();
  })
  .catch(error => {
    /* Error handling */
  })
});
複製程式碼

 上面程式碼中,import()方法放在click事件的監聽函式之中,只有使用者點選了按鈕,才會載入這個模組

  • 條件載入

import()可以放在if程式碼塊,根據不同的情況,載入不同的模組

if (condition) {
  import('moduleA').then(...);
} else {
  import('moduleB').then(...);
}
複製程式碼

 上面程式碼中,如果滿足條件,就載入模組 A,否則載入模組 B。

  • 動態的模組路徑

import()允許模組路徑動態生成

import(f())
.then(...);
複製程式碼

 上面程式碼中,根據函式f的返回結果,載入不同的模組。

注意點

  • import()載入模組成功以後,這個模組會作為一個物件,當作then方法的引數。因此,可以使用物件解構賦值的語法,獲取輸出介面。
import('./myModule.js')
.then(({export1, export2}) => {
  // ...·
});
複製程式碼

 上面程式碼中,export1和export2都是myModule.js的輸出介面,可以解構獲得。

 如果模組有default輸出介面,可以用引數直接獲得。

import('./myModule.js')
.then(myModule => {
  console.log(myModule.default);
});
複製程式碼

 上面的程式碼也可以使用具名輸入的形式。

import('./myModule.js')
.then(({default: theDefault}) => {
  console.log(theDefault);
});
複製程式碼

 如果想同時載入多個模組,可以採用下面的寫法。

Promise.all([
  import('./module1.js'),
  import('./module2.js'),
  import('./module3.js'),
])
.then(([module1, module2, module3]) => {
   ···
});
複製程式碼

 import()也可以用在 async 函式之中。

async function main() {
  const myModule = await import('./myModule.js');
  const {export1, export2} = await import('./myModule.js');
  const [module1, module2, module3] =
    await Promise.all([
      import('./module1.js'),
      import('./module2.js'),
      import('./module3.js'),
    ]);
}
main();
複製程式碼

九、Module的載入實現

1.瀏覽器載入

1.傳統方法

HTML 網頁中,瀏覽器通過<script>標籤載入 JavaScript 指令碼。

<!-- 頁面內嵌的指令碼 -->
<script type="application/javascript">
  // module code
</script>

<!-- 外部指令碼 -->
<script type="application/javascript" src="path/to/myModule.js">
</script>
複製程式碼

上面程式碼中,由於瀏覽器指令碼的預設語言是 JavaScript,因此type="application/javascript"可以省略。

預設情況下,瀏覽器是同步載入 JavaScript 指令碼,即渲染引擎遇到<script>標籤就會停下來,等到執行完指令碼,再繼續向下渲染。如果是外部指令碼,還必須加入指令碼下載的時間

如果指令碼體積很大,下載和執行的時間就會很長,因此造成瀏覽器堵塞,使用者會感覺到瀏覽器“卡死”了,沒有任何響應。這顯然是很不好的體驗,所以瀏覽器允許指令碼非同步載入,下面就是兩種非同步載入的語法。

<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指令碼是不能保證載入順序的。

2.載入規則

瀏覽器載入 ES6 模組,也使用<script>標籤,但是要加入type="module"屬性。

<script type="module" src="./foo.js"></script>
複製程式碼

上面程式碼在網頁中插入一個模組foo.js,由於type屬性設為module,所以瀏覽器知道這是一個 ES6 模組

瀏覽器對於帶有type="module"的<script>,都是非同步載入,不會造成堵塞瀏覽器,即等到整個頁面渲染完,再執行模組指令碼,等同於開啟了<script>標籤的defer屬性

<script type="module" src="./foo.js"></script>
<!-- 等同於 -->
<script type="module" src="./foo.js" defer></script>
複製程式碼

如果網頁有多個<script type="module">,它們會按照在頁面出現的順序依次執行

<script>標籤的async屬性也可以開啟,這時只要載入完成,渲染引擎就會中斷渲染立即執行。執行完成後,再恢復渲染

<script type="module" src="./foo.js" async></script>
複製程式碼

一旦使用了async屬性,<script type="module">就不會按照在頁面出現的順序執行,而是只要該模組載入完成,就執行該模組

ES6 模組也允許內嵌在網頁中,語法行為與載入外部指令碼完全一致。

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

  // other code
</script>
複製程式碼

對於外部的模組指令碼(上例是foo.js),有幾點需要注意。

  • 程式碼是在模組作用域之中執行,而不是在全域性作用域執行。模組內部的頂層變數,外部不可見。
  • 模組指令碼自動採用嚴格模式,不管有沒有宣告use strict。
  • 模組之中,可以使用import命令載入其他模組(.js字尾不可省略,需要提供絕對 URL 或相對 URL),也可以使用export命令輸出對外介面。
  • 模組之中,頂層的this關鍵字返回undefined,而不是指向window。也就是說,在模組頂層使用this關鍵字,是無意義的。
  • 同一個模組如果載入多次,將只執行一次。

下面是一個示例模組。

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 模組之中

const isNotModuleScript = this !== undefined;//判斷是否在ES6模組中
複製程式碼

2.ES6模組與CommonJs模組載入的差異

  • 1.CommonJS 模組輸出的是一個值的拷貝,ES6 模組輸出的是值的引用。
  • 2.CommonJS 模組是執行時載入,ES6 模組是編譯時輸出介面。

第二個差異是因為 CommonJS 載入的是一個物件(即module.exports屬性),該物件只有在指令碼執行完才會生成。而 ES6 模組不是物件,它的對外介面只是一種靜態定義,在程式碼靜態解析階段就會生成。

下面重點解釋第一個差異。

CommonJS 模組輸出的是值的拷貝,也就是說,一旦輸出一個值,模組內部的變化就影響不到這個值。請看下面這個模組檔案lib.js的例子。

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

上面程式碼輸出內部變數counter和改寫這個變數的內部方法incCounter。然後,在main.js裡面載入這個模組。

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

console.log(mod.counter);  // 3
mod.incCounter();
console.log(mod.counter); // 3
複製程式碼

上面程式碼說明,lib.js模組載入以後,它的內部變化就影響不到輸出的mod.counter了。這是因為mod.counter是一個原始型別的值,會被快取。除非寫成一個函式,才能得到內部變動後的值。

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

上面程式碼中,輸出的counter屬性實際上是一個取值器函式。現在再執行main.js,就可以正確讀取內部變數counter的變動了。

$ node main.js
3
4
複製程式碼

ES6 模組的執行機制與 CommonJS 不一樣。JS 引擎對指令碼靜態分析的時候,遇到模組載入命令import,就會生成一個只讀引用。等到指令碼真正執行時,再根據這個只讀引用,到被載入的那個模組裡面去取值。換句話說,ES6 的import有點像 Unix 系統的“符號連線”,原始值變了,import載入的值也會跟著變。因此,ES6 模組是動態引用,並且不會快取值,模組裡面的變數繫結其所在的模組

// 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內部的變化。

// 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能否正確讀取這個變化。

$ babel-node m2.js

bar
baz
複製程式碼

上面程式碼表明,ES6 模組不會快取執行結果,而是動態地去被載入的模組取值,並且變數總是繫結其所在的模組。

由於 ES6 輸入的模組變數,只是一個“符號連線”,所以這個變數是隻讀的,對它進行重新賦值會報錯

// 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通過介面,輸出的是同一個值。不同的指令碼載入這個介面,得到的都是同樣的例項。

// 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的例項。不同的指令碼載入這個模組,得到的都是同一個例項。

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

$ babel-node main.js
1
複製程式碼

這就證明了x.js和y.js載入的都是C的同一個例項。

3.Node載入

1.概述

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 'baz';
import 'abc/123';
複製程式碼

如果模組名包含路徑,那麼import命令會按照路徑去尋找這個名字的指令碼檔案

import 'file:///etc/config/app.json';
import './foo';
import './foo?search';
import '../bar';
import '/baz';
複製程式碼

如果指令碼檔案省略了字尾名,比如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命令是非同步載入,這一點與瀏覽器的處理方法相同。

2.內部變數

ES6 模組應該是通用的,同一個模組不用修改,就可以用在瀏覽器環境和伺服器環境。為了達到這個目標,Node 規定 ES6 模組之中不能使用 CommonJS 模組的特有的一些內部變數

首先,就是this關鍵字。ES6 模組之中,頂層的this指向undefined;CommonJS 模組的頂層this指向當前模組,這是兩者的一個重大差異

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

arguments
require
module
exports
__filename
__dirname
複製程式碼

如果你一定要使用這些變數,有一個變通方法,就是寫一個 CommonJS 模組輸出這些變數,然後再用 ES6 模組載入這個 CommonJS 模組。但是這樣一來,該 ES6 模組就不能直接用於瀏覽器環境了,所以不推薦這樣做。

// expose.js
module.exports = {__dirname};

// use.mjs
import expose from './expose.js';
const {__dirname} = expose;
複製程式碼

上面程式碼中,expose.js是一個 CommonJS 模組,輸出變數__dirname,該變數在 ES6 模組之中不存在。ES6 模組載入expose.js,就可以得到__dirname。

3.ES6模組載入CommonJs模組

CommonJS 模組的輸出都定義在module.exports這個屬性上面。Node 的import命令載入 CommonJS模組,Node 會自動將module.exports屬性,當作模組的預設輸出,即等同於export default xxx

// a.js
module.exports = {
  foo: 'hello',
  bar: 'world'
};

// 等同於
export default {
  foo: 'hello',
  bar: 'world'
};
複製程式碼

import命令載入上面的模組,module.exports會被視為預設輸出,即import命令實際上輸入的是這樣一個物件{ default: module.exports }

所以,一共有三種寫法,可以拿到 CommonJS 模組的module.exports

// 寫法一
import baz from './a';
// baz = {foo: 'hello', bar: 'world'};

// 寫法二
import {default as baz} from './a';
// baz = {foo: 'hello', bar: 'world'};

// 寫法三
import * as baz from './a';
// baz = {
//   get default() {return module.exports;},
//   get foo() {return this.default.foo}.bind(baz),
//   get bar() {return this.default.bar}.bind(baz)
// }
複製程式碼

上面程式碼的第三種寫法,可以通過baz.default拿到module.exports。foo屬性和bar屬性就是可以通過這種方法拿到了module.exports

// b.js
module.exports = null;

// es.js
import foo from './b';
// foo = null;

import * as bar from './b';
// bar = { default:null };
複製程式碼

上面程式碼中,es.js採用第二種寫法時,要通過bar.default這樣的寫法,才能拿到module.exports

// c.js
module.exports = function two() {
  return 2;
};

// es.js
import foo from './c';
foo(); // 2

import * as bar from './c';
bar.default(); // 2
bar(); // throws, bar is not a function
複製程式碼

上面程式碼中,bar本身是一個物件,不能當作函式呼叫,只能通過bar.default呼叫

CommonJS 模組的輸出快取機制,在 ES6 載入方式下依然有效

// foo.js
module.exports = 123;
setTimeout(_ => module.exports = null);
複製程式碼

上面程式碼中,對於載入foo.js的指令碼,module.exports將一直是123,而不會變成null

由於 ES6 模組是編譯時確定輸出介面,CommonJS 模組是執行時確定輸出介面,所以採用import命令載入 CommonJS 模組時,不允許採用下面的寫法。

// 不正確
import { readFile } from 'fs';
複製程式碼

上面的寫法不正確,因為fs是 CommonJS 格式,只有在執行時才能確定readFile介面,而import命令要求編譯時就確定這個介面。解決方法就是改為整體輸入。

// 正確的寫法一
import * as express from 'express';
const app = express.default();

// 正確的寫法二
import express from 'express';
const app = express();
複製程式碼

4.CommonJs模組載入ES6模組

CommonJS 模組載入 ES6 模組,不能使用require命令,而要使用import()函式。ES6 模組的所有輸出介面,會成為輸入物件的屬性

// es.mjs
let foo = { bar: 'my-default' };
export default foo;

// cjs.js
const es_namespace = await import('./es.mjs');
// es_namespace = {
//   get default() {
//     ...
//   }
// }
console.log(es_namespace.default);
// { bar:'my-default' }
複製程式碼

上面程式碼中,default介面變成了es_namespace.default屬性。

// es.js
export let foo = { bar:'my-default' };
export { foo as bar };
export function f() {};
export class c {};

// cjs.js
const es_namespace = await import('./es');
// es_namespace = {
//   get foo() {return foo;}
//   get bar() {return foo;}
//   get f() {return f;}
//   get c() {return c;}
// }
複製程式碼

4.迴圈載入

“迴圈載入”(circular dependency)指的是,a指令碼的執行依賴b指令碼,而b指令碼的執行又依賴a指令碼

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

// b.js
var a = require('a');
複製程式碼

通常,“迴圈載入”表示存在強耦合,如果處理不好,還可能導致遞迴載入,使得程式無法執行,因此應該避免出現

但是實際上,這是很難避免的,尤其是依賴關係複雜的大專案,很容易出現a依賴b,b依賴c,c又依賴a這樣的情況。這意味著,模組載入機制必須考慮“迴圈載入”的情況

對於 JavaScript 語言來說,目前最常見的兩種模組格式CommonJS 和 ES6,處理“迴圈載入”的方法是不一樣的,返回的結果也不一樣

1.CommonJs的迴圈載入

CommonJs 模組的重要特性是載入時執行,即指令碼程式碼在require的時候,就會全部執行。一旦出現某個模組被"迴圈載入",就只輸出已經執行的部分,還未執行的部分不會輸出。

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的程式碼。

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已經執行的部分,只有一行

exports.done = false;
複製程式碼

因此,對於b.js來說,它從a.js只輸入一個變數done,值為false

然後,b.js接著往下執行,等到全部執行完畢,再把執行權交還給a.js。於是,a.js接著往下執行,直到執行完畢。我們寫一個指令碼main.js,驗證這個過程。

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,執行結果如下。

$ 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的執行結果,即它的第四行

exports.done = true;
複製程式碼

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

另外,由於 CommonJS 模組遇到迴圈載入時,返回的是當前已經執行的部分的值,而不是程式碼全部執行後的值,兩者可能會有差異。所以,輸入變數的時候,必須非常小心

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')會更保險一點

2.ES6模組的迴圈載入

ES6 處理“迴圈載入”與 CommonJS 有本質的不同。ES6 模組是動態引用,如果使用import從一個模組載入變數(即import foo from 'foo'),那些變數不會被快取,而是成為一個指向被載入模組的引用,需要開發者自己保證,真正取值的時候能夠取到值。

// 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,結果如下。

$ 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寫成函式來解決。

// 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就可以得到預期結果。

$ node --experimental-modules a.mjs
b.mjs
foo
a.mjs
bar
複製程式碼

這是因為函式具有提升作用,在執行import {bar} from './b'時,函式foo就已經有定義了,所以b.mjs載入的時候不會報錯。這也意味著,如果把函式foo改寫成函式表示式,也會報錯。

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

上面程式碼的第四行,改成了函式表示式,就不具有提升作用,執行就會報錯

// 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也會做類似操作。

$ 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。

// 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)就會報錯。

$ node
> var m = require('./even');
> m.even(10)
TypeError: even is not a function
複製程式碼

5.ES6模組的轉碼

瀏覽器目前還不支援 ES6 模組,為了現在就能使用,可以將其轉為 ES5 的寫法。除了 Babel 可以用來轉碼之外,還有以下兩個方法,也可以用來轉碼。

1.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
複製程式碼

2.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 物件,可以針對這個物件程式設計。下面是一個模組檔案。

// app/es6-file.js:

export class q {
  constructor() {
    this.es6 = 'hello';
  }
}
複製程式碼

然後,在網頁內載入這個模組檔案。

<script>

System.import('app/es6-file').then(function(m) {
  console.log(new m.q().es6); // hello
});

</script>
複製程式碼

上面程式碼中,System.import方法返回的是一個 Promise 物件,所以可以用then方法指定回撥函式。

十、module.exports與exports、export、export default、exports.default區別

首先我們要明白一個前提,CommonJS模組規範和ES6模組規範完全是兩種不同的概念。

1.CommonJs模組規範

Node應用由模組組成,採用CommonJS模組規範。

根據這個規範,每個檔案就是一個模組,有自己的作用域。在一個檔案裡面定義的變數、函式、類,都是私有的,對其他檔案不可見。

CommonJS規範規定,每個模組內部,module變數代表當前模組。這個變數是一個物件,它的exports屬性(即module.exports)是對外的介面。載入某個模組,其實是載入該模組的module.exports屬性。

var x = 5;
var addX = function (value) {
  return value + x;
};
module.exports.x = x;
module.exports.addX = addX;
複製程式碼

上面程式碼通過module.exports輸出變數x和函式addX

require方法用於載入模組

var example = require('./example.js');

console.log(example.x); // 5
console.log(example.addX(1)); // 6
複製程式碼

2.ES6模組規範

不同於CommonJS,ES6使用 export 和 import 來匯出、匯入模組。

// profile.js
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;

export {firstName, lastName, year};
複製程式碼

需要特別注意的是,export命令規定的是對外的介面,必須與模組內部的變數建立一一對應關係

// 寫法一
export var m = 1;

// 寫法二
var m = 1;
export {m};

// 寫法三
var n = 1;
export {n as m};
複製程式碼

3.exports與module.exports

為了方便,Node為每個模組提供一個exports變數,指向module.exports。這等同在每個模組頭部,有一行這樣的命令。

var exports = module.exports;
複製程式碼

於是我們可以直接在 exports 物件上新增方法,表示對外輸出的介面,如同在module.exports上新增一樣。注意,不能直接將exports變數指向一個值,因為這樣等於切斷了exports與module.exports的聯絡。

4.export default 與 exports.default 命令

使用export default命令,為模組指定預設輸出

require 引入的是 module.exports

import 引入的是module.exports.default

// export-default.js
export default function () {
  console.log('foo');
}
複製程式碼

如果你覺得我的文章還不錯的話,可以給個star哦~,GitHub地址

相關文章