手寫CommonJS 中的 require函式

王聖鬆發表於2019-11-16

前言

什麼是 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函式支援以下功能:

  1. 匯入一個符合CommonJS規範的JS檔案。
  2. 支援自動新增檔案字尾(暫時支援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函式支援四種型別檔案:

  1. js檔案
  2. json檔案
  3. node檔案
  4. mjs檔案

手寫CommonJS 中的 require函式

手寫CommonJS 中的 require函式

由於篇幅,這裡我們就只支援兩個副檔名:.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 


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


相關文章