模組是Node.js裡面一個很基本也很重要的概念,各種原生類庫是通過模組提供的,第三方庫也是通過模組進行管理和引用的。本文會從基本的模組原理出發,到最後我們會利用這個原理,自己實現一個簡單的模組載入機制,即自己實現一個require
。
本文完整程式碼已上傳GitHub:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/Node.js/Module/MyModule/index.js
簡單例子
老規矩,講原理前我們先來一個簡單的例子,從這個例子入手一步一步深入原理。Node.js裡面如果要匯出某個內容,需要使用module.exports
,使用module.exports
幾乎可以匯出任意型別的JS物件,包括字串,函式,物件,陣列等等。我們先來建一個a.js
匯出一個最簡單的hello world
:
// a.js
module.exports = "hello world";
然後再來一個b.js
匯出一個函式:
// b.js
function add(a, b) {
return a + b;
}
module.exports = add;
然後在index.js
裡面使用他們,即require
他們,require
函式返回的結果就是對應檔案module.exports
的值:
// index.js
const a = require('./a.js');
const add = require('./b.js');
console.log(a); // "hello world"
console.log(add(1, 2)); // b匯出的是一個加法函式,可以直接使用,這行結果是3
require會先執行目標檔案
當我們require
某個模組時,並不是只拿他的module.exports
,而是會從頭開始執行這個檔案,module.exports = XXX
其實也只是其中一行程式碼,我們後面會講到,這行程式碼的效果其實就是修改模組裡面的exports
屬性。比如我們再來一個c.js
:
// c.js
let c = 1;
c = c + 1;
module.exports = c;
c = 6;
在c.js
裡面我們匯出了一個c
,這個c
經過了幾步計算,當執行到module.exports = c;
這行時c
的值為2
,所以我們require
的c.js
的值就是2
,後面將c
的值改為了6
並不影響前面的這行程式碼:
const c = require('./c.js');
console.log(c); // c的值是2
前面c.js
的變數c
是一個基本資料型別,所以後面的c = 6;
不影響前面的module.exports
,那他如果是一個引用型別呢?我們直接來試試吧:
// d.js
let d = {
num: 1
};
d.num++;
module.exports = d;
d.num = 6;
然後在index.js
裡面require
他:
const d = require('./d.js');
console.log(d); // { num: 6 }
我們發現在module.exports
後面給d.num
賦值仍然生效了,因為d
是一個物件,是一個引用型別,我們可以通過這個引用來修改他的值。其實對於引用型別來說,不僅僅在module.exports
後面可以修改他的值,在模組外面也可以修改,比如index.js
裡面就可以直接改:
const d = require('./d.js');
d.num = 7;
console.log(d); // { num: 7 }
require
和module.exports
不是黑魔法
我們通過前面的例子可以看出來,require
和module.exports
乾的事情並不複雜,我們先假設有一個全域性物件{}
,初始情況下是空的,當你require
某個檔案時,就將這個檔案拿出來執行,如果這個檔案裡面存在module.exports
,當執行到這行程式碼時將module.exports
的值加入這個物件,鍵為對應的檔名,最終這個物件就長這樣:
{
"a.js": "hello world",
"b.js": function add(){},
"c.js": 2,
"d.js": { num: 2 }
}
當你再次require
某個檔案時,如果這個物件裡面有對應的值,就直接返回給你,如果沒有就重複前面的步驟,執行目標檔案,然後將它的module.exports
加入這個全域性物件,並返回給呼叫者。這個全域性物件其實就是我們經常聽說的快取。所以require
和module.exports
並沒有什麼黑魔法,就只是執行並獲取目標檔案的值,然後加入快取,用的時候拿出來用就行。再看看這個物件,因為d.js
是一個引用型別,所以你在任何地方獲取了這個引用都可以更改他的值,如果不希望自己模組的值被更改,需要自己寫模組時進行處理,比如使用Object.freeze()
,Object.defineProperty()
之類的方法。
模組型別和載入順序
這一節的內容都是一些概念,比較枯燥,但是也是我們需要了解的。
模組型別
Node.js的模組有好幾種型別,前面我們使用的其實都是檔案模組
,總結下來,主要有這兩種型別:
- 內建模組:就是Node.js原生提供的功能,比如
fs
,http
等等,這些模組在Node.js程式起來時就載入了。- 檔案模組:我們前面寫的幾個模組,還有第三方模組,即
node_modules
下面的模組都是檔案模組。
載入順序
載入順序是指當我們require(X)
時,應該按照什麼順序去哪裡找X
,在官方文件上有詳細虛擬碼,總結下來大概是這麼個順序:
- 優先載入內建模組,即使有同名檔案,也會優先使用內建模組。
- 不是內建模組,先去快取找。
- 快取沒有就去找對應路徑的檔案。
- 不存在對應的檔案,就將這個路徑作為資料夾載入。
- 對應的檔案和資料夾都找不到就去
node_modules
下面找。- 還找不到就報錯了。
載入資料夾
前面提到找不到檔案就找資料夾,但是不可能將整個資料夾都載入進來,載入資料夾的時候也是有一個載入順序的:
- 先看看這個資料夾下面有沒有
package.json
,如果有就找裡面的main
欄位,main
欄位有值就載入對應的檔案。所以如果大家在看一些第三方庫原始碼時找不到入口就看看他package.json
裡面的main
欄位吧,比如jquery
的main
欄位就是這樣:"main": "dist/jquery.js"
。- 如果沒有
package.json
或者package.json
裡面沒有main
就找index
檔案。- 如果這兩步都找不到就報錯了。
支援的檔案型別
require
主要支援三種檔案型別:
- .js:
.js
檔案是我們最常用的檔案型別,載入的時候會先執行整個JS檔案,然後將前面說的module.exports
作為require
的返回值。- .json:
.json
檔案是一個普通的文字檔案,直接用JSON.parse
將其轉化為物件返回就行。- .node:
.node
檔案是C++編譯後的二進位制檔案,純前端一般很少接觸這個型別。
手寫require
前面其實我們已經將原理講的七七八八了,下面來到我們的重頭戲,自己實現一個require
。實現require
其實就是實現整個Node.js的模組載入機制,我們再來理一下需要解決的問題:
- 通過傳入的路徑名找到對應的檔案。
- 執行找到的檔案,同時要注入
module
和require
這些方法和屬性,以便模組檔案使用。- 返回模組的
module.exports
本文的手寫程式碼全部參照Node.js官方原始碼,函式名和變數名儘量保持一致,其實就是精簡版的原始碼,大家可以對照著看,寫到具體方法時我也會貼上對應的原始碼地址。總體的程式碼都在這個檔案裡面:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js
Module類
Node.js模組載入的功能全部在Module
類裡面,整個程式碼使用物件導向的思想,如果你對JS的物件導向還不是很熟悉可以先看看這篇文章。Module
類的建構函式也不復雜,主要是一些值的初始化,為了跟官方Module
名字區分開,我們自己的類命名為MyModule
:
function MyModule(id = '') {
this.id = id; // 這個id其實就是我們require的路徑
this.path = path.dirname(id); // path是Node.js內建模組,用它來獲取傳入引數對應的資料夾路徑
this.exports = {}; // 匯出的東西放這裡,初始化為空物件
this.filename = null; // 模組對應的檔名
this.loaded = false; // loaded用來標識當前模組是否已經載入
}
require方法
我們一直用的require
其實是Module
類的一個例項方法,內容很簡單,先做一些引數檢查,然後呼叫Module._load
方法,原始碼看這裡:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L970。精簡版的程式碼如下:
MyModule.prototype.require = function (id) {
return Module._load(id);
}
MyModule._load
MyModule._load
是一個靜態方法,這才是require
方法的真正主體,他乾的事情其實是:
- 先檢查請求的模組在快取中是否已經存在了,如果存在了直接返回快取模組的
exports
。- 如果不在快取中,就
new
一個Module
例項,用這個例項載入對應的模組,並返回模組的exports
。
我們自己來實現下這兩個需求,快取直接放在Module._cache
這個靜態變數上,這個變數官方初始化使用的是Object.create(null)
,這樣可以使建立出來的原型指向null
,我們也這樣做吧:
MyModule._cache = Object.create(null);
MyModule._load = function (request) { // request是我們傳入的路勁引數
const filename = MyModule._resolveFilename(request);
// 先檢查快取,如果快取存在且已經載入,直接返回快取
const cachedModule = MyModule._cache[filename];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// 如果快取不存在,我們就載入這個模組
// 載入前先new一個MyModule例項,然後呼叫例項方法load來載入
// 載入完成直接返回module.exports
const module = new MyModule(filename);
// load之前就將這個模組快取下來,這樣如果有迴圈引用就會拿到這個快取,但是這個快取裡面的exports可能還沒有或者不完整
MyModule._cache[filename] = module;
module.load(filename);
return module.exports;
}
上述程式碼對應的原始碼看這裡:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L735
可以看到上述原始碼還呼叫了兩個方法:MyModule._resolveFilename
和MyModule.prototype.load
,下面我們來實現下這兩個方法。
MyModule._resolveFilename
MyModule._resolveFilename
從名字就可以看出來,這個方法是通過使用者傳入的require
引數來解析到真正的檔案地址的,原始碼中這個方法比較複雜,因為按照前面講的,他要支援多種引數:內建模組,相對路徑,絕對路徑,資料夾和第三方模組等等,如果是資料夾或者第三方模組還要解析裡面的package.json
和index.js
。我們這裡主要講原理,所以我們就只實現通過相對路徑和絕對路徑來查詢檔案,並支援自動新增js
和json
兩種字尾名:
MyModule._resolveFilename = function (request) {
const filename = path.resolve(request); // 獲取傳入引數對應的絕對路徑
const extname = path.extname(request); // 獲取檔案字尾名
// 如果沒有檔案字尾名,嘗試新增.js和.json
if (!extname) {
const exts = Object.keys(MyModule._extensions);
for (let i = 0; i < exts.length; i++) {
const currentPath = `${filename}${exts[i]}`;
// 如果拼接後的檔案存在,返回拼接的路徑
if (fs.existsSync(currentPath)) {
return currentPath;
}
}
}
return filename;
}
上述原始碼中我們還用到了一個靜態變數MyModule._extensions
,這個變數是用來存各種檔案對應的處理方法的,我們後面會實現他。
MyModule._resolveFilename
對應的原始碼看這裡:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L822
MyModule.prototype.load
MyModule.prototype.load
是一個例項方法,這個方法就是真正用來載入模組的方法,這其實也是不同型別檔案載入的一個入口,不同型別的檔案會對應MyModule._extensions
裡面的一個方法:
MyModule.prototype.load = function (filename) {
// 獲取檔案字尾名
const extname = path.extname(filename);
// 呼叫字尾名對應的處理函式來處理
MyModule._extensions[extname](this, filename);
this.loaded = true;
}
注意這段程式碼裡面的this
指向的是module
例項,因為他是一個例項方法。對應的原始碼看這裡: https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L942
載入js檔案: MyModule._extensions['.js']
前面我們說過不同檔案型別的處理方法都掛載在MyModule._extensions
上面的,我們先來實現.js
型別檔案的載入:
MyModule._extensions['.js'] = function (module, filename) {
const content = fs.readFileSync(filename, 'utf8');
module._compile(content, filename);
}
可以看到js
的載入方法很簡單,只是把檔案內容讀出來,然後調了另外一個例項方法_compile
來執行他。對應的原始碼看這裡:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L1098
編譯執行js檔案:MyModule.prototype._compile
MyModule.prototype._compile
是載入JS檔案的核心所在,也是我們最常使用的方法,這個方法需要將目標檔案拿出來執行一遍,執行之前需要將它整個程式碼包裹一層,以便注入exports, require, module, __dirname, __filename
,這也是我們能在JS檔案裡面直接使用這幾個變數的原因。要實現這種注入也不難,假如我們require
的檔案是一個簡單的Hello World
,長這樣:
module.exports = "hello world";
那我們怎麼來給他注入module
這個變數呢?答案是執行的時候在他外面再加一層函式,使他變成這樣:
function (module) { // 注入module變數,其實幾個變數同理
module.exports = "hello world";
}
所以我們如果將檔案內容作為一個字串的話,為了讓他能夠變成上面這樣,我們需要再給他拼接上開頭和結尾,我們直接將開頭和結尾放在一個陣列裡面:
MyModule.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
注意我們拼接的開頭和結尾多了一個()
包裹,這樣我們後面可以拿到這個匿名函式,在後面再加一個()
就可以傳引數執行了。然後將需要執行的函式拼接到這個方法中間:
MyModule.wrap = function (script) {
return MyModule.wrapper[0] + script + MyModule.wrapper[1];
};
這樣通過MyModule.wrap
包裝的程式碼就可以獲取到exports, require, module, __filename, __dirname
這幾個變數了。知道了這些就可以來寫MyModule.prototype._compile
了:
MyModule.prototype._compile = function (content, filename) {
const wrapper = Module.wrap(content); // 獲取包裝後函式體
// vm是nodejs的虛擬機器沙盒模組,runInThisContext方法可以接受一個字串並將它轉化為一個函式
// 返回值就是轉化後的函式,所以compiledWrapper是一個函式
const compiledWrapper = vm.runInThisContext(wrapper, {
filename,
lineOffset: 0,
displayErrors: true,
});
// 準備exports, require, module, __filename, __dirname這幾個引數
// exports可以直接用module.exports,即this.exports
// require官方原始碼中還包裝了一層,其實最後呼叫的還是this.require
// module不用說,就是this了
// __filename直接用傳進來的filename引數了
// __dirname需要通過filename獲取下
const dirname = path.dirname(filename);
compiledWrapper.call(this.exports, this.exports, this.require, this,
filename, dirname);
}
上述程式碼要注意我們注入進去的幾個引數和通過call
傳進去的this
:
- this:
compiledWrapper
是通過call
呼叫的,第一個引數就是裡面的this
,這裡我們傳入的是this.exports
,也就是module.exports
,也就是說我們js
檔案裡面this
是對module.exports
的一個引用。- exports:
compiledWrapper
正式接收的第一個引數是exports
,我們傳的也是this.exports
,所以js
檔案裡面的exports
也是對module.exports
的一個引用。- require: 這個方法我們傳的是
this.require
,其實就是MyModule.prototype.require
,也就是MyModule._load
。- module: 我們傳入的是
this
,也就是當前模組的例項。- __filename:檔案所在的絕對路徑。
- __dirname: 檔案所在資料夾的絕對路徑。
到這裡,我們的JS檔案其實已經記載完了,對應的原始碼看這裡:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L1043
載入json檔案: MyModule._extensions['.json']
載入json
檔案就簡單多了,只需要將檔案讀出來解析成json
就行了:
MyModule._extensions['.json'] = function (module, filename) {
const content = fs.readFileSync(filename, 'utf8');
module.exports = JSONParse(content);
}
exports
和module.exports
的區別
網上經常有人問,node.js
裡面的exports
和module.exports
到底有什麼區別,其實前面我們的手寫程式碼已經給出答案了,我們這裡再就這個問題詳細講解下。exports
和module.exports
這兩個變數都是通過下面這行程式碼注入的。
compiledWrapper.call(this.exports, this.exports, this.require, this,
filename, dirname);
初始狀態下,exports === module.exports === {}
,exports
是module.exports
的一個引用,如果你一直是這樣使用的:
exports.a = 1;
module.exports.b = 2;
console.log(exports === module.exports); // true
上述程式碼中,exports
和module.exports
都是指向同一個物件{}
,你往這個物件上新增屬性並沒有改變這個物件本身的引用地址,所以exports === module.exports
一直成立。
但是如果你哪天這樣使用了:
exports = {
a: 1
}
或者這樣使用了:
module.exports = {
b: 2
}
那其實你是給exports
或者module.exports
重新賦值了,改變了他們的引用地址,那這兩個屬性的連線就斷開了,他們就不再相等了。需要注意的是,你對module.exports
的重新賦值會作為模組的匯出內容,但是你對exports
的重新賦值並不能改變模組匯出內容,只是改變了exports
這個變數而已,因為模組始終是module
,匯出內容是module.exports
。
迴圈引用
Node.js對於迴圈引用是進行了處理的,下面是官方例子:
a.js
:
console.log('a 開始');
exports.done = false;
const b = require('./b.js');
console.log('在 a 中,b.done = %j', b.done);
exports.done = true;
console.log('a 結束');
b.js
:
console.log('b 開始');
exports.done = false;
const a = require('./a.js');
console.log('在 b 中,a.done = %j', a.done);
exports.done = true;
console.log('b 結束');
main.js
:
console.log('main 開始');
const a = require('./a.js');
const b = require('./b.js');
console.log('在 main 中,a.done=%j,b.done=%j', a.done, b.done);
當 main.js
載入 a.js
時, a.js
又載入 b.js
。 此時, b.js
會嘗試去載入 a.js
。 為了防止無限的迴圈,會返回一個 a.js
的 exports
物件的 未完成的副本 給 b.js
模組。 然後 b.js
完成載入,並將 exports
物件提供給 a.js
模組。
那麼這個效果是怎麼實現的呢?答案就在我們的MyModule._load
原始碼裡面,注意這兩行程式碼的順序:
MyModule._cache[filename] = module;
module.load(filename);
上述程式碼中我們是先將快取設定了,然後再執行的真正的load
,順著這個思路我能來理一下這裡的載入流程:
main
載入a
,a
在真正載入前先去快取中佔一個位置a
在正式載入時載入了b
b
又去載入了a
,這時候快取中已經有a
了,所以直接返回a.exports
,即使這時候的exports
是不完整的。
總結
require
不是黑魔法,整個Node.js的模組載入機制都是JS
實現的。- 每個模組裡面的
exports, require, module, __filename, __dirname
五個引數都不是全域性變數,而是模組載入的時候注入的。 - 為了注入這幾個變數,我們需要將使用者的程式碼用一個函式包裹起來,拼一個字串然後呼叫沙盒模組
vm
來實現。 - 初始狀態下,模組裡面的
this, exports, module.exports
都指向同一個物件,如果你對他們重新賦值,這種連線就斷了。 - 對
module.exports
的重新賦值會作為模組的匯出內容,但是你對exports
的重新賦值並不能改變模組匯出內容,只是改變了exports
這個變數而已,因為模組始終是module
,匯出內容是module.exports
。 - 為了解決迴圈引用,模組在載入前就會被加入快取,下次再載入會直接返回快取,如果這時候模組還沒載入完,你可能拿到未完成的
exports
。 - Node.js實現的這套載入機制叫CommonJS。
本文完整程式碼已上傳GitHub:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/Node.js/Module/MyModule/index.js
參考資料
Node.js模組載入原始碼:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js
Node.js模組官方文件:http://nodejs.cn/api/modules.html
文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。
作者博文GitHub專案地址: https://github.com/dennis-jiang/Front-End-Knowledges