NodeJS模塊機制及其應用

前言

早期的JavaScript因爲缺少模塊系統。要編寫JS腳本,必須依賴HTML對其進行管理,嚴重製約了JavaScript的發展。而CommonJS規範的提出,賦予了JavaScript開發大型應用程序的基礎能力。其中NodeJS借鑑CommonJS的Modules規範實現了一套簡單易用的模塊系統,爲JavaScript在服務端的開發開闢了道路。html

CommonJS模塊規範

CommonJS的模塊規範定義三個了部分:node

  • 模塊引用:模塊所在的上下文提供require方法,可以接受模塊標識爲參數引入一個模塊的API到當前模塊的上下文中。json

  • 模塊定義:在模塊中,存在一個module對象以表明模塊自己,同時存在exports做用模塊屬性的引用。api

  • 模塊標識:模塊標識即爲require方法的參數,它要求必須爲小駝峯命名的字符串,相對路徑或絕對路徑。數組

Node模塊的實現

  • 模塊引用:在Node模塊的上下文中,存在require方法,可以對模塊進行引入,如const fs = require("fs");瀏覽器

  • 模塊定義:在Node中,以單個文件做爲模塊的基礎單位,即一個文件爲一個模塊,全部掛載到exports對象上的方法屬性即爲導出。緩存

// person.js
exports.name = "vincent";
exports.say = function() {
    console.log("hello world");
};

// driver.js
const person = require("person");
exports.say = function() {
    person.say();
    console.log(`I am ${person.name}`);
};
複製代碼
  • 模塊標識:Node將爲模塊分爲兩類,一類是由Node提供的內建模塊,也稱爲核心模塊;另外一類是用戶編寫的模塊,稱爲用戶或第三方模塊。而Node中的模塊標識符主要分爲如下幾類。bash

    1. 絕對路徑形式:/path/my/module
    2. 相對路徑形式:../path/my/module 或者 ./path/my/module
    3. 模塊名形式: http、fs、koa

以上即是Node對CommonJS模塊規範的實現概覽。但實際上Node對模塊規範進行了必定的取捨,在requireexports module過程當中加入了自身的特點,下面讓咱們來深刻了解一下:app

模塊require過程

  1. 查詢緩存:Node模塊的加載策略和大多數加載器同樣,遵循緩存優先,對於同一模塊的二次加載優先查找緩存。這一點和瀏覽器緩存靜態資源的策略相似,不一樣之處在於Node模塊緩存的是編譯且執行後的模塊對象。除此以外,Node進程在啓動會加載部分核心模塊到內存中,省略了3,4兩個步驟,同時內建模塊在路徑分析中優先判斷,因此加載核心模塊的速度是最快的。
  2. 路徑分析:對模塊標識符進行分析,判斷模塊引入類型,路徑形式的模塊在require時,會講標識符轉化爲真實路徑,並以此爲索引進行緩存;模塊名形式的模塊,若爲核心模塊按照步驟1加載,反之對於第三方模塊,Node會根據模塊路徑字段中的路徑去查找。
    從上圖中能夠看出模塊路徑查找規則能夠歸納爲,沿着當前文件路徑目錄逐級向上查找node_modules目錄。這與JavaScript做用域鏈的查找方式相似,層級越深,查找速度越慢。 下面是require實現的核心邏輯(省略了部分代碼)
Module.prototype.require = function(id) {
    return Module._load(id, this, /* isMain */ false);
};

Module._load = function(request, parent, isMain) {
  let relResolveCacheIdentifier;
  if (parent) {
    // 存在父級模塊時,拼接路徑做爲臨時緩存索引(查詢真實路徑)
    relResolveCacheIdentifier = `${parent.path}\x00${request}`;
    const filename = relativeResolveCache[relResolveCacheIdentifier];
    // 在緩存中查詢模塊
    if (filename !== undefined) {
      const cachedModule = Module._cache[filename];
      if (cachedModule !== undefined) {
        // 將模塊push到父級模塊的children數組中
        updateChildren(parent, cachedModule, true);
        return cachedModule.exports;
      }
      // 刪除臨時索引
      delete relativeResolveCache[relResolveCacheIdentifier];
    }
  }
  // 查詢模塊真實路徑,策略同步驟二
  const filename = Module._resolveFilename(request, parent, isMain);

  const cachedModule = Module._cache[filename];
  // 緩存存在時,返回module.exports
  if (cachedModule !== undefined) {
    updateChildren(parent, cachedModule, true);
    return cachedModule.exports;
  }
  
  // 緩存不存在時,優先查找核心模塊
  const mod = loadNativeModule(filename, request, experimentalModules);
  // 若是能夠被開發者直接reuqire, 那麼直接返回module.exports
  if (mod && mod.canBeRequiredByUsers) return mod.exports;

  // 生成模塊實例並緩存
  const module = new Module(filename, parent);
  Module._cache[filename] = module;
  if (parent !== undefined) {
    relativeResolveCache[relResolveCacheIdentifier] = filename;
  }

  // 是否加載成功,默認失敗
  let threw = true;
  try {
    // 加載模塊,根據文件後綴名使用對應方法
    // .js -> fs.readFileSync -> compile (下一個章節會說明)
    // .json -> fs.readFileSync -> JSON.parse
    // .node -> fs.readFileSync -> dlopen (C/C++模塊)
    // 其餘類型省略
    module.load(filename);
    threw = false;
  } finally {
    // 加載失敗,刪除緩存及其索引
    if (threw) {
      delete Module._cache[filename];
      if (parent !== undefined) {
        delete relativeResolveCache[relResolveCacheIdentifier];
      }
    }
  }
  return module.exports;
};
複製代碼
  1. 文件定位:步驟二講解了模塊路徑的分析策略,下面經過源碼更直接的瞭解文件定位原理。
Module._findPath = function(request, paths, isMain) {
  // 絕對路徑
  const absoluteRequest = path.isAbsolute(request);
  if (absoluteRequest) {
    paths = [''];
  } else if (!paths || paths.length === 0) {
    return false;
  }
    
  // 嘗試經過路徑緩存索引獲取
  const cacheKey = request + '\x00' +
                (paths.length === 1 ? paths[0] : paths.join('\x00'));
  const entry = Module._pathCache[cacheKey];
  if (entry) return entry;

  // 判斷路徑是否以":/"或/結尾
  var exts;
  var trailingSlash = request.length > 0 &&
    request.charCodeAt(request.length - 1) === CHAR_FORWARD_SLASH;
  if (!trailingSlash) {
    trailingSlash = /(?:^|\/)\.?\.$/.test(request);
  }

  // For each path
  for (var i = 0; i < paths.length; i++) {
    // Don't search further if path doesn't exist
    const curPath = paths[i];
    if (curPath && stat(curPath) < 1) continue;
    var basePath = resolveExports(curPath, request, absoluteRequest);
    var filename;
    // 查詢文件類型
    var rc = stat(basePath);
    if (!trailingSlash) {
      // 文件是否存在
      if (rc === 0) {  // File.
            // 嘗試根據模塊類型獲取真實路徑,代碼省略
            filename = findPath();
      }
        
      // 嘗試給文件添加後綴名
      if (!filename) {
        if (exts === undefined)
          exts = Object.keys(Module._extensions);
        filename = tryExtensions(basePath, exts, isMain);
      }
    }
    
    // 當前文件路徑爲文件目錄且後綴名不存在,嘗試獲取filename/index[.extension]
    if (!filename && rc === 1) {  // Directory.
      if (exts === undefined)
        exts = Object.keys(Module._extensions);
      filename = tryPackage(basePath, exts, isMain, request);
    }
    
    // 緩存路徑並返回
    if (filename) {
      Module._pathCache[cacheKey] = filename;
      return filename;
    }
  }
  // 沒有找到文件,返回false
  return false;
};
複製代碼
  1. 編譯執行:根據以上步驟肯定模塊的真實路徑,文件後綴名後,Node會根據經過找到模塊,而後根據不一樣的文件後綴名進行不一樣方式的編譯。編譯的類型大體分爲:JavaScript模塊,C/C++模塊和JSON文件。這裏只對JavaScript模塊進行說明。JavaScript模塊的編譯過程當中,Node會對JS文件進行頭尾包裝的操做。
// (function(exports, require, module, __filename, __dirname) {\n
     JS文件代碼...
// \n})
複製代碼

這樣每一個模塊文件直接都進行了做用域隔離。包裝事後的代碼經過vm原生模塊的runInThisContext()返回一個具體的function對象。最後將當前模塊對象的module自身引用,require方法,exports屬性及一些等全局屬性做爲參數傳入function中執行。這就是這些變量沒有定義卻在每一個模塊文件中存在的緣由。框架

模塊exports過程

剛開始接觸Node時,對於module.exportsexports的關係會存在一些疑惑。它們均可以掛載屬性方法,做爲當前模塊的導出。但它們分別表示什麼?又有什麼區別呢?觀察下面的代碼:

// a.js
exports.name = "vincent";
module.exports.name = "ziwen.fu";
exports.age = 24;

// b.js
const a = require("a");
console.log(a); // { "name": "ziwen.fu", age: 24 };
複製代碼

從前面的模塊源碼解析中,咱們能夠得知Node模塊最終導出的上module.exports的值,而從上面的代碼咱們能夠肯定,module.exports的初始值爲{},而exports是做爲module.exports的引用,掛載到exports上的屬性方法,最終會由module.exports導出。繼續觀察下面的代碼:

// a.js
exports = "from exports";

module.exports = "from module.exports";

// b.js
const a = require("a");
console.log(a);         // from module.exports
複製代碼

從代碼運行結果能夠看出,直接對module.exportsexports進行賦值,最終模塊導出的是module.exports的值。從上一小結可知,exports在當前模塊上下文中是做爲形參傳入,直接改變形參的引用,並不能改變做用域外的值。測試代碼以下:

const myModule = function(myExports) {
    myExports = "24";
    console.log(myExports);
};

const myExports = "8";
myModule(myExports);        // 24
console.log(myExports);     // 8
複製代碼

Node模塊的循環依賴問題

下面這段來自Node官網的示例

// a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');

// b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');

// main.js
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);

// console.log
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true
複製代碼

官方的解釋是,當main.js加載a.js時,a.js嘗試加b.js。此時b.js又嘗試去加載a.js。爲了不死循環,a.js導出了一個未完成的副本,使b.js完成加載,隨後b.js導出給a.js完成整個過程。目前ES6 Module 已經提供瞭解決方案,Node的 ES6模塊也已經進入測驗階段,這裏就不作過多介紹了。

Node模塊的實戰應用

上面介紹了Node模塊的基礎機制,大多數狀況下咱們可能都會使用依賴前置的方式去require模塊,即提早引入當前模塊所需的模塊,並它置於代碼頂部。但有時候,存在部分模塊,程序不須要當即使用它們,這時候動態引入是一個更好的選擇。下面是Node Web服務框架egg.js中加載器(Loader)實現的相關代碼,它提供了一個不同的思路:

// egg-core/lib/loader/utils
loadFile(filepath) {
    // filepath來自於require.resolve(path)的定位
    try {
      // 非JavaScript模塊,同步讀取文件
      // Module._extension爲Node模塊支持後綴名數組
      const extname = path.extname(filepath);
      if (extname && !Module._extensions[extname]) {
        return fs.readFileSync(filepath);
      }
      // JavaScript模塊直接require
      const obj = require(filepath);
      if (!obj) return obj;
      // ES6模塊返回處理
      if (obj.__esModule) return 'default' in obj ? obj.default : obj;
      return obj;
    } catch (err) {
        // ...
    }
  }
  
  // egg-core/lib/loader/context_loader
  // 代理上下文中對象app.context
  // property對應項目文件名
  Object.defineProperty(app.context, property, {
      get() {
        // 查詢緩存
        if (!this[CLASSLOADER]) {
          this[CLASSLOADER] = new Map();
        }
        const classLoader = this[CLASSLOADER];
        // 獲取模塊對象實例並緩存
        let instance = classLoader.get(property);
        if (!instance) {
          instance = getInstance(target, this);
          classLoader.set(property, instance);
        }
        return instance;
      },
    });
複製代碼

egg-loader的思路是經過代理app.context上的property,動態的去require模塊並加以包裝以後掛載到上下文對象上。其中property是來源於require.resolve定位的模塊文件名,藉助緩存機制,使得程序在運行過程當中可以按需引入模塊,同時也減小了開發者引入模塊以及維護模塊名及路徑的成本。

目前Node的模塊機制中,require模塊是基於readFileSync實現的同步API,對於大文件的引入存在諸多不便。而正在試驗過程當中的ES Module支持異步動態引入,同時也解決了循環依賴的問題,將來可能將普遍應用到Node模塊機制中。

參考

相關文章
相關標籤/搜索