寫在前面
為什么會出現CommonJS規范?
因為JavaScript本身並沒有模塊的概念,不支持封閉的作用域和依賴管理,傳統的文件引入方式又會污染變量,甚至文件引入的先后順序都會影響整個項目的運行。同時也沒有一個相對標准的文件引入規范和包管理系統,這個時候CommonJS規范就出現了。
CommonJS規范的優點有哪些?
- 首先要說的就是它的封裝功能,模塊化可以隱藏私有的屬性和方法,這樣不需要別人在重新造輪子。
- 第二就是它能夠封裝作用域,保證了命名空間不會出現命名沖突的問題。
- 第三nodejs中npm包管理有20萬以上的包並且被全球的開發人員不斷更新維護,開發效率幾何倍增。
模塊化的定義
下面就是本文的重頭戲部分了,通過手寫一個CommonJS規范,更加清晰和認識模塊化的含義及如何實現的。另外本文中的示例代碼需要在node.js環境中方可正常運行,否則將出現錯誤。事實上ES6已經出現了模塊規范,如果使用ES6的模塊規范是無需node.js環境的。因此,需要將commonJS規范和ES6的模塊規范區分開來。
1.自執行函數
我們先寫一段簡單的代碼,在node環境下運行,來看看commonJS是如何處理的:
一段非常簡單的函數,調用時候傳遞參數name,將一段字符串返回。但是通過斷點調試我們發現在node環境下,node本身自動給sayHello函數加了一層外衣,就是下面的內容:
(function (exports, require, module, __filename, __dirname) {});
我們不難發現,其實這是一個自執行函數,那么為什么要加上這樣一段看似多余的代碼吶,這就是我們說得CommonJS規范一個好處,它將要執行的函數封裝了起來,所有的變量和方法都可以理解為是私有的了,保證了命名空間。
2.文件導出
前面我們已經了解到在node中,每個文件都可以被看成是一個模塊,那么node中對於模塊的導出,都是使用的相同的方法module.exports。
var str='hello World';
module.exports=str;
3.文件導入
為了方便的使用模塊,我們可以使用require方法對模塊進行導入,類似於這樣:
var a=require('./a.js');
值的注意的是:在文件引入的過程中,是否使用相對或者絕對路徑,如果
a.js
前添加./
或者../
是證明是第三方模塊,不寫絕對和相對路徑為內置模塊,例如:fs
。
分析commonJS規范源碼
我們寫一個簡單的模塊引入,通過斷點,分析它的代碼,並以此為來完善我們自己的commonJS規范
Module._load
首先我們能看到第一次進入是require方法中,分析代碼:
- assert方法用來進行斷言,那么第一行代碼的含義就是判斷一下這個路徑的參數path是否存在,如果不存在就報錯
- 同理第二行代碼檢查路徑參數是不是一個字符串格式,如果不是也報錯
- 第三返回一個函數
Module._load
,從名字中可以看出這應該是一個加載的方法,此方法傳遞三個參數,第一個是路徑,第二個是this的指向,第三個是一個布爾值,表示為是否為必要的。
Module._resolveFilename
斷點繼續運行,走到下一個方法Module._resolveFilename,這個方法是用來解析文件名稱的,將相對路徑解析成絕對路徑。
var filename = Module._resolveFilename(request, parent, isMain);
Module._cache
node中會對已經加載過的模塊進行緩存,供下次引入時候使用,這個方法就是:Module._cache
var cachedModule = Module._cache[filename];
new modal
沒有緩存的時候,node會新建一個模塊,用來存放這個正在加載的模塊:
var module = new Module(filename, parent);
Module._cache[filename] = module;
tryModuleLoad
然后嘗試加載這個模塊
tryModuleLoad(module, filename);
Module._extensions
然后繼續回到load方法中,執行下面的代碼,對擴展名進行完善:
var extension = path.extname(filename) || '.js';
if (!Module._extensions[extension]) extension = '.js';
Module._extensions[extension](this, filename);
Module.wrap
有了文件名之后就就可以拿到對應的文件內容,下面就對文件內容進行處理,我們稱這個方法為文件包裹方法:
var wrapper = Module.wrap(content);
進入這個方法之后你會看到我們熟悉的自執行函數,通過字符串拼接的形式進行包裹。
然后讓這個函數執行
手寫commonJS規范
初始化
首先得有一個方法或者類實現這樣一個規范,然后這個方法接受一個參數path(路徑)
let fs = require('fs');//文件模塊,用來讀取文件
let path = require('path');//用來完善文件路徑
let vm=require('vm');//將字符串當作JavaScript執行
function req(path) {
}
function module() { //模塊相關
}
Module._load
第一步加載,傳入參數路徑,進入到方法中會有一個Module._resolveFilename
,用來解析文件名,我們的代碼就變成了:
let fs = require('fs');//文件模塊,用來讀取文件
let path = require('path');//用來完善文件路徑
let vm=require('vm');//將字符串當作JavaScript執行
function req(path) {
module._load(path);//嘗試加載模塊
}
function module() { //模塊相關
}
module._load = function (path) { //
let fileName=module._resolveFilename(path)//解析文件名
}
module._resolveFilename = function (path) {
}
在進入這個_resolveFilename
方法的時候,傳入的參數可能沒有后綴,可能是一個相對路徑,繼續完善module._resolveFilename
方法:
module._resolveFilename
我們利用正則表達式來對文件名后綴進行分析,這里只考慮是js文件還是json文件,然后利用path模塊完善文件后綴
module._resolveFilename = function (p) {
if ((/\.js$|\.json$/).test(p)) {
// 以js或者json結尾的
return path.resolve(__dirname, p);
}else{
// 沒有后后綴 自動拼后綴
}
}
如果沒有文件后綴名,我們需要補全后綴名,就調用了Module._extensions
Module._extensions
module._extensions = {
'.js':function (module) {},
'.json':function (module) {}
}
module._resolveFilename
方法中對_extensions
這個對象進行遍歷,然后將后綴名加上繼續嘗試,然后通過fs模塊的accessSync
方法對拼接好的路徑進行判斷,代碼如下:
Module._resolveFilename = function (p) {
if((/\.js$|\.json$/).test(p)){
// 以js或者json結尾的
return path.resolve(__dirname, p);
}else{
// 沒有后后綴 自動拼后綴
let exts = Object.keys(Module._extensions);
let realPath;
for (let i = 0; i < exts.length; i++) {
let temp = path.resolve(__dirname, p + exts[i]);
try {
fs.accessSync(temp); // 存在的
realPath = temp
break;
} catch (e) {
}
}
if(!realPath){
throw new Error('module not exists');
}
return realPath
}
}
到現在我們已經可以拿到完整的絕對路徑和后綴名了,根據上面的分析,我們就要去緩存中查看是否有緩存,如果有,就是用緩存的,如果沒有,加入緩存中。
Module._cache
首先去Module._cache這個對象中查找是否有,如果有就直接返回模塊中的exports,也就是cache.exports,如果沒有,就新創建一個模塊。並將模塊的絕對路徑作為module的id屬性
Module._cache = {};
Module._load = function (p) { // 相對路徑,可能這個文件沒有后綴,嘗試加后綴
let filename = Module._resolveFilename(p); // 獲取到絕對路徑
let cache = Module._cache[filename];
if(cache){ // 第一次沒有緩存 不會進來
}
let module = new Module(filename); // 沒有模塊就創建模塊
Module._cache[filename] = module;// 每個模塊都有exports對象 {}
//嘗試加載模塊
tryModuleLoad(module);
return module.exports
}
下面就開始嘗試加載這個模塊,並將module.exports返回。
tryModuleLoad
通過模塊的id我們可以很方便的拿到文件的擴展名,然后利用path.extname
方法來獲取文件的擴展名,並調用對應擴展名下面的處理方法:
function tryModuleLoad(module){
let ext = path.extname(module.id);//擴展名
// 如果擴展名是js,調用js處理器.如果是json,調用json處理器
Module._extensions[ext](module);
}
完善Module._extensions
如果這個文件是一個json文件。因為讀文件返回的是一個字符串,所以要用JSON.parse
轉換讀到的文件,至此對於json文件的引入就全部搞定了,所以要將module.exports賦值,這樣外面return才有內容。
如果是一個js文件,用獲取到的絕對路徑也就是 module的id屬性進行文件讀取,然后調用Module.wrap對文件內容進行包裹,也就是加在對應的自執行函數,然后執行這個函數。
Module._extensions完善如下:
Module._extensions = {
'.js':function (module) {
let content = fs.readFileSync(module.id, 'utf8');
let funcStr = Module.wrap(content);
let fn = vm.runInThisContext(funcStr);
fn.call(module.exports,module.exports,req,module);
},
'.json':function (module) {
module.exports = JSON.parse(fs.readFileSync(module.id, 'utf8'));
}
}
Module.wrap
我們用倆個字符串將文件內容進行包裹並返回新的字符串
Module.wrapper = [
"(function (exports, require, module, __filename, __dirname) {",
"})"
]
Module.wrap = function (script) {
return Module.wrapper[0] + script+ Module.wrapper[1];
}
小細節處理
到現在我們的代碼已經基本完成了,但是現在出現的問題是每次require的代碼都會被執行,我們希望的是有這個模塊的時候要直接使用exports中的值,所以代碼可以這樣完善:
if(cache){ // 第一次沒有緩存 不會進來
return cache.exports;
}
寫在最后
上面的代碼很多情況的處理我並沒有給出,比如path的處理等等。和真正的commonJS規范代碼還是有很多不足的地方,但是我希望通過這樣的方式可以加深你對commonJS規范的理解和使用,特此說明。