深入理解Javascript之Module

darjun發表於2018-12-24

什麼是模組

模組(module)是什麼呢? 模組是為了軟體封裝,複用。當今開源運動盛行,我們可以很方便地使用別人編寫好的模組,而不用自己從頭開始編寫。在程式設計中,我們一直強調避免重複造輪子(Don't Repeat Yourself,DRY)。

想象一下,沒有模組的日子,第三庫基本都是匯出一個全域性變數供開發者使用。例如jQuery$lodash_。這些庫已經儘量避免了全域性變數衝突,只使用幾個全域性變數。但是還是不能避免有衝突,jQuery還提供了noConflict。更遑論我們自己編寫的程式碼。

最初,Javascript 中是沒有模組的概念的。這可能與一開始 Javascript 的定位有關。Javascript 最初只是希望給網頁增加動態元素,定位是簡單易用的指令碼。 但是,隨著網頁端功能越來越豐富,程式越來越龐大,軟體變得越來越難以維護。特別是隨著 NodeJs 的興起,Javascript 語言進入服務端程式設計領域。在編寫大型複雜的程式,模組更是必須品。

模組只是一個抽象概念,要想在實際程式設計中使用還需要規範。如果沒有規範,我有這種寫法,你用那種寫法,豈不是亂了套。

目前,模組的規範主要有3中,CommonJS模組AMD模組和ES6模組。本文著重講解 CommonJS 模組(以 Node 實現為代表)和ES6模組。

2.CommonJS模組

CommonJS 其實是一個通用的 Javascript 語言規範,並不僅僅是模組的規範。Node 中的模組遵循 CommonJS 標準。

基本用法

Node 中提供了一個require方法用來載入模組。例如:

var fs = require('fs');

fs.readFile('file1.txt', 'utf8', function (err, data) {
    if (err) {
        console.error(err);
    } else {
        console.log(data);
    }
});
複製程式碼

匯入模組之後就可以使用模組中定義的介面了,如上例中的readFile

模組類別

在 Node 中大體上有3種模組,普通模組、核心模組和第三方模組。 普通模組與核心模組的匯入方式稍微有些區別。普通模組是我們自己編寫的模組,核心模組是 Node 提供的模組。上面我們使用的fs就是核心模組。匯入普通模組時,需要在require的引數中指定相對路徑。例如:

var myModule = require('./myModule');
myModule.func1();
複製程式碼

模組myModule的字尾.js字尾可以省略。

Node 將核心模組編譯進了引擎。匯入核心模組只需要指定模組名,Node 引擎直接查詢核心模組字典。

第三方模組的匯入也是指定模組名,但是模組的查詢方式有所不同。 首先,在專案目錄下的node_modules目錄中查詢。 如果沒有找到,接著去專案目錄的父目錄中查詢。 直到找到載入該模組,或者到根目錄還未找到返回失敗。

定義模組

在我們日常的程式設計中,經常需要將一些功能封裝在一個模組中,方便自己或他人使用。在 Node 中定義模組的語法很簡單。模組單獨在一個檔案中,檔案中可以使用exports匯出介面或變數。例如:

function addTwoNumber(a, b) {
    return a + b;
}

exports.addTwoNumber = addTwoNumber;
複製程式碼

假設該模組在檔案myMath.js中。在同一目錄下,我們可以這樣來使用:

var myMath = require('./myMath');

console.log(myMath.addTwoNumber(10, 20)); // 30
複製程式碼

模組匯出詳解

函式具體是怎麼匯出的呢?除了exports,我們經常看到的module.exports__dirname__filename是從哪裡來的? 在執行require函式的時候,我們可以理解 Node 額外做了一些處理。

  • 首先,將模組所在檔案內容讀出來。然後將這些內容包裹在一個函式中:
function _doRequire(module, exports, __filename, __dirname) {
    // 模組檔案內容
}
複製程式碼
  • 接下來,Node 引擎構造一個空的模組物件,給這個物件一個空的exports屬性,然後推算出__filename(當前匯入的這個模組的全路徑檔名)和__dirname(模組檔案所在路徑):
var module = {};
module.exports = {}
// __filename = ...
// __dirname = ...
複製程式碼
  • 然後,呼叫第一步構造的那個函式,傳入引數:
_doRequire(module, module.exports, __filename, __dirname);
複製程式碼
  • 最後require返回的是module.exports的值。

按照上面的過程,我們可以很清楚地理解模組的匯出過程。並且也能很快地判斷一些寫法是否有問題:

錯誤寫法:

function addTwoNumber(a, b) {
    return a + b;
}

exports = {
    addTwoNumber: addTwoNumber;
}
複製程式碼

這種寫法為什麼不對?exports實際上初始時是module.exports的一個引用。給exports賦一個新值後,module.exports並沒有改變,還是指向空物件。最後返回的物件是module.exports,沒有addTwoNumber介面。

正確寫法:

function addTwoNumber(a, b) {
    return a + b;
}

// 正確寫法一
exports.addTwoNumber = addTwoNumber;

// 正確寫法二
module.exports.addTwoNumber = addTwoNumber;

// 正確寫法三
module.exports = {
    addTwoNumber: addTwoNumber
};
複製程式碼

exportsmodule.exports開始指向的是同一個物件。寫法一通過exports設定屬性,同樣對module.exports也可見。寫法二通過module.exports設定屬性也可以匯出。 寫法三直接設定module.exports就更不用說了。

建議在程式開發中,堅持一種寫法。個人覺得寫法三顯示設定相對較容易理解。

**有一點需要注意:不是隻有物件可以匯出,函式、類等值也可以。**例如下面就匯出了一個函式:

function addTwoNumber(a, b) {
    return a + b;
}

module.exports = addTwoNumber;
複製程式碼

3.ES模組

ES6 在標準層面為 Javascript 引入了一套簡單的模組系統。ES6 模組完全可以取代 CommonJS 和 AMD 規範。當前熱門的開源框架 React 和 Vue 都已經使用了 ES6 模組來開發。

基本使用

ES6 模組使用export匯出介面,import from匯入需要使用的介面:

// myMath.js
export var pi = 3.14;

export function addTwoNumber(a, b) {
    return a + b;
}

// 或
var pi = 3.14;

function addTwoNumber(a, b) {
    return a + b;
}

export { pi, addTwoNumber };
複製程式碼
// main.js
import { addTwoNumber } from './myMath';

console.log(addTwoNumber(10, 20));
複製程式碼

myMath.js中通過export匯出一個變數pi和一個函式addTwoNumber。上例中演示了兩種匯出方式。一種是一個個匯出,對每一個需要匯出的介面都應用一次export。第二種是在檔案中某處集中匯出。當然,也可以混合使用這兩種方式。推薦使用第二種匯出方式,因為能在一處比較清楚的看出模組匯出了哪些介面。

ES6 模組特性

ES6 模組有一些需要了解和注意的特性。

靜態載入

ES6 模組最重要的特性是“靜態載入”,匯入的介面是隻讀的,不能修改。NodeJS 中的模組,是動態載入的。

靜態載入就是“編譯”時就已經確定了模組匯出,可以做到高效率,並且便於做靜態程式碼分析。同時,靜態載入也限制了模組的載入在檔案中所有語句之前,並且匯入語法中不能含有動態的語法結構(例如變數、if語句等)。

例如:

// 可以呼叫,因為模組載入是“編譯”時進行的。
funcA();
import { funcA, funcB } from './myModule';

// 錯誤,匯入語法中含有變數
var foo = './myModule';
import { funcA, funcB } from './myModule';

// 錯誤,在if語句中
if (foo == "myModule") {
    import { funcA, funcB } from './myModule';
} else {
    import { funcA, funcB } from './hisModule';
}


// 錯誤,匯出的介面是隻讀的,不能修改
import { funcA, funcB } from './myModule';
funcA = function () {};
複製程式碼

匯出的介面與模組中定義的變數或函式必須是一一對應的。而且模組內相應的值修改了,外部也能感知到。看下面程式碼:

// 錯誤,匯出值1,模組中沒有對應
export 1;

// 錯誤,實際上也是匯出1,模組中沒有對應
var m = 1;
export m;

// 可以這樣來匯出,匯出的m與模組中的變數m對應
export var m = 1;

// 可以這樣匯出
var m = 1;
export {m};
複製程式碼
var foo = "bar";
setTimeout(2000, () => { foo = "baz"});

// 2s後foo變為"baz",外部能感知到
複製程式碼
別名

在匯出模組時,可以為介面指定一個別名。這樣,後續可以修改內部介面而保持匯出介面不變。例如:

// myModule.js
var funcA = function () {
}

var funcB = function () {
}

export {
    funcA as func1,
    funcB as func2,
    funcB as myFunc,
}
複製程式碼

上面我們匯出以別名func1匯出函式funcA,以別名func2myFunc匯出函式funcBfunc2myFunc都是指向同一個函式funcB的。下面看看使用這個模組:

// main.js
import { func1, func2, myFunc } from './myModule';
複製程式碼

同樣的,匯入模組時也可以指定別名:

// main.js
import { func1 as func } from './myModule';
複製程式碼
default匯出

上面介紹的模組匯入必須知道介面名字。有時候,使用者學習一個模組時希望能夠快速上手,不想去看文件(怎麼會有這個懶的人?)。ES6 提供了default匯出。例如:

// myModule.js
export default function () {
    console.log('hi');
}

// default匯出方式可以看做是匯出了一個別名為default的介面
var f = function () {
    console.log('hi');
}
export { f as default };
複製程式碼

在外部匯入的時候,需要省略花括號:

// main.js
import func from './myModule';
func();
複製程式碼

也可以兩種方式,同時使用:

// myModule.js
function foo() {
    console.log('foo');
}

export default foo;

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

export { bar };
複製程式碼
// main.js
import foo, { bar } from './myModule';
複製程式碼
整體載入

ES6 還允許一種整體載入的方式匯入模組。通過使用import *可以匯入模組中匯出的所有介面:

// myModule.js
export function funcA() {
    console.log('funcA');
}

export function funcB() {
    console.log('funcB');
}
複製程式碼
// main.js
import * as m from './myModule';

m.funcA();
m.funcB();
複製程式碼

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

// main.js
import * as m from './myModule';

// 錯誤
m.name = 'darjun';
m.func = function () {};
複製程式碼
Node 中使用 ES6 模組

Node 由於已經有 CommonJS 的模組規範了,與 ES6 模組不相容。為了使用 ES6 模組,Node 要求 ES6 模組採用.mjs字尾名,而且檔案中只能使用importexport,不能使用require。而且該功能還在試驗階段,Node v8.5.0以上版本,指定--experimental-modules引數才能使用:

// myModule.mjs
var counter = 1;

export function incCounter() {
    console.log('counter:', counter);
    counter++;
}
複製程式碼
// main.mjs
import { incCounter } from './myModule';

incCounter();
複製程式碼

使用下面命令列執行程式:

$ node --experimental-modules main.mjs
複製程式碼

4.總結

隨著 Javascript 在大型專案中佔用舉足輕重的位置,模組的使用稱為必然。Node 中使用 CommonJS 規範。ES6 中定義了簡單易用高效的模組規範。ES6 規範化是個必然的趨勢,所以在掌握當前 CommonJS 規範的前提下,學習 ES6 模組勢在必行。

5.參考連結

  1. Javascript模組化程式設計(一)
  2. Javascript模組化程式設計(二)
  3. Javascript模組化程式設計(三)
  4. ES6 Module

關於我: 個人主頁 簡書 掘金

相關文章