Nodejs 模塊化實現示例深入探究

正文

本文隻討論 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 對象中,以便下一次加載的時候可以直接從緩存中取到。

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

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 =&gt; 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') &amp;&amp; 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 &amp;&amp; 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 函數加載執行

以上就是Nodejs 模塊化實現示例深入探究的詳細內容,更多關於Nodejs 模塊化的資料請關註WalkonNet其它相關文章!

推薦閱讀: