從 node.js 原始碼看 exports 與 module.exports

一、前言

在 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 的使用還不是很了解,那麼我推薦以下這兩篇文章,特別是第二篇,絕對很值得一讀:


本文特地從 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 初始化時被掛上去的呀~
 
 
 
 
simen

An enthusiastic engineer with a passion for learning. After completing my academic journey, I worked as an engineer in Hsinchu Science Park. Later, I ventured into academia to teach at a university. However, I have now returned to the industry as an engineer, again.

Post a Comment (0)
Previous Post Next Post