Node.js精進(1)——模塊化

  模塊化是一種將軟件功能抽離成獨立、可交互的軟件設計技術,能促進大型應用程序和系統的構建。

  Node.js內置了兩種模塊系統,分別是默認的CommonJS模塊和瀏覽器所支持的ECMAScript模塊

  其中,ECMAScript模塊是在8.5.0版本中新增的,後面又經過了幾輪的迭代。本文若無特別說明,那麼分析的都是CommonJS模塊。

  順便說一句,本系列分析的是Node.js的最新版本18.0.0,在Github上下載源碼後,可以關注下面3個目錄。

├── deps          第三方依賴
├── lib           對外暴露的標準庫JavaScript源碼,例如path、fs等
├── src           支撐Node運行的C/C++ 源碼文件,例如HTTP解析、進程處理等

  本系列所有的示例源碼都已上傳至Github,點擊此處獲取。

  還有一點需要指出,Node.js的官方說明文檔,是我目前爲止遇到的比較符合人類閱讀的文檔。

一、基礎語法

  先來分析一下CommonJS模塊的基礎語法,在Node.js中,可通過 module.exports 和 exports 來導出一個模塊,再通過 require() 來導入一個模塊。

  來看個簡單的示例,先在 1.js 文件中聲明 human 對象,然後使用 module.exports 導出,然後在 2.js 中導入 1.js 文件,打印輸出。

// 1.js
const human = {
  name: 'strick'
}
module.exports = human;
// 2.js
const human = require('./1.js');
console.log(human);  // { name: 'strick' }

  exports 是 module.exports 的快捷方式,但是不能對其直接賦值,像下面這樣導出的就是一個空對象。

// 3.js
exports = {
  name: 'strick'
};
// 2.js
const human = require('./3.js');
console.log(human);  // {}

  接下來換一種寫法,爲 exports 添加一個屬性,這樣就能正確導出。

// 3.js
exports.human = {
  name: 'strick'
};
// 2.js
const human = require('./3.js');
console.log(human);  // { human: { name: 'strick' } }

  module.exports 導出了它所指向的對象,而 exports 導出的是對象的屬性。

二、CommonJS原理

  在Node.js中,可分成兩大類的模塊:核心模塊和第三方模塊。

  其中核心模塊又分成 built-in 模塊和 native 模塊,前者由C/C++編寫,存在於源碼的src目錄中;後者由JavaScript編寫,存在於lib目錄中。

  注意,在 lib/internal/modules 目錄中,可以查看兩種模塊系統的源碼。

  所有非Node.js自帶的模塊統稱爲第三方模塊,也就是任意文件,大家自己寫的業務代碼以及依賴的第三方應用庫都屬於此範疇。

  Node.js會使用模塊封裝器(如下所示)將模塊中的代碼包裹,形成模塊作用域,這樣就能避免模塊之間的作用域污染。

(function(exports, require, module, __filename, __dirname) {
    // 模塊代碼實際存在於此處
});

  __filename可以得到當前模塊的絕對路徑加文件名。__dirname表示當前模塊的目錄名,也包含絕對路徑,與 path.dirname() 相同。

console.log(__filename);    // /Users/strick/code/web/node/01/4.js
console.log(__dirname);     // /Users/strick/code/web/node/01

1)require()

  在lib/internal/modules/cjs/loader.js中聲明瞭 require() 函數,requireDepth 記載了模塊加載的深度。

Module.prototype.require = function(id) {
  validateString(id, 'id');    // 判斷id變量是否是字符串類型
  if (id === '') {
    throw new ERR_INVALID_ARG_VALUE('id', id, 'must be a non-empty string');
  }
  requireDepth++;
  try {
    return Module._load(id, this, /* isMain */ false);
  } finally {
    requireDepth--;
  }
};

  在 _load() 中實現了主要的加載邏輯,源碼比較長,做了些刪減,只列出了關鍵部分。

Module._load = function(request, parent, isMain) {
  // 解析模塊的路徑和名稱
  const filename = Module._resolveFilename(request, parent, isMain);
  // 核心模塊使用 node: 前綴,會繞過 require 緩存
  if (StringPrototypeStartsWith(filename, 'node:')) {
    const id = StringPrototypeSlice(filename, 5);    // Slice 'node:' prefix
    const module = loadNativeModule(id, request);
    if (!module?.canBeRequiredByUsers) {
      throw new ERR_UNKNOWN_BUILTIN_MODULE(filename);
    }
    return module.exports;
  }
  // 第一種情況:如果緩存中已經存在此模塊,那麼返回模塊的 exports 屬性
  const cachedModule = Module._cache[filename];
  if (cachedModule !== undefined) {
    updateChildren(parent, cachedModule, true);
    if (!cachedModule.loaded) {
      const parseCachedModule = cjsParseCache.get(cachedModule);
      if (!parseCachedModule || parseCachedModule.loaded)
        return getExportsForCircularRequire(cachedModule);
      parseCachedModule.loaded = true;
    } else {
      return cachedModule.exports;
    }
  }
  // 第二種情況:如果是核心模塊,那麼調用 NativeModule.prototype.compileForPublicLoader() 返回模塊的 exports 屬性
  const mod = loadNativeModule(filename, request);
  if (mod?.canBeRequiredByUsers &&
      NativeModule.canBeRequiredWithoutScheme(filename)) {
    return mod.exports;
  }
  // 第三種情況:如果是第三方文件,那麼創建一個新模塊並加載文件內容,再將其保存到緩存中
  const module = cachedModule || new Module(filename, parent);
  Module._cache[filename] = module;
  return module.exports;
};

  在 _load() 方法中,會先判斷 node: 前綴(在官方文檔的核心模塊中有過介紹),然後列出3種加載情況:

  1. 如果緩存中已經存在此模塊,那麼返回模塊的 exports 屬性。
  2. 如果是核心模塊,那麼調用 NativeModule.prototype.compileForPublicLoader() 返回模塊的 exports 屬性。
  3. 如果是第三方文件,那麼創建一個新模塊並加載文件內容,再將其保存到緩存中。

  Node.js在加載JS文件時,會先判斷是否有緩存,然後讀取文件內容,再調用 _compile() 進行編譯,下面的源碼也做了刪減。

  還有另外兩種 .json 和 .node 後綴的文件加載過程在此省略。

Module._extensions['.js'] = function(module, filename) {
  // 如果已經分析了源,那麼它將被緩存
  const cached = cjsParseCache.get(module);
  let content;
  if (cached?.source) {
    content = cached.source;
    cached.source = undefined;
  } else {
    content = fs.readFileSync(filename, 'utf8');
  }
  module._compile(content, filename);
};

  在 _compile() 方法中會調用vm模塊創建沙盒,再執行函數代碼,源碼比較長,在此省略。

  注意,雖然 vm 可以在V8虛擬機的上下文中編譯和執行JavaScript代碼,但是它比eval()更爲安全,因爲它運行的腳本無權訪問外部作用域。

2)加載順序

  經過上面的源碼分析,可知加載順序是先緩存,再核心模塊,最後第三方模塊,再詳細一點的話就是:

  (1)緩存,模塊在第一次加載後被緩存,也就是說,解析相同的文件,會返回完全相同的對象,除非修改require.cache

  (2)核心模塊,部分核心模塊已被編譯成二進制文件,加載到了內存中。

  (3)文件模塊的加載過程如下:

  1. 優先加載帶' /'、'./' 或 '../' 路徑前綴的模塊。
  2. 若文件沒有後綴,則依次添加 .js、.json 和 .node 嘗試加載。
  3. 若模塊沒有路徑來指示文件,則該模塊必須是核心模塊或從 node_modules 目錄加載。
  4. 再找不到就拋出 MODULE_NOT_FOUND 錯誤。

  (4)目錄作爲模塊的加載過程如下:

  1. 先將目錄當成包來處理,查找 package.json 文件,讀取 main 字段描述的入口文件。
  2. 若沒有 package.json,main 字段缺失或無法解析時,嘗試依次加載目錄中的 index.js、index.json 或 index.node 文件。
  3. 如果這些嘗試都失敗,則拋出錯誤,Error: Cannot find module 'xx/xx.js'。

  (5)從 node_modules 目錄加載,若不是核心模塊並且沒有路徑前綴,那麼從當前模塊的目錄向上查找,並添加 /node_modules,直至根目錄爲止。

  例如,在'/Users/strick/code/tmp.js' 中調用require('test.js'),那麼將按以下順序查找:

  1. /Users/strick/code/node_modules/test.js
  2. /Users/strick/node_modules/test.js
  3. /Users/node_modules/test.js
  4. /node_modules/test.js

  (6)從全局目錄加載,一種官方不推薦的加載方式。

  如果 NODE_PATH 環境變量設置爲以冒號分隔的絕對路徑列表,則 Node.js 將在這些路徑中搜索模塊(如果它們在其他地方找不到)。

3)循環引用

  在Node.js中,當兩個模塊通過 require() 函數加載對方時,就形成了循環引用,但不會形成死循環。

  下面的示例來自於官網,對其做了些調整。

  先創建 a.js,在加載 b 模塊之前,done 是 false,並且聲明瞭一個 globalVar 變量,沒有爲其添加任何聲明變量的關鍵字,在 b 模塊加載完成後,done 賦值爲 true。

console.log('a starting');
exports.done = false;
globalVar = '全局變量'; // 在a模塊中聲明的全局變量
const b = require('./b.js');
console.log('在a模塊中, b.done = %j', b.done);
exports.done = true;
console.log('a done');

  再創建 b.js,在加載 a 模塊之前,done 也是 false,在 a 模塊加載完成之後,done 也賦值爲 true。

console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('在b模塊中, a.done = %j', a.done);
console.log('globalVar: ', globalVar);
exports.done = true;
console.log('b done');

  最後創建 main.js,再加載 b 模塊。

console.log('main starting');
const a = require('./a.js');    // 先導入a模塊
const b = require('./b.js');    // 再導入b模塊
console.log('在main模塊中, a.done = %j, b.done = %j', a.done, b.done);

  最終的打印順序如下所示,在 main.js 中,先加載 a 模塊,而在 a 模塊中會嘗試加載 b 模塊。那麼在進入到 b 模塊後,爲了防止無限死循環,會導出 a 模塊已執行完成的部分。

main starting
a starting
b starting
在b模塊中, a.done = false
globalVar:  全局變量
b done
在a模塊中, b.done = true
a done
在main模塊中, a.done = true, b.done = true

  在上述示例中,還涉及到另一個問題,那就是在 a 模塊中聲明的 globalVar 變量,能在 b 模塊中被成功打印。

  在上文中也曾提到過模塊封裝器,那麼 globalVar 變量的聲明和打印,相當於下面這樣,如果在函數內聲明變量時省略 var 關鍵字,那麼這個變量就會變成全局變量。

// a.js
(function (exports, require, module, __filename, __dirname) {
  globalVar = '全局變量';
});
// b.js
(function (exports, require, module, __filename, __dirname) {
  console.log(globalVar);
});

  若要避免污染全局作用域,那麼可以聲明嚴格模式,禁止隱式的全局聲明,如下所示。

'use strict';
globalVar = '全局變量';

5)與ECMAScript模塊的差異

  (1)import 語句只允許在 ES 模塊中使用,但可以導入兩種模塊;而 CommonJS 的 require() 不能導入 ES 模塊。

  (2)ES 模塊的 import 是異步執行的;而 CommonJS 模塊的 require() 是同步執行的。

  (3)ES 模塊沒有 __filename、__dirname、require.cache、module.exports 等變量。

  (4)ES 模塊是編譯時輸出,可以靜態分析模塊依賴;而 CommonJS 是運行時加載。

  (5)ES 模塊輸出的是值引用;而 CommonJS 模塊輸出的是值副本。

  需要通過一個示例來理解第五點差異,首先創建 lib.mjs 文件,.mjs 是 Node.js 爲 ES 模塊保留的後綴,在此類文件內可使用 export 和 import 語法。

  在 lib.mjs 文件中,聲明 digit 變量和 increase() 函數,在函數中對 digit 執行遞增,通過 export 將它們導出。

// lib.mjs
export let digit = 0;
export function increase() {
  digit++;
}

  在 main.mjs 文件中,加載 lib.mjs,打印 digit 變量,值爲 0,調用 increase() 函數,再打印,值變爲 1。由此可知,外部可以修改模塊內部的值。

// main.mjs
import { digit, increase } from './lib.mjs';
console.log(digit);  // 0
increase();
console.log(digit);  // 1

  接下來創建 lib.js 文件,同樣是 digit 變量和 increase() 函數,通過 module.exports 將它們導出。

// lib.js
let digit = 0;
function increase() {
  digit++;
}
module.exports.digit = digit;
module.exports.increase = increase;

  在 main.js 文件中,加載 lib.js,打印 digit 變量,值爲 0,調用 increase() 函數,再打印,仍然是 0。由此可知,外部無法修改模塊內部的值。

// main.js
const lib = require('./lib');
console.log(lib.digit);  // 0
lib.increase();
console.log(lib.digit);  // 0

  (6)ES 模塊不管是否遇到循環引用,其 import 導入的變量都會成爲一個指向被加載模塊的引用,而 CommonJS 模塊遇到循環引用只會導出模塊已執行完成的部分。

  這其實也是兩者加載機制的不同所導致的,參考第四點不同。

  CommonJS 對循環引用的處理過程在上文中已介紹,現在改造之前官網的示例,在 main.mjs 中導入 a 和 b 兩個模塊,並打印 a 和 b 的值。

// main.mjs
import a from './a.mjs';
import b from './b.mjs';
console.log('在main模塊中, a = %j, b = %j', a, b);

  在 a.mjs 中,會導入 b.mjs,並打印 b 的值。而在 b.mjs 中,會導入 a.mjs,並打印 a 的值,如此就形成了循環引用。

// a.mjs
import b from './b.mjs';
let done = false;
export default done;
console.log('在a模塊中, b = %j', b);

// b.mjs
import a from './a.mjs';
let done = false;
export default done;
console.log('在b模塊中, a = %j', a);

  運行 main.mjs,馬上就會報錯:ReferenceError: Cannot access 'a' before initialization。

  在 main.mjs 中讀取 a 的值時,會執行 a.mjs 並讀取 b 的值,而在 b.mjs 中,默認會認爲 a 已存在,但在訪問的時候就會發現被欺騙,然後就報錯了。

 

參考資料:

CommonJS模塊

ECMAScript模塊

使用 exports 從 Node.js 文件中公開功能

餓了麼模塊題目

爲什麼 Node.js 不給每一個.js文件以獨立的上下文來避免作用域被污染? 

Node.js技術棧

深入理解Node.js:核心思想與源碼分析

Node.js 模塊系統源碼探微

Node.js VM 不完全指北

What’s the difference between CommonJS and ES6 modules?

ECMAScript6入門之ES6模塊的循環加載

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章