深聊Nodejs模組化

coder2028發表於2022-11-23
本文只討論 CommonJS 規範,不涉及 ESM

我們知道 JavaScript 這門語言誕生之初主要是為了完成網頁上表單的一些規則校驗以及動畫製作,所以布蘭登.艾奇(Brendan Eich)只花了一週多就把 JavaScript 設計出來了。可以說 JavaScript 從出生開始就帶著許多缺陷和缺點,這一點一直被其他語言的程式設計者所嘲笑。隨著 BS 開發模式漸漸地火了起來,JavaScript 所要承擔的責任也越來越大,ECMA 接手標準化之後也漸漸的開始完善了起來。

在 ES 6 之前,JavaScript 一直是沒有自己的模組化機制的,JavaScript 檔案之間無法相互引用,只能依賴指令碼的載入順序以及全域性變數來確定變數的傳遞順序和傳遞方式。而 script 標籤太多會導致檔案之間依賴關係混亂,全域性變數太多也會導致資料流相當紊亂,命名衝突和記憶體洩漏也會更加頻繁的出現。直到 ES 6 之後,JavaScript 開始有了自己的模組化機制,不用再依賴 requirejs、seajs 等外掛來實現模組化了。

在 Nodejs 出現之前,服務端 JavaScript 基本上處於一片荒蕪的境況,而當時也沒有出現 ES 6 的模組化規範(Nodejs 最早從 V8.5 開始支援 ESM 規範:Node V8.5 更新日誌),所以 Nodejs 採用了當時比較先進的一種模組化規範來實現服務端 JavaScript 的模組化機制,它就是 CommonJS,有時也簡稱為 CJS。

這篇文章主要講解 CommonJS 在 Nodejs 中的實現。

一、CommonJS 規範

在 Nodejs 採用 CommonJS 規範之前,還存在以下缺點:

  • 沒有模組系統
  • 標準庫很少
  • 沒有標準介面
  • 缺乏包管理系統

這幾點問題的存在導致 Nodejs 始終難以構建大型的專案,生態環境也是十分的貧乏,所以這些問題都是亟待解決的。

CommonJS 的提出,主要是為了彌補當前 JavaScript 沒有模組化標準的缺陷,以達到像 Java、Python、Ruby 那樣能夠構建大型應用的階段,而不是僅僅作為一門指令碼語言。Nodejs 能夠擁有今天這樣繁榮的生態系統,CommonJS 功不可沒。

1.1 CommonJS 的模組化規範

CommonJS 對模組的定義十分簡單,主要分為模組引用、模組定義和模組標識三個部分。下面進行簡單介紹:

1.1.1、模組引用

示例如下:

const fs = require('fs')

在 CommonJS 規範中,存在一個 require “全域性”方法,它接受一個標識,然後把標識對應的模組的 API 引入到當前模組作用域中。

1.1.2、模組定義

我們已經知道了如何引入一個 Nodejs 模組,但是我們應該如何定義一個 Nodejs 模組呢?在 Nodejs 上下文環境中提供了一個 module 物件和一個 exports 物件,module 代表當前模組,exports 是當前模組的一個屬性,代表要匯出的一些 API。在 Nodejs 中,一個檔案就是一個模組,把方法或者變數作為屬性掛載在 exports 物件上即可將其作為模組的一部分進行匯出。

// add.js
exports.add = function(a, b) {
    return a + b
}

在另一個檔案中,我們就可以透過 require 引入之前定義的這個模組:

const { add } = require('./add.js')

add(1, 2) // print 3
1.1.3、模組標識

模組標識就是傳遞給 require 函式的引數,在 Nodejs 中就是模組的 id。它必須是符合小駝峰命名的字串,或者是以.、..開頭的相對路徑,或者絕對路徑,可以不帶字尾名

模組的定義十分簡單,介面也很簡潔。它的意義在於將類聚的方法和變數等限定在私有的作用於域中,同時支援引入和匯出功能以順暢的連線上下游依賴。

CommonJS 這套模組匯出和引入的機制使得使用者完全不必考慮變數汙染。

以上只是對於 CommonJS 規範的簡單介紹,更多具體的內容可以參考:CommonJS規範

二、Nodejs 的模組化實現

Nodejs 在實現中並沒有完全按照規範實現,而是對模組規範進行了一定的取捨,同時也增加了一些自身需要的特性。接下來我們會探究一下 Nodejs 是如何實現 CommonJS 規範的。

在 Nodejs 中引入模組會經過以下三個步驟:

  • 路徑分析
  • 檔案定位
  • 編譯執行

在瞭解具體的內容之前我們先了解兩個概念:

  • 核心模組:Nodejs 提供的內建模組,比如 fsurlhttp
  • 檔案模組:使用者自己編寫的模組,比如 KoaExpress

核心模組在 Nodejs 原始碼的編譯過程中已經編譯進了二進位制檔案,Nodejs 啟動時會被直接載入到記憶體中,所以在我們引入這些模組的時候就省去了檔案定位、編譯執行這兩個步驟,載入速度比檔案模組要快很多。

檔案模組是在執行的時候動態載入,需要走一套完整的流程:路徑分析檔案定位編譯執行等,所以檔案模組的載入速度比核心模組要慢。

2.1 優先從快取載入

在講解具體的載入步驟之前,我們應當知曉的一點是,Nodejs 對於已經載入過一邊的模組會進行快取,模組的內容會被快取到記憶體當中,如果下次載入了同一個模組的話,就會從記憶體中直接取出來,這樣就省去了第二次路徑分析、檔案定位、載入執行的過程,大大提高了載入速度。無論是核心模組還是檔案模組,require() 對同一檔案的第二次載入都一律會採用快取優先的方式,這是第一優先順序的。但是核心模組的快取檢查優先於檔案模組的快取檢查。

我們在 Nodejs 檔案中所使用的 require 函式,實際上就是在 Nodejs 專案中的 lib/internal/modules/cjs/loader.js 所定義的 Module.prototype.require 函式,只不過在後面的 makeRequireFunction 函式中還會進行一層封裝,Module.prototype.require 原始碼如下:

// Loads a module at the given file path. Returns that module's
// `exports` property.
Module.prototype.require = function(id) {
    validateString(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--;
    }
};

可以看到它最終使用了 Module._load 方法來載入我們的識別符號所指定的模組,找到 Module._load

Module._cache = Object.create(null);
// 這裡先定義了一個快取的物件

// ... ...

// Check the cache for the requested file.
// 1. If a module already exists in the cache: return its exports object.
// 2. If the module is native: call
//    `NativeModule.prototype.compileForPublicLoader()` and return the exports.
// 3. Otherwise, create a new module for the file and save it to the cache.
//    Then have it load  the file contents before returning its exports
//    object.
Module._load = function(request, parent, isMain) {
    let relResolveCacheIdentifier;
    if (parent) {
        debug('Module._load REQUEST %s parent: %s', request, parent.id);
        // Fast path for (lazy loaded) modules in the same directory. The indirect
        // caching is required to allow cache invalidation without changing the old
        // cache key names.
        relResolveCacheIdentifier = `${parent.path}\x00${request}`;
        const filename = relativeResolveCache[relResolveCacheIdentifier];
        if (filename !== undefined) {
            const cachedModule = Module._cache[filename];
            if (cachedModule !== undefined) {
                updateChildren(parent, cachedModule, true);
                return cachedModule.exports;
            }
            delete relativeResolveCache[relResolveCacheIdentifier];
        }
    }

    const filename = Module._resolveFilename(request, parent, isMain);

    const cachedModule = Module._cache[filename];
    if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true);
        return cachedModule.exports;
    }

    const mod = loadNativeModule(filename, request, experimentalModules);
    if (mod && mod.canBeRequiredByUsers) return mod.exports;

    // Don't call updateChildren(), Module constructor already does.
    const module = new Module(filename, parent);

    if (isMain) {
        process.mainModule = module;
        module.id = '.';
    }

    Module._cache[filename] = module;
    if (parent !== undefined) {
        relativeResolveCache[relResolveCacheIdentifier] = filename;
    }

    let threw = true;
    try {
        module.load(filename);
        threw = false;
    } finally {
        if (threw) {
            delete Module._cache[filename];
            if (parent !== undefined) {
                delete relativeResolveCache[relResolveCacheIdentifier];
            }
        }
    }

    return module.exports;
};

我們可以先簡單的看一下原始碼,其實程式碼註釋已經寫得很清楚了。

Nodejs 先會根據模組資訊解析出檔案路徑和檔名,然後以檔名作為 Module._cache 物件的鍵查詢該檔案是否已經被快取,如果已經被快取的話,直接返回快取物件的 exports 屬性。否則就會使用 Module._resolveFilename 重新解析檔名,再查詢一邊快取物件。否則就會當做核心模組來載入,核心模組使用 loadNativeModule 方法進行載入。

如果經過了以上幾個步驟之後,在快取中仍然找不到 require 載入的模組物件,那麼就使用 Module 構造方法重新構造一個新的模組物件。載入完畢之後還會快取到 Module._cache 物件中,以便下一次載入的時候可以直接從快取中取到。

從原始碼來看,跟我們之前說的沒什麼區別。

參考nodejs進階影片講解:進入學習

2.2 路徑分析

我們知道識別符號是進行路徑分析和檔案定位的依據,在引用某個模組的時候我們就會給 require 函式傳入一個識別符號,根據我們使用的經歷不難發現識別符號基本上可以分為以下幾種:

  • 核心模組:比如 httpfs
  • 檔案模組:這類模組的識別符號是一個路徑字串,指向工程內的某個檔案
  • 非路徑形式的檔案模組:也叫做自定義模組,比如 connectkoa

識別符號型別不同,載入的方式也有差異,接下來我將介紹不同識別符號的載入方式。

2.2.1 核心模組

核心模組的載入優先順序僅次於快取,前文提到過由於核心模組的程式碼已經編譯成了二進位制程式碼,在 Nodejs 啟動的時候就會載入到記憶體中,所以核心模組的載入速度非常快。它根本不需要進行路徑分析和檔案定位,如果你想寫一個和核心模組同名的模組的話,它是不會被載入的,因為其載入優先順序不如核心模組。

2.2.2 路徑形式的檔案模組

當識別符號為路徑字串時,require 都會把它當做檔案模組來載入,在根據識別符號獲得真實路徑之後,Nodejs 會將真實路徑作為鍵把模組快取到一個物件裡,使二次載入更快。

由於檔案模組的識別符號指明瞭模組檔案的具體位置,所以載入速度相對而言也比較快。

2.2.3 自定義模組

自定義模組是一個包含 package.json 的專案所構造的模組,它是一種特殊的模組,其查詢方式比較複雜,所以耗時也是最長的。

在 Nodejs 中有一個叫做模組路徑的概念,我們新建一個 module_path.js 的檔案,然後在其中輸入如下內容:

console.log(module.paths)

然後使用 Nodejs 執行:

node module_path.js

我們可以看到控制檯輸入大致如下:

[ 'C:\\Users\\UserName\\Desktop\\node_modules',  'C:\\Users\\UserName\\node_modules',  'C:\\Users\\node_modules',  'C:\\node_modules' ]

此時我的 module_path.js 檔案是放在桌面的,所以可以看到這個檔案模組的模組路徑是當前檔案同級目錄下的 node_modules,如果找不到的話就從父級資料夾的同名目錄下找,知道找到根目錄下。這種查詢方式和 JavaScript 中的作用域鏈非常相似。可以看到當檔案路徑越深的時候查詢所耗時間越長,所以這也是自定義模組載入速度最慢的原因。

在 Windows 環境中,Nodejs 透過下面函式獲取模組路徑:

Module._nodeModulePaths = function(from) {
    // Guarantee that 'from' is absolute.
    from = path.resolve(from);

    // note: this approach *only* works when the path is guaranteed
    // to be absolute.  Doing a fully-edge-case-correct path.split
    // that works on both Windows and Posix is non-trivial.

    // return root node_modules when path is 'D:\\'.
    // path.resolve will make sure from.length >=3 in Windows.
    if (from.charCodeAt(from.length - 1) === CHAR_BACKWARD_SLASH &&
        from.charCodeAt(from.length - 2) === CHAR_COLON)
        return [from + 'node_modules'];

    const paths = [];
    var p = 0;
    var last = from.length;
    for (var i = from.length - 1; i >= 0; --i) {
        const code = from.charCodeAt(i);
        // The path segment separator check ('\' and '/') was used to get
        // node_modules path for every path segment.
        // Use colon as an extra condition since we can get node_modules
        // path for drive root like 'C:\node_modules' and don't need to
        // parse drive name.
        if (code === CHAR_BACKWARD_SLASH ||
            code === CHAR_FORWARD_SLASH ||
            code === CHAR_COLON) {
            if (p !== nmLen)
                paths.push(from.slice(0, last) + '\\node_modules');
            last = i;
            p = 0;
        } else if (p !== -1) {
            if (nmChars[p] === code) {
                ++p;
            } else {
                p = -1;
            }
        }
    }

    return paths;
};

程式碼和註釋都寫得很明白,大家看看就行,常量都放在 /lib/internal/constants.js 這個模組。

2.3 檔案定位

2.3.1 副檔名分析

我們在引用模組的很多時候,傳遞的識別符號都不會攜帶副檔名,比如

// require('./internal/constants.js')

require('./internal/constants')

很明顯下面的方式更簡潔,但是 Nodejs 在定位檔案的時候還是會幫我們補齊。補齊的順序依次為:.js.json.node,在補齊的時候 Nodejs 會依次進行嘗試。在嘗試的時候 Nodejs 會呼叫 fs 模組來判斷檔案是否存在,所以這裡可能會存在效能問題,如果在引用模組的時候加上副檔名,可以使得模組載入的速度變得更快。

在 Nodejs 原始碼 中,我們可以看到當解析不到檔名的時候,會嘗試使用 tryExtensions 方法來新增副檔名:

if (!filename) {
    // Try it with each of the extensions
    if (exts === undefined)
        exts = Object.keys(Module._extensions);
    filename = tryExtensions(basePath, exts, isMain);
}

而嘗試的副檔名就是 Module._extensions 的鍵值,檢索程式碼不難發現程式碼中依次定義了 .js.json.node.mjs 等鍵,所以 tryExtensions 函式會依次進行嘗試:

// Given a path, check if the file exists with any of the set extensions
function tryExtensions(p, exts, isMain) {
    for (var i = 0; i < exts.length; i++) {
        const filename = tryFile(p + exts[i], isMain);

        if (filename) {
            return filename;
        }
    }
    return false;
}

其中又呼叫了 tryFile 方法:

function tryFile(requestPath, isMain) {
    const rc = stat(requestPath);
    if (preserveSymlinks && !isMain) {
        return rc === 0 && path.resolve(requestPath);
    }
    return rc === 0 && toRealPath(requestPath);
}

// Check if the file exists and is not a directory
// if using --preserve-symlinks and isMain is false,
// keep symlinks intact, otherwise resolve to the
// absolute realpath.
function tryFile(requestPath, isMain) {
    const rc = stat(requestPath);
    if (preserveSymlinks && !isMain) {
        return rc === 0 && path.resolve(requestPath);
    }
    return rc === 0 && toRealPath(requestPath);
}

// 這個函式在其他地方還有用到,比較重要
function toRealPath(requestPath) {
    return fs.realpathSync(requestPath, {
        [internalFS.realpathCacheKey]: realpathCache
    });
}

可以看到最終還是依賴了 fs.realpathSync 方法,所以這裡就跟之前說的是一樣的,可能會存在效能問題,如果我們直接帶上了副檔名的話,直接就可以解析出 filename,就不會去嘗試副檔名了,這樣可以稍微提高一點載入速度。

2.3.2 目錄和包分析

我們寫的檔案模組可能是一個 npm 包,此時包內包含許多 js 檔案,所以 Nodejs 載入的時候又需要定位檔案。Nodejs 會查詢 package.json 檔案,使用 JSON.stringify 來解析 json,隨後取出其 main 欄位之後對檔案進行定位,如果檔名缺少擴充套件的話,也會進入副檔名嘗試環節。

如果 main 欄位指定的檔名有誤,或者壓根沒有 package.json 檔案,那麼 Nodejs 會將 index 當做預設檔名,隨後開始嘗試副檔名。

2.4 模組編譯

Nodejs 中每一個模組就是一個 Module類例項,Module 的建構函式如下:

function Module(id = '', parent) {
    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 = [];
}

編譯和執行是引入檔案模組的最後一個環節,定位到具體檔案後,Nodejs 會新建一個模組物件,然後根據路徑載入快取以後進行編譯,副檔名不同,編譯的方式也不同,它們的編譯方法都註冊在了 Module._extensions 物件上,前文有提到過:

  • .js 檔案:透過同步讀取檔案內容後編譯執行
  • .json 檔案:透過 fs 模組讀取檔案,之後使用 JSON.parse 轉化成 JS 物件
  • .node 檔案:這是使用 C/C++ 編寫的擴充套件模組,透過內建的 dlopen 方法載入最後編譯生成的檔案
  • .mjs 檔案:這是 Nodejs 支援 ESM 載入方式的模組檔案,所以使用 require 方法載入的時候會直接丟擲錯誤

在 Nodejs 的 輔助函式模組 中,透過以下程式碼把 Module._extensions 傳遞給了 require 函式:

// Enable support to add extra extension types.require.extensions = Module._extensions;

所以我們可以透過在模組中列印 require.extensions 檢視當前 Nodejs 能夠解析的模組:

console.log(require.extensions)
// { '.js': [Function], '.json': [Function], '.node': [Function] }

另外我們可以看到上面第二段程式碼中的註釋:Enable support to add extra extension types,也就是說我們可以透過修改 require.extensions 物件來註冊模組的解析方法。

比如我們有一個 .csv 檔案,我們想把它解析成一個二維陣列,那麼我們就可以寫一下方法註冊:

const fs = require('fs')

// 註冊解析方法到 require.extensions 物件
require.extensions['.csv'] = function(module, filename) {
    // module 是當前模組的 Module 例項,filename 是當前檔案模組的路徑
    const content = fs.readFileSync(filename, 'utf8'),
          lines = content.split(/\r\n/)
    const res = lines.map(line => line.split(','))
    // 注意匯出是透過給 module.exports 賦值,而不是用 return
    module.exports = res
}

/*
*    demo.csv 的內容為:
*    1,2,3
*    2,3,4
*    5,6,7
*/

const arr = require('./demo.csv')
console.log(arr)

// output
// [ [ '1', '2', '3' ], [ '2', '3', '4' ], [ '5', '6', '7' ] ]

但是在 v0.10.6 開始 Nodejs 就不再推薦使用這種方式來擴充套件載入方式了,而是期望現將其他語言轉化為 JavaScript 以後再載入執行,這樣就避免了將複雜的編譯載入過程引入到 Nodejs 的執行過程。

接下來我們瞭解一下 Nodejs 內建的幾種模組的載入方式。

2.4.1 JavaScript 模組的編譯

在我們編寫 Nodejs 模組的時候我們可以隨意的使用 requiremodulemodule__dirname__filename 等變數,彷彿它們都是 Nodejs 內建的全域性變數一樣,但是實際上他們都是區域性變數。在 Nodejs 載入 JavaScript 模組的時候,會自動將模組內的所有程式碼包裹到一個匿名函式內,構成一個區域性作用域,順便把 require……等變數傳入了匿名函式內部,所以我們的程式碼可以隨意使用這些變數。

假設我們的模組程式碼如下:

exports.add = (a, b) => a + b

經過 Nodejs 載入之後,程式碼變成了下面這樣:

(function(exports, require, module, __filename, __dirname) {
    exports.add = (a, b) => a + b
})

這樣看起來的話,一切都變得很順其自然了。這也是為什麼每個模組都是獨立的名稱空間,在模組檔案內隨便命名變數而不用擔心全域性變數汙染,因為這些變數都定義在了函式內部,成為了這個包裹函式的私有變數。

弄明白 Nodejs 載入 JavaScript 的原理之後,我們很容易就可以弄明白為什麼不能給 exports 直接賦值了,根本原因就在於 JavaScript 是一門按值傳遞(Pass-by-Value)的語言,不管我們給變數賦值的是引用型別還是原始型別,我們得到變數得到的都是一個值,只不過賦值引用型別時,變數得到的是一個代表儲存引用型別的記憶體地址值(可以理解為指標),而我們使用變數時 JavaScript 會根據這個值去記憶體中找到對應的引用型別值,所以看起來也像是引用傳遞。而一旦我們給 exports 這種變數重新賦值的時候,exports 就失去了對原來引用型別的指向,轉而指向新的值,所以就會導致我們賦給 exports 的值並沒有指向原來的引用型別物件。

看看下面這段程式碼:

function changeRef(obj) {
    obj = 12
}

const ref = {}
changeRef(ref)
console.log(ref) // {}

可以看到函式內對 obj 重新賦值根本不影響函式外部的 ref物件,所以如果我們在模組內(及包裹函式內)修改 exports 的指向的話,外部的 module.exports 物件根本不受影響,我們匯出的操作也就失敗了。

下面我們稍微看一下 Nodejs 原始碼是如何編譯執行 JavaScript 程式碼的。

首先根據 Module._extensions 物件上註冊的 .js 模組載入方法找到入口:

// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
    const content = fs.readFileSync(filename, 'utf8');
    module._compile(content, filename);
};

可以看到載入方法聽過 fs.readFileSync 方法同步讀取了 .js 的檔案內容之後,就把內容交給 module_compile 方法去處理了,這個方法位於 Module 類的原型上,我們繼續找到 Module.prototype._compile 方法:

// Run the file contents in the correct scope or sandbox. Expose
// the correct helper variables (require, module, exports) to
// the file.
// Returns exception, if any.Module.prototype._compile = function(content, filename) {
    let moduleURL;
    let redirects;
    if (manifest) {
        moduleURL = pathToFileURL(filename);
        redirects = manifest.getRedirects(moduleURL);
        manifest.assertIntegrity(moduleURL, content);
    }

    const compiledWrapper = wrapSafe(filename, content);

    var inspectorWrapper = null;
    if (getOptionValue('--inspect-brk') && process._eval == null) {
        if (!resolvedArgv) {
            // We enter the repl if we're not given a filename argument.
            if (process.argv[1]) {
                resolvedArgv = Module._resolveFilename(process.argv[1], null, false);
            } else {
                resolvedArgv = 'repl';
            }
        }

        // Set breakpoint on module start
        if (!hasPausedEntry && filename === resolvedArgv) {
            hasPausedEntry = true;
            inspectorWrapper = internalBinding('inspector').callAndPauseOnStart;
        }
    }
    const dirname = path.dirname(filename);
    const require = makeRequireFunction(this, redirects);
    var result;
    const exports = this.exports;
    const thisValue = exports;
    const module = this;
    if (requireDepth === 0) statCache = new Map();
    if (inspectorWrapper) {
        result = inspectorWrapper(compiledWrapper, thisValue, exports,
                                  require, module, filename, dirname);
    } else {
        result = compiledWrapper.call(thisValue, exports, require, module,
                                      filename, dirname);
    }
    if (requireDepth === 0) statCache = null;
    return result;
};

可以看到最後還是交給了 compiledWrapper 方法來處理模組內容(inspectWrapper 是做斷電除錯用的,我們們可以不管它),繼續看 compiledWrapper 方法。

compiledWrapper 方法來源於 wrapSafe 的執行結果:

const compiledWrapper = wrapSafe(filename, content);

wrapSafe 函式的定義如下:

function wrapSafe(filename, content) {
    if (patched) {
        const wrapper = Module.wrap(content);
        return vm.runInThisContext(wrapper, {
            filename,
            lineOffset: 0,
            displayErrors: true,
            importModuleDynamically: experimentalModules ? async (specifier) => {
                const loader = await asyncESM.loaderPromise;
                return loader.import(specifier, normalizeReferrerURL(filename));
            } : undefined,
        });
    }

    const compiled = compileFunction(
        content,
        filename,
        0,
        0,
        undefined,
        false,
        undefined,
        [],
        [
            'exports',
            'require',
            'module',
            '__filename',
            '__dirname',
        ]
    );

    if (experimentalModules) {
        const { callbackMap } = internalBinding('module_wrap');
        callbackMap.set(compiled.cacheKey, {
            importModuleDynamically: async (specifier) => {
                const loader = await asyncESM.loaderPromise;
                return loader.import(specifier, normalizeReferrerURL(filename));
            }
        });
    }

    return compiled.function;
}

// Module.wrap
// eslint-disable-next-line func-style
let wrap = function(script) {
    return Module.wrapper[0] + script + Module.wrapper[1];
};

const wrapper = [
    '(function (exports, require, module, __filename, __dirname) { ',
    '\n});'
];

Object.defineProperty(Module, 'wrap', {
    get() {
        return wrap;
    },
    set(value) {
        patched = true;
        wrap = value;
    }
});

上面這段程式碼可以看到 wrapSafe 方法透過 Module.wrap 將模組程式碼構造成了一個匿名函式,隨後扔給了 vm.runInThisContext 或者 compileFunction 去執行,這兩函式都開始涉及到 JavaScript 跟 C/C++ 的底層了,作者水平渣渣,不再進行下一步解讀,感興趣的童鞋可以自己找到原始碼繼續閱讀。

2.4.2 C/C++ 模組的編譯

Nodejs 透過呼叫 process.dlopen 載入和執行 C/C++ 模組,該函式在 Window 和 *nix 系統下有不同的實現,透過 linuv 相容層進行了封裝。

實際上 .node 模組不需要編譯,因為是根據 C/C++ 編譯而成的,所以只有載入和執行過程。編寫 C/C++ 模組能夠提高 Nodejs 的擴充套件能力和計算能力,我們知道 Nodejs 是單執行緒非同步無阻塞的語言,優勢在於 IO 密集型場景而非計算密集型場景。當我們有大量的計算操作需要執行時,我們可以將計算操作放到 C/C++ 模組中執行,這樣可以提升 Nodejs 在計算密集型場景下的表現。但是 C/C++ 的程式設計門檻比 Nodejs 高很多,所以這也是一大缺點。

Nodejs 在 v10.x 中引入了 Worker Threads 特性,並且這一特性在 v12.x 中開始預設啟用,大大提高了 Nodejs 在計算密集型場景下的表現,在某種程度上減少了開發者所需要編寫的 C/C++ 程式碼量。

2.4.3 JSON 檔案的編譯

JSON 檔案的編譯是最簡單的,透過 fs.readFileSync 讀取檔案內容後,呼叫 JSON.parse 轉化成 JavaScript 物件匯出就行了。

由於作者水平有限,關於核心模組以及 C/C++ 模組的書寫和編譯不再講解。

三、總結

透過這篇文章,我們至少學習到了以下幾點:

  • CommonJS 模組化規範的基本內容

    CommonJS 規範主要包括 模組引用模組定義模組標識,規定了一個模組從引入到消費以及匯出的整個過程。透過給 require 方法傳遞模組識別符號(路徑字串或者模組名稱)來引入 CJS 模組,匯出時給 module.exports 或者 exports 賦值或者新增屬性即可。

  • Nodejs 引入模組的載入順序和基本步驟

    1、載入順序和速度:

    require 函式接收到模組識別符號時,會優先檢查記憶體中是否已經有快取的模組物件,有的話直接返回,沒有就繼續查詢。所以快取的載入優先順序和載入速度是最高的,其次是核心模組,因為核心模組已經被編譯到了 Nodejs 程式碼中,Nodejs 啟動的時候就已經把核心模組的內容載入到了記憶體中,所以核心模組的載入順序和載入速度位於第二,僅次於記憶體。然後就是檔案模組,Nodejs 透過找到檔案然後使用對應的方法載入檔案中的程式碼並執行。最後才是自定義模組。

    2、載入基本步驟:

    載入步驟大概有路徑分析檔案定位編譯執行三個過程。

    Nodejs 在拿到模組識別符號之後,會進行路徑分析,獲得了入口檔案的絕對路徑之後就會去記憶體檢索,如果記憶體中沒有快取的話就會進入下一步,進行檔案定位。注意自定義模組會有個 模組路徑 的概念,載入自定義模組時會首先在當前檔案的同級 node_modules 目錄下查詢,如果沒有找到的話就向上一級繼續查詢 node_modules,直到系統根目錄(Windows 的磁碟機代號目錄,比如 C:\ 或者 *nix 的根目錄 /),所以自定義模組的載入耗時最長。

    路徑分析之後會進行檔案定位,嘗試多種不同的副檔名然後判斷檔案是否存在,如果最終都不存在的話就會繼續把這個模組當做自定義模組進行載入,如果還是找不到就直接報錯。擴充套件判斷的順序依次為 .js.json.node

  • Nodejs 對於不同模組的編譯方式

    • JavaScript 模組透過包裹函式包裹之後交給系統函式執行
    • JSON 模組透過 JSON.parse 轉化為 JavaScript 物件然後返回結果
    • C/C++ 模組透過系統級的 process.dlopen 函式載入執行

相關文章