前言
什麼是 CommonJS ?
node.js 的應用採用的commonjs模組規範。
每一個檔案就是一個模組,擁有自己獨立的作用域,變數,以及方法等,對其他的模組都不可見。CommonJS規範規定:每個模組內部,module變數代表當前模組。這個變數是一個物件,它的exports屬性(即module.exports)是對外的介面。載入某個模組,其實是載入該模組的module.exports屬性。require方法用於載入模組。
CommonJS模組的特點:
所有程式碼都執行在模組作用域,不會汙染全域性作用域。
模組可以多次載入,但是隻會在第一次載入時執行一次,然後執行結果就被快取了,以後再載入,就直接讀取快取結果。要想讓模組再次執行,必須清除快取。
模組載入的順序,按照其在程式碼中出現的順序。
如何使用?
假設我們現在有個a.js檔案,我們要在main.js 中使用a.js的一些方法和變數,執行環境是nodejs。這樣我們就可以使用CommonJS規範,讓a檔案匯出方法/變數。然後使用require函式引入變數/函式。
示例:
// a.js
module.exports = '這是a.js的變數'; // 匯出一個變數/方法/物件都可以複製程式碼
// main.js
let str = require('./a'); // 這裡如果匯入a.js,那麼他會自動按照預定順序幫你新增字尾
console.log(str); // 輸出:'這是a.js的變數'複製程式碼
手寫一個require函式
前言
我們現在就開始手寫一個 精簡版的 require函式,這個require函式支援以下功能:
- 匯入一個符合CommonJS規範的JS檔案。
- 支援自動新增檔案字尾(暫時支援JS和JSON檔案)
現在就開始吧!
1. 定義一個req方法
我們先自定義一個req方法,和全域性的require函式隔離開。
這個req方法,接受一個名為ID的引數,也就是要載入的檔案路徑。
// main.js
function req(id){}
let a = req('./a')
console.log(a)複製程式碼
2. 新建一個Module 類
新建一個module類,這個module將會處理檔案載入的全過程。
function Module(id) {
this.id = id; // 當前模組的檔案路徑
this.exports = {} // 當前模組匯出的結果,預設為空
}
複製程式碼
3. 獲取檔案絕對路徑
剛才我們介紹到,require 函式支援傳入一個路徑。這個路徑可以是相對路徑,也可以是絕對路徑,也可以不寫檔案字尾名。
我們在Module類上新增一個叫做“_resolveFilename” 的方法,用於解析使用者傳進去的檔案路徑,獲取一個絕對路徑。
// 將一個相對路徑 轉化成絕對路徑
Module._resolveFilename = function (id) {}複製程式碼
繼續新增一個 “extennsions” 的屬性,這個屬性是一個物件。key是副檔名,value就是副檔名對應的不同檔案的處理方法。
我們通過debugger nodejs require原始碼看到,原生的require函式支援四種型別檔案:
- js檔案
- json檔案
- node檔案
- mjs檔案
由於篇幅,這裡我們就只支援兩個副檔名:.js 和.json。
我們分別在extensions物件上,新增兩個屬性,兩個屬性的值分別都是一個函式。方便不同檔案型別分類處理。
// main.js
Module.extensions['.js'] = function (module) {}
Module.extensions['.json'] = function (module) {}複製程式碼
接著,我們匯入nodejs原生的“path”模組和“fs”模組,方便我們獲取檔案絕對路徑和檔案操作。
我們處理一下 Module._resolveFilename 這個方法,讓他可以正常工作。
Module._resolveFilename = function (id) {
// 將相對路徑轉化成絕對路徑
let absPath = path.resolve(id);
// 先判斷檔案是否存在如果存在了就不要增加了
if(fs.existsSync(absPath)){
return absPath;
}
// 去嘗試新增檔案字尾 .js .json
let extenisons = Object.keys(Module.extensions);
for (let i = 0; i < extenisons.length; i++) {
let ext = extenisons[i];
// 判斷路徑是否存在
let currentPath = absPath + ext; // 獲取拼接後的路徑
let exits = fs.existsSync(currentPath); // 判斷是否存在
if(exits){
return currentPath
}
}
throw new Error('檔案不存在')
}複製程式碼
在這裡,我們支援接受一個名id的引數,這個引數將是使用者傳來的路徑。
首先我們先使用 path.resolve()獲取到檔案絕對路徑。接著用 fs.existsSync 判斷檔案是否存在。如果沒有存在,我們就嘗試新增檔案字尾。
我們會去遍歷現在支援的檔案擴充套件物件,嘗試拼接路徑。如果拼接後檔案存在,返回檔案路徑。不存在丟擲異常。
這樣我們在req方法內,就可以獲取到完整的檔案路徑:
function req(id){
// 通過相對路徑獲取絕對路徑
let filename = Module._resolveFilename(id);
}複製程式碼
4. 載入模組 —— JS的實現
這裡就是我們的重頭戲,載入common.js模組。
首先 new 一個Module例項。傳入一個檔案路徑,然後返回一個新的module例項。
接著定義一個 tryModuleLoad 函式,傳入我們新建立的module例項。
function tryModuleLoad(module) { // 嘗試載入模組
let ext = path.extname(module.id);
Module.extensions[ext](module)
}複製程式碼
function req(id){
// 通過相對路徑獲取絕對路徑
let filename = Module._resolveFilename(id);
let module = new Module(filename); // new 一個新模組
tryModuleLoad(module);
}複製程式碼
tryModuleLoad 函式 獲取到module後,會使用 path.extname 函式獲取副檔名,接著按照不同副檔名交給不同的函式分別處理。
接下來,我們處理js檔案載入.
第一步,傳入一個module物件例項。
使用module物件中的id屬性,獲取檔案絕對路徑。拿到檔案絕對路徑後,使用fs模組讀取檔案內容。讀取編碼是utf8。
Module.extensions['.js'] = function (module) {
// 1) 讀取
let script = fs.readFileSync(module.id, 'utf8');
}複製程式碼
第二步,偽造一個自執行函式。
這裡先新建一個wrapper 陣列。陣列的第0項是自執行函式開頭,最後一項是結尾。
let wrapper = [
'(function (exports, require, module, __dirname, __filename) {\r\n',
'\r\n})'
];複製程式碼
這個自執行函式需要傳入5個引數:exports物件,require函式,module物件,dirname路徑,fileame檔名。
我們將獲取到的要載入檔案的內容,和自執行函式模版拼接,組裝成一個完整的可執行js文字:
Module.extensions['.js'] = function (module) {
// 1) 讀取
let script = fs.readFileSync(module.id, 'utf8');
// 2) 內容拼接
let content = wrapper[0] + script + wrapper[1];
}複製程式碼
第三步:建立沙箱執行環境
這裡我們就要用到nodejs中的 “vm” 模組了。這個模組可以建立一個nodejs的虛擬機器,提供一個獨立的沙箱執行環境。
具體介紹可以看: vm模組的官方介紹
我們使用vm模組的 runInThisContext函式,他可以建立一個有全域性global屬性的沙盒。用法是傳入一個js文字內容。我們將剛才拼接的文字內容傳入,返回一個fn函式:
const vm = require('vm');
Module.extensions['.js'] = function (module) {
// 1) 讀取
let script = fs.readFileSync(module.id, 'utf8');
// 2) 內容拼接
let content = wrapper[0] + script + wrapper[1];
// 3)建立沙盒環境,返回js函式
let fn = vm.runInThisContext(content);
}複製程式碼
第四步:執行沙箱環境,獲得匯出物件。
因為我們上面有需要檔案目錄路徑,所以我們先獲取一下目錄路徑。這裡使用path模組的dirname 方法。
接著我們使用call方法,傳入引數,立即執行。
call 方法的第一個引數是函式內部的this物件,其餘引數都是函式所需要的引數。
Module.extensions['.js'] = function (module) {
// 1) 讀取
let script = fs.readFileSync(module.id, 'utf8');
// 2) 增加函式 還是一個字串
let content = wrapper[0] + script + wrapper[1];
// 3) 讓這個字串函式執行 (node裡api)
let fn = vm.runInThisContext(content); // 這裡就會返回一個js函式
let __dirname = path.dirname(module.id);
// 讓函式執行
fn.call(module.exports, module.exports, req, module, __dirname, module.id)
}複製程式碼
這樣,我們傳入module物件,接著內部會將要匯出的值掛在到module的export屬性上。
第五步:返回匯出值
由於我們的處理函式是非純函式,所以直接返回module例項的export物件就ok。
function req(id){ // 沒有非同步的api方法
// 通過相對路徑獲取絕對路徑
let filename = Module._resolveFilename(id);
tryModuleLoad(module); // module.exports = {}
return module.exports;
}複製程式碼
這樣,我們就實現了一個簡單的require函式。
let str = req('./a');
// str = req('./a');
console.log(str);複製程式碼
// a.js
module.exports = "這是a.js檔案"複製程式碼
5. 載入模組 —— JSON檔案的實現
json檔案的實現就比較簡單了。使用fs讀取json檔案內容,然後用JSON.parse轉為js物件就ok。
Module.extensions['.json'] = function (module) {
let script = fs.readFileSync(module.id, 'utf8');
module.exports = JSON.parse(script)
}複製程式碼
6. 優化
文章初,我們有寫:commonjs會將我們要載入的模組快取。等我們再次讀取時,就去快取中讀取我們的模組,而不是再次呼叫fs和vm模組獲得匯出內容。
我們在Module物件上新建一個_cache屬性。這個屬性是一個物件,key是檔名,value是檔案匯出的內容快取。
在我們載入模組時,首先先去_cache屬性上找有沒有快取過。如果有,直接返回快取內容。如果沒有,嘗試獲取匯出內容,並掛在到快取物件上。
Module._cache = {}
function req(id){
// 通過相對路徑獲取絕對路徑
let filename = Module._resolveFilename(id);
let cache = Module._cache[filename];
if(cache){ // 如果有快取,直接將模組的結果返回
return cache.exports
}
let module = new Module(filename); // 建立了一個模組例項
Module._cache[filename] = module // 輸入進快取物件內
// 載入相關模組 (就是給這個模組的exports賦值)
tryModuleLoad(module); // module.exports = {}
return module.exports;
}
複製程式碼
完整實現
const path = require('path');
const fs = require('fs');
const vm = require('vm');
function Module(id) {
this.id = id; // 當前模組的id名
this.exports = {}; // 預設是空物件 匯出的結果
}
Module.extensions = {};
// 如果檔案是js 的話 後期用這個函式來處理
Module.extensions['.js'] = function (module) {
// 1) 讀取
let script = fs.readFileSync(module.id, 'utf8');
// 2) 增加函式 還是一個字串
let content = wrapper[0] + script + wrapper[1];
// 3) 讓這個字串函式執行 (node裡api)
let fn = vm.runInThisContext(content); // 這裡就會返回一個js函式
let __dirname = path.dirname(module.id);
// 讓函式執行
fn.call(module.exports, module.exports, req, module, __dirname, module.id)
}
// 如果檔案是json
Module.extensions['.json'] = function (module) {
let script = fs.readFileSync(module.id, 'utf8');
module.exports = JSON.parse(script)
}
// 將一個相對路徑 轉化成絕對路徑
Module._resolveFilename = function (id) {
// 將相對路徑轉化成絕對路徑
let absPath = path.resolve(id);
// 先判斷檔案是否存在如果存在
if(fs.existsSync(absPath)){
return absPath;
}
// 去嘗試新增檔案字尾 .js .json
let extenisons = Object.keys(Module.extensions);
for (let i = 0; i < extenisons.length; i++) {
let ext = extenisons[i];
// 判斷路徑是否存在
let currentPath = absPath + ext; // 獲取拼接後的路徑
let exits = fs.existsSync(currentPath); // 判斷是否存在
if(exits){
return currentPath
}
}
throw new Error('檔案不存在')
}
let wrapper = [
'(function (exports, require, module, __dirname, __filename) {\r\n',
'\r\n})'
];
// 模組獨立 相互沒關係
function tryModuleLoad(module) { // 嘗試載入模組
let ext = path.extname(module.id);
Module.extensions[ext](module)
}
Module._cache = {}
function req(id){ // 沒有非同步的api方法
// 通過相對路徑獲取絕對路徑
let filename = Module._resolveFilename(id);
let cache = Module._cache[filename];
if(cache){ // 如果有快取直接將模組的結果返回
return cache.exports
}
let module = new Module(filename); // 建立了一個模組
Module._cache[filename] = module;
// 載入相關模組 (就是給這個模組的exports賦值)
tryModuleLoad(module); // module.exports = {}
return module.exports;
}
let str = req('./a');
console.log(str);複製程式碼
結束總結
這樣,我們就手寫實現了一個精簡版的CommonJS require函式。
讓我們回顧一下,require的實現流程:
- 拿到要載入的檔案絕對路徑。沒有字尾的嘗試新增字尾
- 嘗試從快取中讀取匯出內容。如果快取有,返回快取內容。沒有,下一步處理
- 新建一個模組例項,並輸入進快取物件
- 嘗試載入模組
- 根據檔案型別,分類處理
- 如果是js檔案,讀取到檔案內容,拼接自執行函式文字,用vm模組建立沙箱例項載入函式文字,獲得匯出內容,返回內容
- 如果是json檔案,讀取到檔案內容,用JSON.parse 函式轉成js物件,返回內容
- 獲取匯出返回值。
掛個招聘
我們是碼雲Gitee私有化部門,正在招聘阿里p6級別的前端開發。要求:統招本科學歷及以上,4年以上前端開發經驗,25-35k。座標北京西三旗,不打卡,不996。有意者請傳送簡歷至:wangshengsong@oschina.cn