作者:肖磊
個人主頁:github
最近一直在用node.js
寫一些相關的工具,對於node.js
的模組如何去載入,以及所遵循的模組載入規範的具體細節又是如何並不是瞭解。這篇檔案也是看過node.js
原始碼及部分文章總結而來:
在es2015
標準以前,js
並沒有成熟的模組系統的規範。Node.js
為了彌補這樣一個缺陷,採用了CommonJS
規範中所定義的模組規範,它包括:
1.require
require
是一個函式,它接收一個模組的識別符號,用以引用其他模組暴露出來的API
。
2.module context
module context
規定了一個模組當中,存在一個require變數,它遵從上面對於這個require
函式的定義,一個exports
物件,模組如果需要向外暴露API,即在一個exports
的物件上新增屬性。以及一個module object
。
3.module Identifiers
module Identifiers
定義了require
函式所接受的引數規則,比如說必須是小駝峰命名的字串,可以沒有檔案字尾名,.
或者..
表明檔案路徑是相對路徑等等。
具體關於commonJS
中定義的module
規範,可以參見wiki文件
在我們的node.js
程式當中,我們使用require
這個看起來是全域性(後面會解釋為什麼看起來是全域性的)的方法去載入其他模組。
const util = require(`./util`)
複製程式碼
首先我們來看下關於這個方法,node.js
內部是如何定義的:
Module.prototype.require = function () {
assert(path, `missing path`);
assert(typeof path === `string`, `path must be a string`);
// 實際上是呼叫Module._load方法
return Module._load(path, this, /* isMain */ false);
}
Module._load = function (request, parent, isMain) {
.....
// 獲取檔名
var filename = Module._resolveFilename(request, parent, isMain);
// _cache快取的模組
var cachedModule = Module._cache[filename];
if (cachedModule) {
updateChildren(parent, cachedModule, true);
return cachedModule.exports;
}
// 如果是nativeModule模組
if (NativeModule.nonInternalExists(filename)) {
debug(`load native module %s`, request);
return NativeModule.require(filename);
}
// Don`t call updateChildren(), Module constructor already does.
// 初始化一個新的module
var module = new Module(filename, parent);
if (isMain) {
process.mainModule = module;
module.id = `.`;
}
// 載入模組前,就將這個模組快取起來。注意node.js的模組載入系統是如何避免迴圈依賴的
Module._cache[filename] = module;
// 載入module
tryModuleLoad(module, filename);
// 將module.exports匯出的內容返回
return module.exports;
}
複製程式碼
Module._load
方法是一個內部的方法,主要是:
- 根據你傳入的代表模組路徑的字串來查詢相應的模組路徑;
- 根據找到的模組路徑來做快取;
- 進而去載入對應的模組。
接下來我們來看下node.js
是如何根據傳入的模組路徑字串來查詢對應的模組的:
Module._resolveFilename = function (request, parent, isMain, options) {
if (NativeModule.nonInternalExists(request)) {
return request;
}
var paths;
if (typeof options === `object` && options !== null &&
Array.isArray(options.paths)) {
...
} else {
// 獲取模組的大致路徑 [parentDir] | [id, [parentDir]]
paths = Module._resolveLookupPaths(request, parent, true);
}
// look up the filename first, since that`s the cache key.
// node index.js
// request = index.js
// paths = [`/root/foo/bar/index.js`, `/root/foo/bar`]
var filename = Module._findPath(request, paths, isMain);
if (!filename) {
var err = new Error(`Cannot find module `${request}``);
err.code = `MODULE_NOT_FOUND`;
throw err;
}
return filename;
}
複製程式碼
在這個方法內部,需要呼叫一個內部的方法:Module._resolveLookupPaths
,這個方法會依據父模組的路徑獲取所有這個模組可能的路徑:
Module._resolveLookupPaths = function (request, parent, newReturn) {
...
}
複製程式碼
這個方法內部有以下幾種情況的處理:
- 是啟動模組,即通過
node xxx
啟動的模組
這個時候node.js
會直接獲取到你這個程式執行路徑,並在這個方法當中返回
require(xxx)
require一個存在於node_modules
中的模組
這個時候會對執行路徑上所有可能存在node_modules
的路徑進行遍歷一遍
require(./)
require一個相對路徑或者絕對路徑的模組
直接返回父路徑
當拿到需要找尋的路徑後,呼叫Module._findPath
方法去查詢對應的檔案路徑。
Module._findPath = function (request, paths, isMain) {
if (path.isAbsolute(request)) {
paths = [``];
} else if (!paths || paths.length === 0) {
return false;
}
// x00 -> null,相當於空字串
var cacheKey = request + `x00` +
(paths.length === 1 ? paths[0] : paths.join(`x00`));
// 路徑的快取
var entry = Module._pathCache[cacheKey];
if (entry)
return entry;
var exts;
// 尾部是否帶有/
var trailingSlash = request.length > 0 &&
request.charCodeAt(request.length - 1) === 47/*/*/;
// For each path
for (var i = 0; i < paths.length; i++) {
// Don`t search further if path doesn`t exist
const curPath = paths[i]; // 當前路徑
if (curPath && stat(curPath) < 1) continue;
var basePath = path.resolve(curPath, request);
var filename;
// 呼叫internalModuleStat方法來判斷檔案型別
var rc = stat(basePath);
// 如果路徑不以/結尾,那麼可能是檔案,也可能是資料夾
if (!trailingSlash) {
if (rc === 0) { // File. 檔案
if (preserveSymlinks && !isMain) {
filename = path.resolve(basePath);
} else {
filename = toRealPath(basePath);
}
} else if (rc === 1) { // Directory. 當提供的路徑是資料夾的情況下會去這個路徑下找package.json中的main欄位對應的模組的入口檔案
if (exts === undefined)
// `.js` `.json` `.node` `.ms`
exts = Object.keys(Module._extensions);
// 獲取pkg內部的main欄位對應的值
filename = tryPackage(basePath, exts, isMain);
}
if (!filename) {
// try it with each of the extensions
if (exts === undefined)
exts = Object.keys(Module._extensions);
filename = tryExtensions(basePath, exts, isMain); // ${basePath}.(js|json|node)等檔案字尾,看是否檔案存在
}
}
// 如果路徑以/結尾,那麼就是資料夾
if (!filename && rc === 1) { // Directory.
if (exts === undefined)
exts = Object.keys(Module._extensions);
filename = tryPackage(basePath, exts, isMain) ||
// try it with each of the extensions at "index"
tryExtensions(path.resolve(basePath, `index`), exts, isMain);
}
if (filename) {
// Warn once if `.` resolved outside the module dir
if (request === `.` && i > 0) {
if (!warned) {
warned = true;
process.emitWarning(
`warning: require(`.`) resolved outside the package ` +
`directory. This functionality is deprecated and will be removed ` +
`soon.`,
`DeprecationWarning`, `DEP0019`);
}
}
// 快取路徑
Module._pathCache[cacheKey] = filename;
return filename;
}
}
return false;
}
function tryPackage(requestPath, exts, isMain) {
var pkg = readPackage(requestPath); // 獲取package.json當中的main欄位
if (!pkg) return false;
var filename = path.resolve(requestPath, pkg); // 解析路徑
return tryFile(filename, isMain) || // 直接判斷這個檔案是否存在
tryExtensions(filename, exts, isMain) || // 判斷這個分別以js,json,node等字尾結尾的檔案是否存在
tryExtensions(path.resolve(filename, `index`), exts, isMain); // 判斷這個分別以 ${filename}/index.(js|json|node)等字尾結尾的檔案是否存在
}
複製程式碼
梳理下上面查詢模組時的一個策略:
require
模組的時候,傳入的字串最後一個字元不是/
時:
-
如果是個檔案,那麼直接返回這個檔案的路徑
-
如果是個資料夾,那麼會找個這個資料夾下是否有
package.json
檔案,以及這個檔案當中的main
欄位對應的路徑(對應原始碼當中的方法為tryPackage
):- 如果main欄位對應的路徑是一個檔案且存在,那麼就返回這個路徑
- main欄位對應的路徑對應沒有帶字尾,那麼嘗試使用
.js
,.json
,.node
,.ms
字尾去載入對應檔案 - 如果以上2個條件都不滿足,那麼嘗試對應路徑下的
index.js
,index.json
,index.node
檔案
-
如果以上2個方法都沒有找到對應檔案路徑,那麼就對檔案路徑後新增分別新增
.js
,.json
,.node
,.ms
字尾去載入對應的檔案(對應原始碼當中的方法為tryExtensions
)
require
模組的時候,傳入的字串最後一個字元是/
時,即require
的是一個資料夾時:
- 首先查詢這個資料夾下的package.json檔案中的main欄位對應的路徑,具體的流程方法和上面說的查詢package.json檔案的一致
- 查詢當前檔案下的
index.js
,index.json
,index.node
等檔案
當找到檔案的路徑後就呼叫tryModuleLoad
開始載入模組了,這個方法內部實際上是呼叫了模組例項的load
方法:
Module.prototype.load = function () {
...
this.filename = filename;
// 定義module的paths。獲取這個module路徑上所有可能的node_modules路徑
this.paths = Module._nodeModulePaths(path.dirname(filename));
var extension = path.extname(filename) || `.js`;
if (!Module._extensions[extension]) extension = `.js`;
// 開始load這個檔案
Module._extensions[extension](this, filename);
this.loaded = true;
...
}
複製程式碼
呼叫Module._extension
方法去載入不同格式的檔案,就拿js
檔案來說:
Module._extensions[`.js`] = function(module, filename) {
// 首先讀取檔案的文字內容
var content = fs.readFileSync(filename, `utf8`);
module._compile(internalModule.stripBOM(content), filename);
};
複製程式碼
內部呼叫了Module.prototype._compile
這個方法:
Module.prototype._compile = function (content, filename)) {
content = internalModule.stripShebang(content);
// create wrapper function
// 將原始碼的文字包裹一層
var wrapper = Module.wrap(content);
// vm.runInThisContext在一個v8的虛擬機器內部執行wrapper後的程式碼
var compiledWrapper = vm.runInThisContext(wrapper, {
filename: filename,
lineOffset: 0,
displayErrors: true
});
var inspectorWrapper = null;
if (process._breakFirstLine && 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 (filename === resolvedArgv) {
delete process._breakFirstLine;
inspectorWrapper = process.binding(`inspector`).callAndPauseOnStart;
}
}
var dirname = path.dirname(filename);
// 構造require函式
var require = internalModule.makeRequireFunction(this);
var depth = internalModule.requireDepth;
if (depth === 0) stat.cache = new Map();
var result;
if (inspectorWrapper) {
result = inspectorWrapper(compiledWrapper, this.exports, this.exports,
require, this, filename, dirname);
} else {
// 開始執行這個函式
// 傳入的引數依次是 module.exports / require / module / filename / dirname
result = compiledWrapper.call(this.exports, this.exports, require, this,
filename, dirname);
}
if (depth === 0) stat.cache = null;
return result;
}
Module.wrap = function(script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
Module.wrapper = [
`(function (exports, require, module, __filename, __dirname) { `,
`
});`
];
複製程式碼
- 通過
Module.wrap
將原始碼包裹一層(遵循commonJS
規範) - 通過呼叫
vm
v8虛擬機器暴露出來的方法來構造一個新的函式 - 完成函式的呼叫
通過原始碼發現,Module.wrapper
在對原始碼文字進行包裹的時候,傳入了5個引數:
- exports
是對於第三個引數module
的exports
屬性的引用
- require
這個require
並非是Module.prototype.require
方法,而是通過internalModule.makeRequireFunction
重新構造出來的,這個方法內部還是依賴Module.prototype.require
方法去載入模組的,同時還對這個require
方法做了一些擴充。
- module
module
物件,如果需要向外暴露API
供其他模組來使用,需要在module.exports
屬性上定義
- __filename
當前檔案的絕對路徑
- __dirname
當前檔案的父資料夾的絕對路徑
幾個問題
exports 和 module.exports的關係
特別注意第一個引數和第三引數的聯絡:第一引數是對於第三個引數的exports
屬性的引用。一旦將某個模組exports
賦值給另外一個新的物件,那麼就斷開了exports
屬性和module.exports
之間的引用關係,同時在其他模組當中也無法引用在當前模組中通過exports
暴露出去的API
,對於模組的引用始終是獲取module.exports
屬性。
迴圈引用
官方示例:
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);
複製程式碼
$ node main.js
main 開始
a 開始
b 開始
在 b 中,a.done = false
b 結束
在 a 中,b.done = true
a 結束
在 main 中,a.done=true,b.done=true
複製程式碼
在a
模組載入時,需要載入b
模組,但是在實際載入a
模組之前,就已經將a
模組進行的快取,具體參見Module._load
方法:
Module._cache[filename] = module;
tryModuleLoad(module, filename);
複製程式碼
因為在載入b
模組的過程中再次去載入a
模組的時候,這時是直接從快取中獲取a
模組匯出的API
,此時exports.done
的屬性還是false
,未被設定為true
,只有當b
模組被完全載入後,a
模組exports
屬性才被設定為true
。