CommonJS 模塊化簡易實現

在這裏插入圖片描述

原文出自:https://www.pandashen.com


CommonJS 概述

CommonJS 是一種模塊化的標準,而 NodeJS 是這種標準的實現,每個文件就是一個模塊,有自己的作用域。在一個文件裏面定義的變量、函數、類,都是私有的,對其他文件不可見。

NodeJS 模塊化的簡易實現

在實現模塊加載之前,我們需要清除模塊的加載過程:

  • 假設 A 文件夾下有一個 a.js,我們要解析出一個絕對路徑來;
  • 我們寫的路徑可能沒有後綴名 .js.json
  • 得到一個真實的加載路徑(模塊會被緩存)先去緩存中看一下這個文件是否存在,如果存在返回緩存 沒有則創建一個模塊;
  • 得到對應文件的內容,加一個閉包,把內容塞進去,之後執行即可。

1、提前加載需要用到的模塊

因爲我們只是實現 CommonJS 的模塊加載方法,並不會去實現整個 Node,在這裏我們需要依賴一些 Node 的模塊,所以我們就 “不要臉” 的使用 Node 自帶的 require 方法把模塊加載進來。

// 依賴模塊
// 操作文件的模塊
const fs = require("fs");

// 處理路徑的模塊
const path = require("path");

// 虛擬機,幫我們創建一個黑箱執行代碼,防止變量污染
const vm = require("vm");

2、創建 Module 構造函數

其實 CommonJS 中引入的每一個模塊我們都需要通過 Module 構造函數創建一個實例。

// 創建 Module 構造函數
/*
* @param {String} p
*/
function Module(p) {
    this.id = p; // 當前文件的表示(絕對路徑)
    this.exports = {}; // 每個模塊都有一個 exports 屬性,用來存儲模塊的內容
    this.loaded = false; // 標記是否被加載過
}

3、定義靜態屬性存儲我們需要使用的一些值

// Module 靜態變量
// 函數後面需要使用的閉包的字符串
Module.wrapper = [
    "(function (exports, require, module, __dirname, __filename) {",
    "\n})"
];

// 根據絕對路徑進行緩存的模塊的對象
Module._cacheModule = {};

// 處理不同文件後綴名的方法
Module._extensions = {
    ".js": function() {},
    ".json": function() {}
};

4、創建引入模塊的 req 方法

爲了防止和 Node 自帶的 require 方法重名,我們將模擬的方法重命名爲 req

// 引入模塊方法 req
/*
* @param {String} moduleId
*/
function req(moduleId) {
    // 將 req 傳入的參數處理成絕對路徑
    let p = Module._resolveFileName(moduleId);

    // 生成一個新的模塊
    let module = new Module(p);
}

在上面代碼中,我們先把傳入的參數通過 Module._resolveFileName 處理成了一個絕對路徑,並創建模塊實例把絕對路徑作爲參數傳入,我們現在實現一下 Module._resolveFileName 方法。

5、返回文件絕對路徑 Module._resolveFileName 方法的實現

這個方法的功能就是將 req 方法的參數根據是否有後綴名兩種方式處理成帶後綴名的文件絕對路徑,如果 req 的參數沒有後綴名,會去按照 Module._extensions 的鍵的後綴名順序進行查找文件,直到找到後綴名對應文件的絕對路徑,優先 .js,然後是 .json,這裏我們只實現這兩種文件類型的處理。

// 處理絕對路徑 _resolveFileName 方法
/*
* @param {String} moduleId
*/
Module._resolveFileName = function(moduleId) {
    // 將參數拼接成絕對路徑
    let p = path.resolve(moduleId);

    // 判斷是否含有後綴名
    if (!/\.\w+$/.test(p)) {
        // 創建規範規定查找文件後綴名順序的數組 .js .json
        let arr = Object.keys(Module._extensions);

        // 循環查找
        for (let i = 0; i < arr.length; i++) {
            // 將絕對路徑與後綴名進行拼接
            let file = p + arr[i];
            // 查找不到文件時捕獲異常
            try {
                // 並通過 fs 模塊同步查找文件的方法對改路徑進行查找,文件未找到會直接進入 catch 語句
                fs.accessSync(file);

                // 如果找到文件將該文件絕對路徑返回
                return file;
            } catch (e) {
                // 當後綴名循環完畢都沒有找到對應文件時,拋出異常
                if (i >= arr.length) throw new Error("not found module");
            }
        }
    } else {
        // 有後綴名直接返回該絕對路徑
        return p;
    }
};

6、加載模塊的 load 方法

// 完善 req 方法
/*
* @param {String} moduleId
*/
function req(moduleId) {
    // 將 req 傳入的參數處理成絕對路徑
    let p = Module._resolveFileName(moduleId);

    // 生成一個新的模塊
    let module = new Module(p);

    // ********** 下面爲新增代碼 **********
    // 加載模塊
    let content = module.load(p);

    // 將加載後返回的內容賦值給模塊實例的 exports 屬性上
    module.exports = content;

    // 最後返回 模塊實例的 exports 屬性,即加載模塊的內容
    return module.exports;
    // ********** 上面爲新增代碼 **********
}

上面代碼實現了一個實例方法 load,傳入文件的絕對路徑,爲模塊加載文件的內容,在加載後將值存入模塊實例的 exports 屬性上最後返回,其實 req 函數返回的就是模塊加載回來的內容。

// load 方法
// 模塊加載的方法
Module.prototype.load = function(filepath) {
    // 判斷加載的文件是什麼後綴名
    let ext = path.extname(filepath);

    // 根據不同的後綴名處理文件內容,參數是當前實例
    let content = Moudule._extensions[ext](this);

    // 將處理後的結果返回
    return content;
};

7、實現加載 .js 文件和 .json 文件的方法

還記得前面準備的靜態屬性中有 Module._extensions 就是用來存儲這兩個方法的,下面我們來完善這兩個方法。

// 處理後綴名方法的 _extensions 對象
Module._extensions = {
    ".js": function(module) {
        // 讀取 js 文件,返回文件的內容
        let script = fs.readFileSync(module.id, "utf8");

        // 給 js 文件的內容增加一個閉包環境
        let fn = Module.wrap(script);

        // 創建虛擬機,將我們創建的 js 函數執行,將 this 指向模塊實例的 exports 屬性
        vm.runInThisContext(fn).call(
            module.exports,
            module.exports,
            req,
            module
        );

        // 返回模塊實例上的 exports 屬性(即模塊的內容)
        return module.exports;
    },
    ".json": function(module) {
        // .json 文件的處理相對簡單,將讀出的字符串轉換成對象即可
        return JSON.parse(fs.readFileSync(module.id, "utf8"));
    }
};

我們這裏使用了 Module.wrap 方法,代碼如下,其實幫助我們加了一個閉包環境(即套了一層函數並傳入了我們需要的參數),裏面所有的變量都是私有的。

// 創建閉包 wrap 方法
Module.wrap = function(content) {
    return Module.wrapper[0] + content + Module.wrapper[1];
};

Module.wrapper 的兩個值其實就是我們需要在外層包了一個函數的前半段和後半段。

這裏我們要劃重點了,非常重要:
1、我們在虛擬機中執行構建的閉包函數時利用執行上/下文 callthis 指向了模塊實例的 exports 屬性上,所以這也是爲什麼我們用 Node 啓動一個 js 文件,打印 this 時,不是全局對象 global,而是一個空對象,這個空對象就是我們的 module.exports,即當前模塊實例的 exports 屬性。
2、還是第一條的函數執行,我們傳入的第一個參數是改變 this 指向,那第二個參數是 module.exports,所以在每個模塊導出的時候,使用 module.exports = xxx,其實直接替換了模塊實例的值,即直接把模塊的內容存放在了模塊實例的 exports 屬性上,而 req 最後返回的就是我們模塊導出的內容。
3、第三個參數之所以傳入 req 是因爲我們還可能在一個模塊中導入其他模塊,而 req 會返回其他模塊的導出在當前模塊使用,這樣整個 CommonJS 的規則就這樣建立起來了。

8、對加載過的模塊進行緩存

我們現在的程序是有問題的,當重複加載了一個已經加載過得模塊,當執行 req 方法的時候會發現,又創建了一個新的模塊實例,這是不合理的,所以我們下面來實現一下緩存機制。

還記得之前的一個靜態屬性 Module._cacheModule,它的值是一個空對象,我們會把所有加載過的模塊的實例存儲到這個對象上。

// 完善 req 方法(處理緩存)
/*
* @param {String} moduleId
*/
function req(moduleId) {
    // 將 req 傳入的參數處理成絕對路徑
    let p = Module._resolveFileName(moduleId);

    // ********** 下面爲新增代碼 **********
    // 判斷是否已經加載過
    if (Module._cacheModule[p]) {
        // 模塊存在,如果有直接把 exports 對象返回即可
        return Module._cacheModule[p].exprots;
    }
    // ********** 上面爲新增代碼 **********

    // 生成一個新的模塊
    let module = new Module(p);

    // 加載模塊
    let content = module.load(p);

    // ********** 下面爲新增代碼 **********
    // 存儲時是拿模塊的絕對路徑作爲鍵與模塊內容相對應的
    Module._cacheModule[p] = module;

    // 是否緩存表示改爲 true
    module.loaded = true;
    // ********** 上面爲新增代碼 **********

    // 將加載後返回的內容賦值給模塊實例的 exports 屬性上
    module.exports = content;

    // 最後返回 模塊實例的 exports 屬性,即加載模塊的內容
    return module.exports;
}

9、試用 req 加載模塊

在同級目錄下新建一個文件 a.js,使用 module.exports 隨便導出一些內容,在我們實現模塊加載的最下方嘗試引入並打印內容。

// 導出自定義模塊
// a.js
module.exports = "Hello world";
// 檢測 req 方法
const a = req("./a");
console.log(a); // Hello world

<hr/>

CommonJS 模塊查找規範

其實我們只實現了 CommonJS 規範的一部分,即自定義模塊的加載,其實在 CommonJS 的規範當中關於模塊查找的規則還有很多,具體的我們就用下面的流程圖來表示。

在這裏插入圖片描述

這篇文章讓我們瞭解了 CommonJS 是什麼,主要目的在於理解 Node 模塊化的實現思路,想要更深入的瞭解 CommonJS 的實現細節,建議看一看 NodeJS 源碼對應的部分,如果覺得源碼比較多,不容易找到模塊化實現的代碼,也可以在 VSCode 中通過調用 require 方法引入模塊時,打斷點調試,一步一步的跟進到 Node 源碼中查看。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章