Node.js module system (二) - exports 和 module.exports 的差異

在我的Node.js - 模組系統 (一)文章裡, 初步的認識了 Node.js 模組系統的運作原理, 在這篇文章中, 將更深入探討模組系統到底是怎麼一回事。

CommonJS 模組規範

瀏覽器中執行的 JavaScript 是不支援模組化的, 但在 Node.js 中參照 CommonJS 規範, 設計了一套模組化系統

  • 模組(文件)作用域 (每個文件的成員都是封閉的, 預設無法互相取用, 需透過導出和載入)
  • 通信規則
    • 使用 require 來載入模組
    • 使用 exports 接口物件來導出模組中成員

關於瀏覽器中的 JS 及 Node.js 的比較, 請參考認識 Node.js

exports 物件和 module.exports 物件差異

Node.js - 模組系統 (一)中, 導出模組成員時是使用 exports 物件做導出的, 如以下範例

B.js 導出成員

1
2
3
// B.js
exports.aaa = 'aaa'
exports.bbb = function() {}

A.js 引入成員

1
2
3
// A.js
let B_exports = require('./B')
console.log(B_exports)

終端機執行 node A, 成功的取得 B 導出的成員

1
{ aaa: 'aaa', bbb: [Function] }

在上一篇沒有提到的是, exports 其實是一個簡化的寫法, 在 Node 模組系統中, 真正被導出的物件其實叫做 module.exports, 下面來做個實驗看看

B.js 導出成員

1
2
3
// B.js
module.exports.aaa = 'aaa'
module.exports.bbb = function() {}

A.js 引入成員

1
2
3
// A.js
let B_exports = require('./B')
console.log(B_exports)

終端機執行 node A, 得到的結果和使用 exports 物件導出是一樣的

1
{ aaa: 'aaa', bbb: [Function] }

嗯? 我說那個原理呢?

若你對 JavaScript 有基本的認識, 應該對 by reference (傳參考)的概念不陌生, 其實就是透過這個原理做到的。

在 JS 中物件型別特性是傳參考,若將一個A變數指向某個物件,當修改A變數或是指向的那個物件內部任一屬性,都會影響另一個物件,因為現在這兩個變數都被指向同一個記憶體位置。

範例

1
2
3
4
5
var obj1 = { name: 'dylan', age: 18 };
var obj2 = obj1;
obj2.age = 21;

console.log(obj1); // {name: "dylan", age: 21}

了解這個概念後, 你可以想像一件事, Node.js 在模組系統的底層寫了這麼一段程式碼

1
2
3
var module = {
exports : {}
}

module.exports 是被預設的接口物件, 每一個 Node.js 的模組底層都有這麼一個空物件,你可以想像, 在程式碼的最後 Node 會將 module.exports 物件 return

1
return module.exports

之後誰來 require 這個模組, 誰就可以得到這個 module.exports 物件。


but 這個物件的名稱實在是太長了, 如果要導出多個物件你可能需要

1
2
3
4
module.exports.aaa = 'aaa'
module.exports.bbb = 'bbb'
module.exports.ccc = 'ccc'
// loop ...

當然你也可以這麼做啦

1
2
3
4
5
module.exports = {
aaa: 'aaa',
bbb: 'bbb',
ccc: 'ccc',
}

不過貼心如 Node, 還是為你做了一件事情

1
let exports = module.exports

它宣告了一個新變數 exports 指向了 module.exports 接口物件的記憶體, 所以透過傳參考的特性, 不管你對兩個中的哪個物件新增成員, 他們都會同步被改變。關於這點你可以在任一 JS 檔貼上下面這段程式碼, 並且在 Node 環境執行它, 你會得到 true 的結果

1
console.log(exports === module.exports)

好的, 了解這個概念後, 現在你可以簡化成

1
2
3
exports.aaa = 'aaa'
exports.bbb = 'bbb'
exports.ccc = 'ccc'

但是你千萬不能這麼做

1
2
3
4
5
exports = {
aaa: 'aaa',
bbb: 'bbb',
ccc: 'ccc',
}

exports 物件陷阱

為什麼不能這麼做呢? 來實際實驗看看

B.js

1
2
3
4
5
exports = {
aaa: 'aaa',
bbb: 'bbb',
ccc: 'ccc',
}

A.js

1
2
let B_exports = require('./B')
console.log(B_exports)

終端機執行 node A, 得到的結果

1
{}

奇怪了? exports 不是等同於 module.exports 嗎? 為什麼引入模組得到的是空物件?

其實, 如果你真的理解 JavaScript 傳參考的特性, 就有答案了。

來看看一個簡單傳參考範例

首先先建立一個傳參考物件

1
2
3
4
5
let obj1 = { name: 'dylan', age: 18 }
let obj2 = obj1
obj2.hobby = 'coding'

console.log(obj1, obj2)

輸出結果

1
2
{name: 'dylan', age: 18, hobby: 'coding'} // obj1
{name: 'dylan', age: 18, hobby: 'coding'} // obj2

到目前為止沒有問題, 但是若你對 obj2 物件重新賦值( 也是就重新讓 obj2 等於某某東西時 )

1
2
3
4
5
6
7
obj2 = {
aaa: 'aaa',
bbb: 'bbb',
ccc: 'ccc',
}

console.log(obj1)

輸出結果, obj1 並沒有被影響

1
{name: "dylan", age: 18, hobby: "coding"}

當你對傳參考物件(obj2)重新賦值時, 它的記憶體位置已經不再指向和 obj1 所指向的 {name: 'dylan', age: 18, hobby: 'coding' 物件了, 它已是另外一個獨立的物件, 也就是說傳參考的連結在你重新賦值時即斷開。

所以, 在 Node.js 中, 請不要對 exports 物件做任何重新賦值的行為, 如果有需要只能使用 module.exports 物件。

總結實戰時最常用使用方式:

  • 導出多個成員 (也就是導出一個物件, 成員都放在裡頭)
    • exports.XXX = xxxmodule.exports.XXX = xxx 都可以
    • module.exports = { ... } (只能是 module.exports)
  • 導出單個成員 (無論指向什麼型別的東西, 導出的東西就是它一個)
    • module.exports = XXX (只能是 module.exports)

最後, 若對上列的描述還是覺得不夠清楚, 你可以再看一次(毆).. 不是啦, 如果你覺得要理解這些有點麻煩, 就一律都使用 module.exports 就可以避免所有問題了。