一、前言
在 node 中,一個 .js 檔可以視為一個 module (模組),你的套件裡面可以由大大小小不同的 modules 組合而成。眾多的 modules 組合出來的整體,可稱為套件 (package)。當我們使用 require() 引進一支 .js 檔時,node 是如何將它引入的呢?為什麼你的 .js 檔擁有自己的 scope,你可以在裡面撰寫屬於該模組自己的私有變數?你的模組為什麼是透過 exports 或 module.exports 來揭露它對外的介面?為什麼我們說 exports 是 module.exports 的 shorthand(捷徑),當你的模組需要傳出函式(或建構式)時,你應該使用 module.exports = fn,而不是 exports?
假使您對於如何使用 exports 以及 module.exports 的使用還不是很了解,那麼我推薦以下這兩篇文章,特別是第二篇,絕對很值得一讀:
- What is a Module - Cho S. Kim
- Export This: Interface Design Patterns for Node.js Modules - Alon Salant
- Andy 超佛心中譯整理版:參透 Node 中 exports 的 7 種設計模式
本文特地從 node.js 的 bootstrapping 程式碼追起,從 node 如何載入 native module 再延伸至載入一般 module。node 的模組載入有它一套完善的機制,我的心得僅限於比較淺層的追蹤 (因為我也看不懂 node_contextify.cc 這樣的底層程式),但我相信已經能足以表達出 exports 與 module.exports 的意思。
二、從 node.js core 的初始化談起
node.js 的核心初始化程式碼位於原始碼目錄底下的 src/node.js,它的內容是一支函式,並且自 node.cc 中的 StartNodeInstance() -> CreateEnvironment() -> LoadEnvironment() 繼而被執行起來。node.js 這支程式進行了許多初始化工作,其中一部分則是將核心模組(像 events, fs, console 等) 一一載入並將它們 cached 起來。
底下的程式碼源於 node.js v4.5.0
- 在 node.js 裡有一支 evalScript(),它內部 require 了核心模組 'module'。我們略過 evalScript() 的內容,先看 NativeModule 這個 class 為何物
'use strict'; // node core bootstrapping (function(process) { this.global = this; // ... 略 function evalScript(name) { // 看到 NativeModule.require 這支靜態方法, 先暫停一下 var Module = NativeModule.require('module'); // ...
- NativeModule 是一個建構式,你可以看到它的實例將擁有一個 this.exports 的屬性,預設是一個空物件。在初始化過程中,將會使用 NativeModule 的靜態方法 require 來載入核心模組,並將其 cached 住
function NativeModule(id) { this.filename = `${id}.js`; this.id = id; this.exports = {}; // 預設 this.exports 指向一個空物件 this.loaded = false; this.loading = true; } // ... 略 NativeModule.require = function(id) { // ... 前面先檢查模組是否存在於快取 // 如果有, 則將它撈出來後直接 return 它的 exports 屬性 // 如果沒有, 就 new 一個模組物件 var nativeModule = new NativeModule(id); // 然後 cache 起來 nativeModule.cache(); // 下一步是執行 compile(), 下面看一下 compile 做了什麼 nativeModule.compile(); return nativeModule.exports; };
- compile() 做的事情,第一個是先使用靜態方法 NativeModule.wrap() 將載入的 source code 包裹起來,第二則是呼叫 runInThisContext() 交由更底層 (v8) 來為這個載入的程式碼產生自己的 context,它傳出的一支函式 fn,接著馬上被呼叫 (這就像 bundler 使用立即函式在打包模組的概念,只是這裡拆了兩個步驟,主要是 context 的封裝是交由更底層建構起來的,而不是像一般的 JS 程式碼使用 literal 的立即函式去封裝)
- 這裡你應該要注意,fn 被呼叫時,node.js 傳入了什麼參數給它
NativeModule.prototype.compile = function() { var source = NativeModule.getSource(this.id); // 關鍵在你的 .js 檔 source code 會經過靜態方法 .wrap() 打包 // 我們在下一段會看到 wrap() 做了什麼事 source = NativeModule.wrap(source); this.loading = true; try { // runInThisContext() 不言自明, 讓你的 source 有自己的 scope // 它傳出一支函式 fn var fn = runInThisContext(source, { filename: this.filename, lineOffset: 0 }); // 接著執行 fn, 並將 this.exports 等等參數傳入 fn(this.exports, NativeModule.require, this, this.filename); this.loaded = true; } finally { this.loading = false; } }; NativeModule.prototype.cache = function() { NativeModule._cache[this.id] = this; };
- NativeModule.wrap() 使用 function(...) { 與 }); 將 source code 給包裹住了。這裡請注意包裹後的函式,它的簽署,它說明了為什麼我們在寫模組時,為什麼可以直接呼叫 require 以及使用 exports 或 module.exports,因為它們都是被當成參數傳進去的
// 現在你的 source 被包裹進一個函式中, 它的簽署是 // function (exports, require, module, __filename, __dirname) // 這也是為什麼你可以在你的 .js 檔中使用 exports, require, module 的原因 // 其實他們都是本地的變數(function 的 parameters) NativeModule.wrap = function(script) { return NativeModule.wrapper[0] + script + NativeModule.wrapper[1]; }; NativeModule.wrapper = [ '(function (exports, require, module, __filename, __dirname) { ', '\n});' ]; // 回顧上一段的 fn(this.exports, NativeModule.require, this, this.filename); // 對照一下, exports, require, module, ... 就從這裡塞進去的
- 根據以上的說明,在你的模組中,exports 其實是一個函式內的本地變數,它指向 module.exports,預設是一個空物件
- 因此,如果你的模組準備揭露一些函式,你可以實作 exports.methodA 、 exports.methodB、exports.methodC 等等,這些函式都會被掛在 module.exports 的物件底下,因為 exports 就是指向那個物件阿
- 如果,我們 assign 了新的值給 module.exports,例如一個函式、建構式、字串、數字、另一個物件,exports 這個捷徑變數仍然指向了舊的那個預設物件。可是真正被揭露出去的,還是 module.exports 身上所掛的東西(看你愛掛甚麼就掛什麼)
- 或者,我們令 exports = xxx,那麼 xxx 也是不會被 export 出去。因為這樣只是打斷 exports 指向 module.exports 而已
- 說到底,其實沒那麼難懂,就只是變數 (exports) 指向一個物件 (module.exports) 的問題而已。類似的情況在寫一般程式時也經常遇到,沒有理由名稱換成 exports 就好像很神祕一樣
- 好啦!其實我習慣都是用 module.exports,比較沒有困擾 XDDD
三、核心模組 'module'
還記得最前面有看到一條載入 'module' 的程式碼,如下。這個核心模組位於 /lib/module.js(function(process) { // ... 略 function evalScript(name) { // 看到 NativeModule.require 這支靜態方法, 先暫停一下 var Module = NativeModule.require('module'); // ...
- /lib/module.js,它的建構式長的跟 NativeModule 很像,而且它的靜態屬性 wrapper 與靜態方法 wrap 根本和 NativeModule 一樣。所以,我們大概可以想像,不管是對核心模組還是外部模組,整個概念都是類似的,比較有點差別的 NativeModule 跟 Module 的 require 方法有點不同
function Module(id, parent) { this.id = id; this.exports = {}; this.parent = parent; // ... 略 } module.exports = Module; // ... 它所做的事情, 跟 NativeModule 一樣 Module.wrapper = NativeModule.wrapper; Module.wrap = NativeModule.wrap;
- Module 的 require 方法,你必須塞入 file path 給它
// Loads a module at the given file path. Returns that module's // `exports` property. Module.prototype.require = function(path) { assert(path, 'missing path'); assert(typeof path === 'string', 'path must be a string'); return Module._load(path, this); };
四、結語
說到這裡,大致上也理出一些頭緒來了!真的寫得好累阿~ 應該有人會覺得我怎麼那麼無聊,為了這一點點小東西,寫了那麼一大堆。哈哈,其實我也不知道,本來只是要講 exports 跟 module.exports 到底差在哪,可是感覺那好像沒什麼好寫的。因為書上或網路上都講很多了啦,然後就激起我想要把它寫完整一點的慾望!呼~ 不管怎樣,終於是完成了。
最後,大家如果有興趣可以多看 evalScript() 幾眼,你也會發現 __dirname 這種全域變數是如何在 node.js core 初始化時被掛上去的呀~