做爲前端開發者,不可避免天天都要跟 Node.js 打交道。Node 遵循 Commonjs 規範,規範的核心是經過 require 來加載依賴的其餘模塊。咱們已常常習慣於使用社區提供的各類庫,但對於模塊引用的背後原理知之甚少。這篇文章經過源碼閱讀,淺析在 commonjs 規範中 require 背後的工做原理。javascript
你們都知道,在 node js 的模塊/文件中,有些「全局」變量是能夠直接使用的,好比 require
, module
, __dirname
, __filename
, exports
。html
其實這些變量或方法並非「全局」的,而是在 commonjs
模塊加載中, 經過包裹的形式,提供的局部變量。前端
module.exports = function () {
console.log(__dirname);
}
複製代碼
通過 compile
以後,就有了 module
,__dirname
等變量能夠直接使用。java
(function (exports, require, module, __filename, __dirname) {
module.exports = function () {
console.log(__dirname);
}
})
複製代碼
這也能夠很好解答初學者經常會困惑的問題,爲何給 exports 賦值,require 以後獲得的結果是 undefined?node
// 直接給 exports 賦值是不會生效的
(function (exports, module) {
exports = function () {
}
})(m.exports, m)
return m.exports;
複製代碼
直接賦值只是修改了局部變臉 exports
的值。最終 export
出去的 module.exports
沒有被賦值。git
文檔中描述得很是清楚,簡化版 require 模塊的查找過程以下:github
在 Y 路徑下,require(X)
1. 若是X是內置模塊(http, fs, path 等), 直接返回內置模塊,再也不執行
2. 若是 X 以 '/' 開頭,把 Y 設置爲文件系統根目錄
3. 若是 X 以 './', '/', '../' 開頭
a. 按照文件的形式加載(Y + X),根據 extensions 依次嘗試加載文件 [X, X.js, X.json, X.node]
若是存在就返回該文件,再也不繼續執行。
b. 按照文件夾的形式加載(Y + X),若是存在就返回該文件,再也不繼續執行,若找不到將拋出錯誤
a. 嘗試解析路徑下 package.json main 字段
b. 嘗試加載路徑下的 index 文件(index.js, index.json, index.node)
4. 搜索 NODE_MODULE,若存在就返回模塊
a. 從路徑 Y 開始,一層層往上找,嘗試加載(路徑 + 'node_modules/' + X)
b. 在 GLOBAL_FOLDERS node_modules 目錄中查找 X
5. 拋出 "Not Found" Error
複製代碼
例如在 /Users/helkyle/projects/learning-module/foo.js
中 require('bar') 將會從/Users/helkyle/projects/learning-module/
開始逐層往上查找 bar
模塊(不是以 './', '/', '../'
開頭)。npm
'/Users/helkyle/projects/learning-module/node_modules',
'/Users/helkyle/projects/node_modules',
'/Users/helkyle/node_modules',
'/Users/node_modules',
'/node_modules'
複製代碼
須要注意的是,在使用 npm link
功能的時候,被 link
模塊內的 require
會以被 link
模塊在文件系統中的絕對路徑進行查找,而不是 main module
所在的路徑。json
舉個例子,假設有兩個模塊。後端
/usr/lib/foo
/usr/lib/bar
複製代碼
經過 link
形式在 foo
模塊中 link bar
,會產生軟連 /usr/lib/foo/node_modules/bar
指向 /usr/lib/bar
,這種狀況下 bar
模塊下 require('quux')
的查找路徑是 /usr/lib/bar/node_modules/
而不是 /usr/lib/foo/node_modules
在實踐過程當中能瞭解到,實際上 Node module require
的過程會有緩存。也就是兩次 require
同一個 module
會獲得同樣的結果。
// a.js
module.exports = {
foo: 1,
};
// b.js
const a1 = require('./a.js');
a1.foo = 2;
const a2 = require('./a.js');
console.log(a2.foo); // 2
console.log(a1 === a2); // true
複製代碼
執行 node b.js
,能夠看到,第二次 require a.js
跟第一次 require
獲得的是相同的模塊引用。
從源碼上看,require
是對 module
經常使用方法的封裝。
function makeRequireFunction(mod, redirects) {
const Module = mod.constructor;
let require;
// 簡化其餘代碼
require = function require(path) {
return mod.require(path);
};
function resolve(request, options) {
validateString(request, 'request');
return Module._resolveFilename(request, mod, false, options);
}
require.resolve = resolve;
function paths(request) {
validateString(request, 'request');
return Module._resolveLookupPaths(request, mod);
}
resolve.paths = paths;
require.main = process.mainModule;
require.extensions = Module._extensions;
require.cache = Module._cache;
return require;
}
複製代碼
跟蹤代碼看到,require()
最終調用的是 Module._load
方法:
// 忽略代碼,看看 load 的過程發生了什麼?
Module._load = function(request, parent, isMain) {
// 調用 _resolveFilename 得到模塊絕對路徑
const filename = Module._resolveFilename(request, parent, isMain);
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
// 若是存在緩存,直接返回緩存的 exports 對象
return cachedModule.exports;
}
// 內建模塊直接返回
const mod = loadNativeModule(filename, request, experimentalModules);
if (mod && mod.canBeRequiredByUsers) return mod.exports;
// 建立新的 module 對象
const module = new Module(filename, parent);
// main module 特殊處理
if (isMain) {
process.mainModule = module;
module.id = '.';
}
// 緩存 module
Module._cache[filename] = module;
// 返回 module exports 對象
return module.exports;
};
複製代碼
到這裏,module cache
的原理也很清晰,模塊在首次加載後,會以模塊絕對路徑爲 key
緩存在 Module._cache
屬性上,再次 require
時會直接返回已緩存的結果以提升 效率。在控制檯打印 require.cache
看看。
// b.js
require('./a.js');
require('./a.js');
console.log(require.cache);
複製代碼
緩存中有兩個key
,分別是 a.js
, b.js
文件在系統中的絕對路徑。value
則是對應模塊 load
以後的 module
對象。因此第二次 require('./a.js')
的結果是 require.cache['/Users/helkyle/projects/learning-module/a.js'].exports
和第一次 require
指向的是同一個 Object
。
{
'/Users/helkyle/projects/learning-module/b.js':
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/helkyle/projects/learning-module/b.js',
loaded: false,
children: [ [Object] ],
paths:
[ '/Users/helkyle/projects/learning-module/node_modules',
'/Users/helkyle/projects/node_modules',
'/Users/helkyle/node_modules',
'/Users/node_modules',
'/node_modules' ] },
'/Users/helkyle/projects/learning-module/a.js':
Module {
id: '/Users/helkyle/projects/learning-module/a.js',
exports: { foo: 1 },
parent:
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/helkyle/projects/learning-module/b.js',
loaded: false,
children: [Array],
paths: [Array] },
filename: '/Users/helkyle/projects/learning-module/a.js',
loaded: true,
children: [],
paths: [
'/Users/helkyle/projects/learning-module/node_modules',
'/Users/helkyle/projects/node_modules',
'/Users/helkyle/node_modules',
'/Users/node_modules',
'/node_modules'
]
}
}
複製代碼
jest 是 Facebook 開源的前端測試庫,提供了不少很是強大又實用的功能。mock module
是其中很是搶眼的特性。使用方式是在須要被 mock 的文件模塊同級目錄下的 __mock__
文件夾添加同名文件,執行測試代碼時運行 jest.mock(modulePath)
,jest
會自動加載 mock
版本的 module
。
舉個例子,項目中有個 apis
文件,提供對接後端 api
。
// /projects/foo/apis.js
module.export = {
getUsers: () => fetch('api/users')
};
複製代碼
在跑測試過程當中,不但願它真的鏈接後端請求。這時候根據 jest
文檔,在 apis 文件同級目錄建立 mock file
// /projects/foo/__mock__/apis.js
module.exports = {
getUsers: () => [
{
id: "1",
name: "Helkyle"
},
{
id: "2",
name: "Chinuketsu"
}
]
}
複製代碼
測試文件中,主動調用 jest.mock('./apis.js')
便可。
jest.mock('./apis.js');
const apis = require('./apis.js');
apis.getUsers()
.then((users) => {
console.log(users);
// [ { id: '1', name: 'Helkyle' }, { id: '2', name: 'Chinuketsu' } ]
})
複製代碼
瞭解 require
的基礎原理以後,咱們也來實現相似的功能,將加載 api.js
的語句改寫成加載 __mock__/api.js
。
因爲緩存機制的存在,提早寫入目標緩存,再次 require 將獲得咱們指望的結果。
// 提早 require mock apis 文件,產生緩存。
require('./__mock__/apis.js');
// 給即將 require 的文件路徑寫入緩存
const originalPath = require.resolve('./apis.js');
require.cache[originalPath] = require.cache[require.resolve('./__mock__/apis.js')];
// 獲得的將是緩存版本
const apis = require('./apis.js');
apis.getUsers()
.then((users) => {
console.log(users);
// [ { id: '1', name: 'Helkyle' }, { id: '2', name: 'Chinuketsu' } ]
})
複製代碼
基於 require.cache
的方式,須要提早 require mock module
。👆提到了,因爲最終都是經過 Module._load
來加載模塊,在這個位置進行攔截便可完成按需 mock
。
const Module = require('module');
const originalLoad = Module._load;
Module._load = function (path, ...rest) {
if (path === './apis.js') {
path = './__mock__/apis.js';
}
return originalLoad.apply(Module, [path, ...rest]);
}
const apis = require('./apis.js');
apis.getUsers()
.then((users) => {
console.log(users);
})
複製代碼
注意:以上內容僅供參考。從實際運行結果上看,Jest 有本身實現的模塊加載機制,跟 commonjs
有出入。好比在 jest
中 require module
並不會寫入 require.cache
。
查閱 Node
文檔發現,在 Command Line
章節也有一個 --require
,使用這個參數能夠在執行業務代碼以前預先加載特定模塊。
舉個例子,編寫 setup
文件,往 global
對象上掛載 it
, assert
等方法。
// setup.js
global.it = async function test(title, callback) {
try {
await callback();
console.log(`✓ ${title}`);
} catch (error) {
console.error(`✕ ${title}`);
console.error(error);
}
}
global.assert = require('assert');
複製代碼
給啓動代碼添加 --require
參數。引入 global.assert
, global.it
,就能夠在代碼中直接使用 assert
, it
不用在測試文件中引入。
node --require './setup.js' foo.test.js
複製代碼
// foo.test.js
// 不須要 require('assert');
function sum (a, b) {
return a + b;
}
// 沒有 --require 會報 it is not defined
it('add two numbers', () => {
assert(sum(2, 3) === 5);
})
複製代碼