【深入探究Node】(2)“模塊機(jī)制” 有十三問
我嘗試用一種自問自答的方式記下筆記,就像面試一樣,我自個(gè)兒覺得有意思極了,希望你也喜歡
第一問:CommonJS規(guī)范是干嘛的
CommonJS規(guī)范為JavaScript制定了一個(gè)美好的愿景——希望JavaScript能夠在任何地方運(yùn)行。
CommonJS規(guī)范的提出,主要是為了彌補(bǔ)當(dāng)前JavaScript沒有標(biāo)準(zhǔn)的缺陷,以達(dá)到像Python、Ruby和Java具備開發(fā)大型應(yīng)用的基礎(chǔ)能力,而不是停留在小腳本程序的階段
第二問:那你知道CommonJs模塊包含什么嗎?
CommonJS對模塊的定義十分簡單,主要分為模塊引用、模塊定義和模塊標(biāo)識3個(gè)部分。
1.模塊引用
模塊引用的示例代碼如下:
var math = require('math');
在CommonJS規(guī)范中,存在require()方法,這個(gè)方法接受模塊標(biāo)識,以此引入一個(gè)模塊的API到當(dāng)前上下文中。
2.模塊定義
在模塊中,上下文提供require()方法來引入外部模塊。對應(yīng)引入的功能,上下文提供了exports對象用于導(dǎo)出當(dāng)前模塊的方法或者變量,并且它是唯一導(dǎo)出的出口。在模塊中,還存在一個(gè)module對象,它代表模塊自身,而exports是module的屬性。在Node中,一個(gè)文件就是一個(gè)模塊,將方法掛載在exports對象上作為屬性即可定義導(dǎo)出的方式:
// math.js
exports.add = function () {
var sum = 0,
i = 0,
args = arguments,
l = args.length;
while (i < l) {
sum += args[i++];
}
return sum;
};
在另一個(gè)文件中,我們通過require()方法引入模塊后,就能調(diào)用定義的屬性或方法了:
// program.js
var math = require('math');
exports.increment = function (val) {
return math.add(val, 1);
};
模塊的定義十分簡單,接口也十分簡潔。它的意義在于將類聚的方法和變量等限定在私有的作用域中,同時(shí)支持引入和導(dǎo)出功能以順暢地連接上下游依賴。如圖所示,每個(gè)模塊具有獨(dú)立的空間,它們互不干擾,在引用時(shí)也顯得干凈利落。
CommonJS構(gòu)建的這套模塊導(dǎo)出和引入機(jī)制使得用戶完全不必考慮變量污染,命名空間等方案與之相比相形見絀。
3.模塊標(biāo)識
模塊標(biāo)識其實(shí)就是傳遞給require()方法的參數(shù),它必須是符合小駝峰命名的字符串,或者以.、..開頭的相對路徑,或者絕對路徑。它可以沒有文件名后綴.js。
第三問:上面提到了模塊引用,你可以談?wù)勀K引用的過程嗎?
在Node中引入模塊,需要經(jīng)歷如下3個(gè)步驟。
(1) 路徑分析 (2) 文件定位 (3) 編譯執(zhí)行
第四問:Node中所有模塊的引用都要經(jīng)歷這些?
非也!
在Node中,模塊分為兩類:一類是Node提供的模塊,稱為核心模塊;另一類是用戶編寫的模塊,稱為文件模塊。
? 核心模塊部分在Node源代碼的編譯過程中,編譯進(jìn)了二進(jìn)制執(zhí)行文件。在Node進(jìn)程啟動時(shí),部分核心模塊就被直接加載進(jìn)內(nèi)存中,所以這部分核心模塊引入時(shí),文件定位和編譯執(zhí)行這兩個(gè)步驟可以省略掉,并且在路徑分析中優(yōu)先判斷,所以它的加載速度是最快的。
? 文件模塊則是在運(yùn)行時(shí)動態(tài)加載,需要完整的路徑分析、文件定位、編譯執(zhí)行過程,速度比核心模塊慢。
第五問:我覺得還不夠全面,特別重要的一點(diǎn)就是模塊二次引用的時(shí)候,你沒講。
確實(shí),模塊二次引用跟第一次是不一樣的。
與前端瀏覽器會緩存靜態(tài)腳本文件以提高性能一樣,Node對引入過的模塊都會進(jìn)行緩存,以減少二次引入時(shí)的開銷。不同的地方在于,瀏覽器僅僅緩存文件,而Node緩存的是與前端瀏覽器會緩存靜態(tài)腳本文件以提高性能一樣,Node對引入過的模塊都會進(jìn)行緩存,以減少二次引入時(shí)的開銷。不同的地方在于,瀏覽器僅僅緩存文件,而Node緩存的是編譯和執(zhí)行之后的對象。
不論是核心模塊還是文件模塊,require()方法對相同模塊的二次加載都一律采用緩存優(yōu)先的方式,這是第一優(yōu)先級的。不同之處在于核心模塊的緩存檢查先于文件模塊的緩存檢查。。
不論是核心模塊還是文件模塊,require()方法對相同模塊的二次加載都一律采用緩存優(yōu)先的方式,這是第一優(yōu)先級的。不同之處在于核心模塊的緩存檢查先于文件模塊的緩存檢查。
第六問:你能談?wù)?模塊引用中的路徑分析嗎?
可以,路徑分析其實(shí)就是 模塊標(biāo)志符分析
模塊標(biāo)識符在Node中主要分為以下幾類。
? 核心模塊,如http、fs、path等。 ? .或..開始的相對路徑文件模塊。 ? 以/開始的絕對路徑文件模塊。 ? 非路徑形式的文件模塊,如自定義的connect模塊。
而這幾種標(biāo)志符的分析都是不同的。
●核心模塊
核心模塊的優(yōu)先級僅次于緩存加載,它在Node的源代碼編譯過程中已經(jīng)編譯為二進(jìn)制代碼,其加載過程最快。
如果試圖加載一個(gè)與核心模塊標(biāo)識符相同的自定義模塊,那是不會成功的。如果自己編寫了一個(gè)http用戶模塊,想要加載成功,必須選擇一個(gè)不同的標(biāo)識符或者換用路徑的方式。
●路徑形式的文件模塊
以.、..和/開始的標(biāo)識符,這里都被當(dāng)做文件模塊來處理。在分析文件模塊時(shí),require()方法會將路徑轉(zhuǎn)為真實(shí)路徑,并以真實(shí)路徑作為索引,將編譯執(zhí)行后的結(jié)果存放到緩存中,以使二次加載時(shí)更快。
由于文件模塊給Node指明了確切的文件位置,所以在查找過程中可以節(jié)約大量時(shí)間,其加載速度慢于核心模塊。
●自定義模塊
自定義模塊指的是非核心模塊,也不是路徑形式的標(biāo)識符。它是一種特殊的文件模塊,可能是一個(gè)文件或者包的形式(通常我們npm install 的包就是屬于自定義模塊,它是被放在node_modules包里的)。這類模塊的查找是最費(fèi)時(shí)的,也是所有方式中最慢的一種。
第七問:為什么說自定義模塊的查找是最慢的?
模塊路徑是Node在定位文件模塊的具體文件時(shí)制定的查找策略,具體表現(xiàn)為一個(gè)路徑組成的數(shù)組。關(guān)于這個(gè)路徑的生成規(guī)則,我們可以手動嘗試一番。
(1) 創(chuàng)建module_path.js文件,其內(nèi)容為 console.log(module.paths);。(2) 將其放到任意一個(gè)目錄中然后執(zhí)行node module_path.js。
在Linux下,你可能得到的是這樣一個(gè)數(shù)組輸出:
[ '/home/jackson/research/node_modules',
'/home/jackson/node_modules',
'/home/node_modules',
'/node_modules' ]
而在Windows下,也許是這樣:
[ 'c:\\nodejs\\node_modules', 'c:\\node_modules' ]
可以看出,模塊路徑的生成規(guī)則如下所示。
? 當(dāng)前文件目錄下的node_modules目錄。 ? 父目錄下的node_modules目錄。 ? 父目錄的父目錄下的node_modules目錄。 ? 沿路徑向上逐級遞歸,直到根目錄下的node_modules目錄。
它的生成方式與JavaScript的原型鏈或作用域鏈的查找方式十分類似。在加載的過程中,Node會逐個(gè)嘗試模塊路徑中的路徑,直到找到目標(biāo)文件為止。可以看出,當(dāng)前文件的路徑越深,模塊查找耗時(shí)會越多,這是自定義模塊的加載速度是最慢的原因。
第八問:假如我使用require("myfile")引用文件模塊,那這個(gè)模塊分析過程是怎樣的。
我覺得需要分兩種情況討論,一種是 當(dāng)查到的myfile是文件時(shí)就需要按照文件擴(kuò)展名分析,一種是查不到是文件,而是目錄或者包時(shí),就需要繼續(xù)按照 目錄分析
●文件擴(kuò)展名分析
require()在分析標(biāo)識符的過程中,會出現(xiàn)標(biāo)識符中不包含文件擴(kuò)展名的情況。CommonJS模塊規(guī)范也允許在標(biāo)識符中不包含文件擴(kuò)展名,這種情況下,Node會按.js、.json、.node的次序補(bǔ)足擴(kuò)展名,依次嘗試。
在嘗試的過程中,需要調(diào)用fs模塊同步阻塞式地判斷文件是否存在。因?yàn)镹ode是單線程的,所以這里是一個(gè)會引起性能問題的地方。小訣竅是:如果是.node和.json文件,在傳遞給require()的標(biāo)識符中帶上擴(kuò)展名,會加快一點(diǎn)速度。另一個(gè)訣竅是:同步配合緩存,可以大幅度緩解Node單線程中阻塞式調(diào)用的缺陷。
●目錄分析和包
在分析標(biāo)識符的過程中,require()通過分析文件擴(kuò)展名之后,可能沒有查找到對應(yīng)文件,但卻得到一個(gè)目錄,這在引入自定義模塊和逐個(gè)模塊路徑進(jìn)行查找時(shí)經(jīng)常會出現(xiàn),此時(shí)Node會將目錄當(dāng)做一個(gè)包來處理。
在這個(gè)過程中,Node對CommonJS包規(guī)范進(jìn)行了一定程度的支持。首先,Node在當(dāng)前目錄下查找package.json(CommonJS包規(guī)范定義的包描述文件),通過JSON.parse()解析出包描述對象,從中取出main屬性指定的文件名進(jìn)行定位。如果文件名缺少擴(kuò)展名,將會進(jìn)入擴(kuò)展名分析的步驟。
而如果main屬性指定的文件名錯(cuò)誤,或者壓根沒有package.json文件,Node會將index當(dāng)做默認(rèn)文件名,然后依次查找index.js、index.json、index.node。
如果在目錄分析的過程中沒有定位成功任何文件,則自定義模塊進(jìn)入下一個(gè)模塊路徑進(jìn)行查找。如果模塊路徑數(shù)組都被遍歷完畢,依然沒有查找到目標(biāo)文件,則會拋出查找失敗的異常。
第九問:上面提到模塊的引入的最后步驟模塊編譯了,其實(shí)文件定位之后是加載文件,然后編譯,你能談?wù)劜煌募窃趺醇虞d的嗎
在Node中,每個(gè)文件模塊都是一個(gè)Module對象,,它的定義如下:
function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent;
if (parent && parent.children) {
parent.children.push(this);
}
this.filename = null;
this.loaded = false;
this.children = [];
}
編譯和執(zhí)行是引入文件模塊的最后一個(gè)階段。定位到具體的文件后,Node會新建一個(gè)模塊對象,然后根據(jù)路徑載入并編譯。對于不同的文件擴(kuò)展名,其載入方法也有所不同,具體如下所示。
? .js文件。通過fs模塊同步讀取文件后編譯執(zhí)行。 ? .node文件。這是用 C/C++編寫的擴(kuò)展文件,通過dlopen()方法加載最后編譯生成的文件。? .json文件。通過fs模塊同步讀取文件后,用 JSON.parse()解析返回結(jié)果。? 其余擴(kuò)展名文件。它們都被當(dāng)做.js文件載入。
每一個(gè)編譯成功的模塊都會將其文件路徑作為索引緩存在Module._cache對象上,以提高二次引入的性能。
根據(jù)不同的文件擴(kuò)展名,Node會調(diào)用不同的讀取方式,如.json文件的調(diào)用如下:
// Native extension for .json
Module._extensions['.json'] = function(module, filename) {
var content = NativeModule.require('fs').readFileSync(filename, 'utf8');
try {
module.exports = JSON.parse(stripBOM(content));
} catch (err) {
err.message = filename + ': ' + err.message;
throw err;
}
};
其中,Module._extensions會被賦值給require()的extensions屬性,所以通過在代碼中訪問require.extensions可以知道系統(tǒng)中已有的擴(kuò)展加載方式。編寫如下代碼測試一下:
console.log(require.extensions);
得到的執(zhí)行結(jié)果如下:
{ '.js': [Function], '.json': [Function], '.node': [Function] }
如果想對自定義的擴(kuò)展名進(jìn)行特殊的加載,可以通過類似require.extensions['.ext']的方式實(shí)現(xiàn)。
在確定文件的擴(kuò)展名之后,Node將調(diào)用具體的編譯方式來將文件執(zhí)行后返回給調(diào)用者。
第十問:前面談到分別有.js ,.node, .json的文件模塊。我比較感興趣的是.js,即JavaScript模塊的編譯,你能談?wù)剢幔?/span>
好的。
回到CommonJS模塊規(guī)范,我們知道每個(gè)模塊文件中存在著require、exports、module這3個(gè)變量,但是它們在模塊文件中并沒有定義,那么從何而來呢?甚至在Node的API文檔中,我們知道每個(gè)模塊中還有__filename、__dirname這兩個(gè)變量的存在,它們又是從何而來的呢?如果我們把直接定義模塊的過程放諸在瀏覽器端,會存在污染全局變量的情況。
事實(shí)上,在編譯的過程中,Node對獲取的JavaScript文件內(nèi)容進(jìn)行了頭尾包裝。在頭部添加了(function (exports, require, module, __filename, __dirname) {\n,在尾部添加了\n});。一個(gè)正常的JavaScript文件會被包裝成如下的樣子:
(function (exports, require, module, __filename, __dirname) {
var math = require('math');
exports.area = function (radius) {
return Math.PI * radius * radius;
};
});
這樣每個(gè)模塊文件之間都進(jìn)行了作用域隔離。包裝之后的代碼會通過vm原生模塊的runInThisContext()方法執(zhí)行(類似eval,只是具有明確上下文,不污染全局),返回一個(gè)具體的function對象。最后,將當(dāng)前模塊對象的exports屬性、require()方法、module(模塊對象自身),以及在文件定位中得到的完整文件路徑和文件目錄作為參數(shù)傳遞給這個(gè)function()執(zhí)行。
這就是這些變量并沒有定義在每個(gè)模塊文件中卻存在的原因。在執(zhí)行之后,模塊的exports屬性被返回給了調(diào)用方。exports屬性上的任何方法和屬性都可以被外部調(diào)用到,但是模塊中的其余變量或?qū)傩詣t不可直接被調(diào)用。
至此,require、exports、module的流程已經(jīng)完整,這就是Node對CommonJS模塊規(guī)范的實(shí)現(xiàn)。
第十一問:好了,順便也談?wù)凜/C++模塊和JSON文件的編譯吧
好的。
C/C++模塊的編譯
Node調(diào)用process.dlopen()方法進(jìn)行加載和執(zhí)行。
實(shí)際上,.node的模塊文件并不需要編譯,因?yàn)樗蔷帉慍/C++模塊之后編譯生成的,所以這里只有加載和執(zhí)行的過程。在執(zhí)行的過程中,模塊的exports對象與.node模塊產(chǎn)生聯(lián)系,然后返回給調(diào)用者。
C/C++模塊給Node使用者帶來的優(yōu)勢主要是執(zhí)行效率方面的,劣勢則是C/C++模塊的編寫門檻比JavaScript高。
JSON文件的編譯
.json文件的編譯是3種編譯方式中最簡單的。Node利用fs模塊同步讀取JSON文件的內(nèi)容之后,調(diào)用JSON.parse()方法得到對象,然后將它賦給模塊對象的exports,以供外部調(diào)用。
JSON文件在用作項(xiàng)目的配置文件時(shí)比較有用。如果你定義了一個(gè)JSON文件作為配置,那就不必調(diào)用fs模塊去異步讀取和解析,直接調(diào)用require()引入即可。此外,你還可以享受到模塊緩存的便利,并且二次引入時(shí)也沒有性能影響。
第十二問:我們經(jīng)常在面試中遇到除了CommonJS外,其實(shí)還遇到AMD,能否介紹下AMD呢?
JavaScript在Node出現(xiàn)之后,比別的編程語言多了一項(xiàng)優(yōu)勢,那就是一些模塊可以在前后端實(shí)現(xiàn)共用,這是因?yàn)楹芏郃PI在各個(gè)宿主環(huán)境下都提供。但是在實(shí)際情況中,前后端的環(huán)境是略有差別的。
前后端JavaScript分別擱置在HTTP的兩端,它們扮演的角色并不同。瀏覽器端的JavaScript需要經(jīng)歷從同一個(gè)服務(wù)器端分發(fā)到多個(gè)客戶端執(zhí)行,而服務(wù)器端JavaScript則是相同的代碼需要多次執(zhí)行。前者的瓶頸在于帶寬,后者的瓶頸則在于CPU和內(nèi)存等資源。前者需要通過網(wǎng)絡(luò)加載代碼,后者從磁盤中加載,兩者的加載速度不在一個(gè)數(shù)量級上。
縱觀Node的模塊引入過程,幾乎全都是同步的。盡管與Node強(qiáng)調(diào)異步的行為有些相反,但它是合理的。但是如果前端模塊也采用同步的方式來引入,那將會在用戶體驗(yàn)上造成很大的問題。UI在初始化過程中需要花費(fèi)很多時(shí)間來等待腳本加載完成。鑒于網(wǎng)絡(luò)的原因,CommonJS為后端JavaScript制定的規(guī)范并不完全適合前端的應(yīng)用場景。經(jīng)過一段爭執(zhí)之后,AMD規(guī)范最終在前端應(yīng)用場景中勝出。它的全稱是Asynchronous Module Definition,即是“異步模塊定義”。
AMD規(guī)范是CommonJS模塊規(guī)范的一個(gè)延伸,它的模塊定義如下:
define(id? , dependencies? , factory);
它的模塊id和依賴是可選的,與Node模塊相似的地方在于factory的內(nèi)容就是實(shí)際代碼的內(nèi)容。下面的代碼定義了一個(gè)簡單的模塊:
define(function() {
var exports = {};
exports.sayHello = function() {
alert('Hello from module: ' + module.id);
};
return exports;
});
不同之處在于AMD模塊需要用define來明確定義一個(gè)模塊,而在Node實(shí)現(xiàn)中是隱式包裝的,它們的目的是進(jìn)行作用域隔離,僅在需要的時(shí)候被引入,避免掉過去那種通過全局變量或者全局命名空間的方式,以免變量污染和不小心被修改。另一個(gè)區(qū)別則是內(nèi)容需要通過返回的方式實(shí)現(xiàn)導(dǎo)出。
第十三問:其實(shí)除了CommonJS ,AMD外,還有CMD,順便也介紹下吧
好的。
CMD規(guī)范由國內(nèi)的玉伯提出,與AMD規(guī)范的主要區(qū)別在于定義模塊和依賴引入的部分。AMD需要在聲明模塊的時(shí)候指定所有的依賴,通過形參傳遞依賴到模塊內(nèi)容中:
define(['dep1', 'dep2'], function (dep1, dep2) {
return function () {};
});
與AMD模塊規(guī)范相比,CMD模塊更接近于Node對CommonJS規(guī)范的定義:
define(factory);
在依賴部分,CMD支持動態(tài)引入,示例如下:
define(function(require, exports, module) {
// The module code goes here
});
require、exports和module通過形參傳遞給模塊,在需要依賴模塊時(shí),隨時(shí)調(diào)用require()引入即可。
最后
公眾號里回復(fù)關(guān)鍵詞加群,加入技術(shù)交流群 文章點(diǎn)個(gè)在看,支持一下把!點(diǎn)擊關(guān)注我們

