Node.js 模塊系統源碼探微

原創不易,但願能關注下咱們,再順手點個贊~~

本文首發於政採雲前端團隊博客: Node.js 模塊系統源碼探微前端

前言

Node.js 的出現使得前端工程師能夠跨端工做在服務器上,固然,一個新的運行環境的誕生亦會帶來新的模塊、功能、抑或是思想上的革新,本文將帶領讀者領略 Node.js (如下簡稱 Node) 的模塊設計思想以及剖析部分核心源碼實現。node

CommonJS 規範

Node 最初遵循 CommonJS 規範來實現本身的模塊系統,同時作了一部分區別於規範的定製。CommonJS 規範是爲了解決 JavaScript 的做用域問題而定義的模塊形式,它可使每一個模塊在它自身的命名空間中執行。json

該規範強調模塊必須經過 module.exports 導出對外的變量或函數,經過 require() 來導入其餘模塊的輸出到當前模塊做用域中,同時,遵循如下約定:數組

  • 在模塊中,必須暴露一個 require 變量,它是一個函數,require 函數接受一個模塊標識符,require 返回外部模塊的導出的 API。若是要求的模塊不能被返回則 require 必須拋出一個錯誤。
  • 在模塊中,必須有一個自由變量叫作 exports,它是一個對象,模塊在執行時能夠在 exports 上掛載模塊的屬性。模塊必須使用 exports 對象做爲惟一的導出方式。
  • 在模塊中,必須有一個自由變量 module,它也是一個對象。module 對象必須有一個 id 屬性,它是這個模塊的頂層 id。id 屬性必須是這樣的,require(module.id) 會從源出 module.id 的那個模塊返回 exports 對象(就是說 module.id 能夠被傳遞到另外一個模塊,並且在要求它時必須返回最初的模塊)。

Node 對 CommonJS 規範的實現

  • 定義了模塊內部的 module.require 函數和全局的 require 函數,用來加載模塊。
  • 在 Node 模塊系統中,每一個文件都被視爲一個獨立的模塊。模塊被加載時,都會初始化爲 Module 對象的實例,Module 對象的基本實現和屬性以下所示:
function Module(id = "", parent) {
  // 模塊 id,一般爲模塊的絕對路徑
  this.id = id;
  this.path = path.dirname(id);
  this.exports = {};
  // 當前模塊調用者
  this.parent = parent;
  updateChildren(parent, this, false);
  this.filename = null;
  // 模塊是否加載完成 
  this.loaded = false;
  // 當前模塊所引用的模塊
  this.children = [];
}
複製代碼
  • 每個模塊都對外暴露本身的 exports 屬性做爲使用接口。

模塊導出以及引用

在 Node 中,可以使用 module.exports 對象總體導出一個變量或者函數,也可將須要導出的變量或函數掛載到 exports 對象的屬性上,代碼以下所示:緩存

// 1. 使用 exports: 筆者習慣一般用做對工具庫函數或常量的導出
exports.name = 'xiaoxiang';
exports.add = (a, b) => a + b;
// 2. 使用 module.exports:導出一整個對象或者單一函數
...
module.exports = {
  add,
  minus
}
複製代碼

經過全局 require 函數引用模塊,可傳入模塊名稱、相對路徑或者絕對路徑,當模塊文件後綴爲 js / json / node 時,可省略後綴,以下代碼所示:bash

// 引用模塊
const { add, minus } = require('./module');
const a = require('/usr/app/module');
const http = require('http');
複製代碼

注意事項:服務器

  • exports 變量是在模塊的文件級做用域內可用的,且在模塊執行以前賦值給 module.exports
exports.name = 'test';
console.log(module.exports.name); // test
module.export.name = 'test';
console.log(exports.name); // test
複製代碼
  • 若是爲 exports 賦予了新值,則它將再也不綁定到 module.exports,反之亦然:
exports = { name: 'test' };
console.log(module.exports.name, exports.name); // undefined, test
複製代碼
  • module.exports 屬性被新對象徹底替換時,一般也須要從新賦值 exports
module.exports = exports = { name: 'test' };
console.log(module.exports.name, exports.name) // test, test
複製代碼

模塊系統實現分析

模塊定位

如下是 require 函數的代碼實現:前端工程師

// require 入口函數
Module.prototype.require = function(id) {
  //...
  requireDepth++;
  try {
    return Module._load(id, this, /* isMain */ false); // 加載模塊
  } finally {
    requireDepth--;
  }
};
複製代碼

上述代碼接收給定的模塊路徑,其中的 requireDepth 用來記載模塊加載的深度。其中 Module 的類方法 _load 實現了 Node 加載模塊的主要邏輯,下面咱們來解析 Module._load 函數的源碼實現,爲了方便你們理解,我把註釋加在了文中。app

Module._load = function(request, parent, isMain) {
  // 步驟一:解析出模塊的全路徑
  const filename = Module._resolveFilename(request, parent, isMain);
  
  // 步驟二:加載模塊,具體分三種狀況處理
  // 狀況一:存在緩存的模塊,直接返回模塊的 exports 屬性
  const cachedModule = Module._cache[filename];
  if (cachedModule !== undefined) 
    return cachedModule.exports;
  // 狀況二:加載內建模塊
  const mod = loadNativeModule(filename, request);
  if (mod && mod.canBeRequiredByUsers) return mod.exports;
  // 狀況三:構建模塊加載
  const module = new Module(filename, parent);
  // 加載過以後就進行模塊實例緩存
  Module._cache[filename] = module;
  
  // 步驟三:加載模塊文件
  module.load(filename);
 
  // 步驟四:返回導出對象
  return module.exports;
};
複製代碼

加載策略

上面的代碼信息量比較大,咱們主要看如下幾個問題:async

  1. 模塊的緩存策略是什麼? 分析上述代碼咱們能夠看到,_load 加載函數針對三種狀況給出了不一樣的加載策略,分別是:
  • 狀況一:緩存命中,直接返回。
  • 狀況二:內建模塊,返回暴露出來的 exports 屬性,也就是 module.exports 的別名。
  • 狀況三:使用文件或第三方代碼生成模塊,最後返回,而且緩存,這樣下次一樣的訪問就會去使用緩存而不是從新加載。
  1. Module._resolveFilename(request, parent, isMain) 是怎麼解析出文件名稱的?

    咱們看以下定義的類方法:

Module._resolveFilename = function(request, parent, isMain, options) {
 if (NativeModule.canBeRequiredByUsers(request)) { 
 	// 優先加載內建模塊
   return request;
 }
 let paths;
    
 // node require.resolve 函數使用的 options,options.paths 用於指定查找路徑
 if (typeof options === "object" && options !== null) {
   if (ArrayIsArray(options.paths)) {
     const isRelative =
       request.startsWith("./") ||
       request.startsWith("../") ||
       (isWindows && request.startsWith(".\\")) ||
       request.startsWith("..\\");
     if (isRelative) {
       paths = options.paths;
     } else {
       const fakeParent = new Module("", null);
       paths = [];
       for (let i = 0; i < options.paths.length; i++) {
         const path = options.paths[i];
         fakeParent.paths = Module._nodeModulePaths(path);
         const lookupPaths = Module._resolveLookupPaths(request, fakeParent);
         for (let j = 0; j < lookupPaths.length; j++) {
           if (!paths.includes(lookupPaths[j])) paths.push(lookupPaths[j]);
         }
       }
     }
   } else if (options.paths === undefined) {
     paths = Module._resolveLookupPaths(request, parent);
   } else {
		//...
   }
 } else {
   // 查找模塊存在路徑
   paths = Module._resolveLookupPaths(request, parent);
 }
 // 依據給出的模塊和遍歷地址數組,以及是否爲入口模塊來查找模塊路徑
 const filename = Module._findPath(request, paths, isMain);
 if (!filename) {
   const requireStack = [];
   for (let cursor = parent; cursor; cursor = cursor.parent) {
     requireStack.push(cursor.filename || cursor.id);
   }
   // 未找到模塊,拋出異常(是否是很熟悉的錯誤)
   let message = `Cannot find module '${request}'`;
   if (requireStack.length > 0) {
     message = message + "\nRequire stack:\n- " + requireStack.join("\n- ");
   }
   
   const err = new Error(message);
   err.code = "MODULE_NOT_FOUND";
   err.requireStack = requireStack;
   throw err;
 }
 // 最終返回包含文件名的完整路徑
 return filename;
};
複製代碼

上面的代碼中比較突出的是使用了 _resolveLookupPaths_findPath 兩個方法。

  • _resolveLookupPaths: 經過接受模塊名稱和模塊調用者,返回提供 _findPath 使用的遍歷範圍數組。
// 模塊文件尋址的地址數組方法
   Module._resolveLookupPaths = function(request, parent) {
    if (NativeModule.canBeRequiredByUsers(request)) {
      debug("looking for %j in []", request);
      return null;
    }
   
    // 若是不是相對路徑
    if (
      request.charAt(0) !== "." ||
      (request.length > 1 &&
        request.charAt(1) !== "." &&
        request.charAt(1) !== "/" &&
        (!isWindows || request.charAt(1) !== "\\"))
    ) {
      /** 
       * 檢查 node_modules 文件夾
       * modulePaths 爲用戶目錄,node_path 環境變量指定目錄、全局 node 安裝目錄 
       */
      let paths = modulePaths;
   
      if (parent != null && parent.paths && parent.paths.length) {
        // 父模塊的 modulePath 也要加到子模塊的 modulePath 裏面,往上回溯查找
        paths = parent.paths.concat(paths);
      }
   
      return paths.length > 0 ? paths : null;
    }
   
    // 使用 repl 交互時,依次查找 ./ ./node_modules 以及 modulePaths
    if (!parent || !parent.id || !parent.filename) {
      const mainPaths = ["."].concat(Module._nodeModulePaths("."), modulePaths);
         
      return mainPaths;
    }
   
    // 若是是相對路徑引入,則將父級文件夾路徑加入查找路徑
    const parentDir = [path.dirname(parent.filename)];
    return parentDir;
   };
複製代碼
  • _findPath: 依據目標模塊和上述函數查找到的範圍,找到對應的 filename 並返回。
// 依據給出的模塊和遍歷地址數組,以及是否頂層模塊來尋找模塊真實路徑
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;
 let exts;
 let trailingSlash =
   request.length > 0 &&
   request.charCodeAt(request.length - 1) === CHAR_FORWARD_SLASH; // '/'
 if (!trailingSlash) {
   trailingSlash = /(?:^|\/)\.?\.$/.test(request);
 }
 // For each path
 for (let i = 0; i < paths.length; i++) {
   const curPath = paths[i];
   if (curPath && stat(curPath) < 1) continue;
   const basePath = resolveExports(curPath, request, absoluteRequest);
   let filename;
   const rc = stat(basePath);
   if (!trailingSlash) {
     if (rc === 0) { // stat 狀態返回 0,則爲文件
       // File.
       if (!isMain) {
         if (preserveSymlinks) {
           // 當解析和緩存模塊時,命令模塊加載器保持符號鏈接。
           filename = path.resolve(basePath);
         } else {
           // 不保持符號連接
           filename = toRealPath(basePath);
         }
       } else if (preserveSymlinksMain) {
         filename = path.resolve(basePath);
       } else {
         filename = toRealPath(basePath);
       }
     }
     if (!filename) {
       if (exts === undefined) exts = ObjectKeys(Module._extensions);
       // 解析後綴名
       filename = tryExtensions(basePath, exts, isMain);
     }
   }
   if (!filename && rc === 1) { 
     /** 
       *  stat 狀態返回 1 且文件名不存在,則認爲是文件夾
       * 若是文件後綴不存在,則嘗試加載該目錄下的 package.json 中 main 入口指定的文件
       * 若是不存在,而後嘗試 index[.js, .node, .json] 文件
     */
     if (exts === undefined) exts = ObjectKeys(Module._extensions);
     filename = tryPackage(basePath, exts, isMain, request);
   }
   if (filename) { // 若是存在該文件,將文件名則加入緩存
     Module._pathCache[cacheKey] = filename;
     return filename;
   }
 }
 const selfFilename = trySelf(paths, exts, isMain, trailingSlash, request);
 if (selfFilename) {
   // 設置路徑的緩存
   Module._pathCache[cacheKey] = selfFilename;
   return selfFilename;
 }
 return false;
};
複製代碼

模塊加載

標準模塊處理

閱讀完上面的代碼,咱們發現,當遇到模塊是一個文件夾的時候會執行 tryPackage 函數的邏輯,下面簡要分析一下具體實現。

// 嘗試加載標準模塊
function tryPackage(requestPath, exts, isMain, originalPath) {
  const pkg = readPackageMain(requestPath);
  if (!pkg) {
    // 若是沒有 package.json 這直接使用 index 做爲默認入口文件
    return tryExtensions(path.resolve(requestPath, "index"), exts, isMain);
  }
  const filename = path.resolve(requestPath, pkg);
  let actual =
    tryFile(filename, isMain) ||
    tryExtensions(filename, exts, isMain) ||
    tryExtensions(path.resolve(filename, "index"), exts, isMain);
  //...
  return actual;
}
// 讀取 package.json 中的 main 字段
function readPackageMain(requestPath) {
  const pkg = readPackage(requestPath);
  return pkg ? pkg.main : undefined;
}
複製代碼

readPackage 函數負責讀取和解析 package.json 文件中的內容,具體描述以下:

function readPackage(requestPath) {
  const jsonPath = path.resolve(requestPath, "package.json");
  const existing = packageJsonCache.get(jsonPath);
  if (existing !== undefined) return existing;
  // 調用 libuv uv_fs_open 的執行邏輯,讀取 package.json 文件,而且緩存
  const json = internalModuleReadJSON(path.toNamespacedPath(jsonPath));
  if (json === undefined) {
    // 接着緩存文件
    packageJsonCache.set(jsonPath, false);
    return false;
  }
  //...
  try {
    const parsed = JSONParse(json);
    const filtered = {
      name: parsed.name,
      main: parsed.main,
      exports: parsed.exports,
      type: parsed.type
    };
    packageJsonCache.set(jsonPath, filtered);
    return filtered;
  } catch (e) {
    //...
  }
}
複製代碼

上面的兩段代碼完美地解釋 package.json 文件的做用,模塊的配置入口( package.json 中的 main 字段)以及模塊的默認文件爲何是 index,具體流程以下圖所示:

圖片

模塊文件處理

定位到對應模塊以後,該如何加載和解析呢?如下是具體代碼分析:

Module.prototype.load = function(filename) {
  // 保證模塊沒有加載過
  assert(!this.loaded);
  this.filename = filename;
  // 找到當前文件夾的 node_modules
  this.paths = Module._nodeModulePaths(path.dirname(filename));
  const extension = findLongestRegisteredExtension(filename);
  //...
  // 執行特定文件後綴名解析函數 如 js / json / node
  Module._extensions[extension](this, filename);
  // 表示該模塊加載成功
  this.loaded = true;
  // ... 省略 esm 模塊的支持
};
複製代碼

後綴處理

能夠看出,針對不一樣的文件後綴,Node.js 的加載方式是不一樣的,一下針對 .js, .json, .node 簡單進行分析。

  • .js 後綴 js 文件讀取主要經過 Node 內置 API fs.readFileSync 實現。
Module._extensions[".js"] = function(module, filename) {
 
  // 讀取文件內容
  const content = fs.readFileSync(filename, "utf8");
  // 編譯執行代碼
  module._compile(content, filename);
};
複製代碼
  • .json 後綴 JSON 文件的處理邏輯比較簡單,讀取文件內容後執行 JSONParse 便可拿到結果。
Module._extensions[".json"] = function(module, filename) {
  // 直接按照 utf-8 格式加載文件
  const content = fs.readFileSync(filename, "utf8");
  //...
  try {
    // 以 JSON 對象格式導出文件內容
    module.exports = JSONParse(stripBOM(content));
  } catch (err) {
	//...
  }
};
複製代碼
  • .node 後綴 .node 文件是一種由 C / C++ 實現的原生模塊,經過 process.dlopen 函數讀取,而 process.dlopen 函數實際上調用了 C++ 代碼中的 DLOpen 函數,而 DLOpen 中又調用了 uv_dlopen, 後者加載 .node 文件,相似 OS 加載系統類庫文件。
Module._extensions[".node"] = function(module, filename) {
  //...
  return process.dlopen(module, path.toNamespacedPath(filename));
};
複製代碼

從上面的三段源碼,咱們看出來而且能夠理解,只有 JS 後綴最後會執行實例方法_compile,咱們去除一些實驗特性和調試相關的邏輯來簡要的分析一下這段代碼。

編譯執行

模塊加載完成後,Node 使用 V8 引擎提供的方法構建運行沙箱,並執行函數代碼,代碼以下所示:

Module.prototype._compile = function(content, filename) {
  let moduleURL;
  let redirects;
  // 向模塊內部注入公共變量 __dirname / __filename / module / exports / require,而且編譯函數
  const compiledWrapper = wrapSafe(filename, content, this);
  const dirname = path.dirname(filename);
  const require = makeRequireFunction(this, redirects);
  let result;
  const exports = this.exports;
  const thisValue = exports;
  const module = this;
  if (requireDepth === 0) statCache = new Map();
  	//...
   // 執行模塊中的函數
	result = compiledWrapper.call(
      thisValue,
      exports,
      require,
      module,
      filename,
      dirname
    );
  hasLoadedAnyUserCJSModule = true;
  if (requireDepth === 0) statCache = null;
  return result;
};
// 注入變量的核心邏輯
function wrapSafe(filename, content, cjsModuleInstance) {
  if (patched) {
    const wrapper = Module.wrap(content);
    // vm 沙箱運行 ,直接返回運行結果,env -> SetProtoMethod(script_tmpl, "runInThisContext", RunInThisContext);
    return vm.runInThisContext(wrapper, {
      filename,
      lineOffset: 0,
      displayErrors: true,
      // 動態加載
      importModuleDynamically: async specifier => {
        const loader = asyncESM.ESMLoader;
        return loader.import(specifier, normalizeReferrerURL(filename));
      }
    });
  }
  let compiled;
  try {
    compiled = compileFunction(
      content,
      filename,
      0,
      0,
      undefined,
      false,
      undefined,
      [],
      ["exports", "require", "module", "__filename", "__dirname"]
    );
  } catch (err) {
	//...
  }
  const { callbackMap } = internalBinding("module_wrap");
  callbackMap.set(compiled.cacheKey, {
    importModuleDynamically: async specifier => {
      const loader = asyncESM.ESMLoader;
      return loader.import(specifier, normalizeReferrerURL(filename));
    }
  });
  return compiled.function;
}
複製代碼

上述代碼中,咱們能夠看到在_compile 函數中調用了 wrapwrapSafe 函數,執行了 __dirname / __filename / module / exports / require 公共變量的注入,而且調用了 C++ 的 runInThisContext 方法(位於 src/node_contextify.cc 文件)構建了模塊代碼運行的沙箱環境,並返回了 compiledWrapper 對象,最終經過 compiledWrapper.call 方法運行模塊。

結語

至此,Node.js 的模塊系統分析告一段落,Node.js 世界的精彩和絕妙無窮無盡,學習的路上和諸君共勉。

招賢納士

政採雲前端團隊(ZooTeam),一個年輕富有激情和創造力的前端團隊,隸屬於政採雲產品研發部,Base 在風景如畫的杭州。團隊現有 50 餘個前端小夥伴,平均年齡 27 歲,近 3 成是全棧工程師,妥妥的青年風暴團。成員構成既有來自於阿里、網易的「老」兵,也有浙大、中科大、杭電等校的應屆新人。團隊在平常的業務對接以外,還在物料體系、工程平臺、搭建平臺、性能體驗、雲端應用、數據分析及可視化等方向進行技術探索和實戰,推進並落地了一系列的內部技術產品,持續探索前端技術體系的新邊界。

若是你想改變一直被事折騰,但願開始能折騰事;若是你想改變一直被告誡須要多些想法,卻無從破局;若是你想改變你有能力去作成那個結果,卻不須要你;若是你想改變你想作成的事須要一個團隊去支撐,但沒你帶人的位置;若是你想改變既定的節奏,將會是「 5 年工做時間 3 年工做經驗」;若是你想改變原本悟性不錯,但老是有那一層窗戶紙的模糊… 若是你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的本身。若是你但願參與到隨着業務騰飛的過程,親手推進一個有着深刻的業務理解、完善的技術體系、技術創造價值、影響力外溢的前端團隊的成長曆程,我以爲咱們該聊聊。任什麼時候間,等着你寫點什麼,發給 ZooTeam@cai-inc.com

推薦閱讀

相關文章
相關標籤/搜索