手寫CommonJS 中的 require函數

前言

什麼是 CommonJS ?

node.js 的應用採用的commonjs模塊規範。javascript

每個文件就是一個模塊,擁有本身獨立的做用域,變量,以及方法等,對其餘的模塊都不可見。CommonJS規範規定:每一個模塊內部,module變量表明當前模塊。這個變量是一個對象,它的exports屬性(即module.exports)是對外的接口。加載某個模塊,實際上是加載該模塊的module.exports屬性。require方法用於加載模塊。html


CommonJS模塊的特色:

全部代碼都運行在模塊做用域,不會污染全局做用域。前端

模塊能夠屢次加載,可是隻會在第一次加載時運行一次,而後運行結果就被緩存了,之後再加載,就直接讀取緩存結果。要想讓模塊再次運行,必須清除緩存。java

模塊加載的順序,按照其在代碼中出現的順序。node


如何使用?

假設咱們如今有個a.js文件,咱們要在main.js 中使用a.js的一些方法和變量,運行環境是nodejs。這樣咱們就可使用CommonJS規範,讓a文件導出方法/變量。而後使用require函數引入變量/函數。json

示例:api

// a.js

module.exports = '這是a.js的變量'; // 導出一個變量/方法/對象均可以複製代碼


// main.js

let str = require('./a'); // 這裏若是導入a.js,那麼他會自動按照預約順序幫你添加後綴
console.log(str); // 輸出:'這是a.js的變量'複製代碼


手寫一個require函數

前言

咱們如今就開始手寫一個 精簡版的 require函數,這個require函數支持如下功能:數組

  1. 導入一個符合CommonJS規範的JS文件。
  2. 支持自動添加文件後綴(暫時支持JS和JSON文件)

如今就開始吧!緩存


1. 定義一個req方法

咱們先自定義一個req方法,和全局的require函數隔離開。bash

這個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函數支持四種類型文件:

  1. js文件
  2. json文件
  3. node文件
  4. 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的實現流程: 

  1. 拿到要加載的文件絕對路徑。沒有後綴的嘗試添加後綴
  2. 嘗試從緩存中讀取導出內容。若是緩存有,返回緩存內容。沒有,下一步處理
  3. 新建一個模塊實例,並輸入進緩存對象
  4. 嘗試加載模塊
  5. 根據文件類型,分類處理
  6. 若是是js文件,讀取到文件內容,拼接自執行函數文本,用vm模塊建立沙箱實例加載函數文本,得到導出內容,返回內容
  7. 若是是json文件,讀取到文件內容,用JSON.parse 函數轉成js對象,返回內容
  8. 獲取導出返回值。


掛個招聘

咱們是碼雲Gitee私有化部門,正在招聘阿里p6級別的前端開發。要求:統招本科學歷及以上,4年以上前端開發經驗,25-35k。座標北京西三旗,不打卡,不996。有意者請發送簡歷至:wangshengsong@oschina.cn 


感謝各位讀者的支持。歡迎點贊 評論 轉發 收藏

相關文章
相關標籤/搜索