用了這麼久的 require,你真的懂它的原理嗎?

我們常說 node 並不是一門新的編程語言,他只是 javascript 的運行時,運行時你可以簡單地理解爲運行 javascript 的環境。在大多數情況下我們會在瀏覽器中去運行 javascript,有了 node 的出現,我們可以在 node 中去運行 javascript,這意味着哪裏安裝了 node 或者瀏覽器,我們就可以在哪裏運行 javascript。

1.node 模塊化的實現

node 中是自帶模塊化機制的,每個文件就是一個單獨的模塊,並且它遵循的是 CommonJS 規範,也就是使用 require 的方式導入模塊,通過 module.export 的方式導出模塊。

node 模塊的運行機制也很簡單,其實就是在每一個模塊外層包裹了一層函數,有了函數的包裹就可以實現代碼間的作用域隔離。

你可能會說,我在寫代碼的時候並沒有包裹函數呀,是的的確如此,這一層函數是 node 自動幫我們實現的,我們可以來測試一下。

我們新建一個 js 文件,在第一行打印一個並不存在的變量,比如我們這裏打印 window,在 node 中是沒有 window 的。

console.log(window);
複製代碼

通過 node 執行該文件,會發現報錯信息如下。(請使用系統默認 cmd 執行命令)。

(function (exports, require, module, __filename, __dirname) { console.log(window);
ReferenceError: window is not defined
    at Object.<anonymous> (/Users/choice/Desktop/node/main.js:1:75)
    at Module._compile (internal/modules/cjs/loader.js:689:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:700:10)
    at Module.load (internal/modules/cjs/loader.js:599:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:538:12)
    at Function.Module._load (internal/modules/cjs/loader.js:530:3)
    at Function.Module.runMain (internal/modules/cjs/loader.js:742:12)
    at startup (internal/bootstrap/node.js:279:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:752:3)
複製代碼

可以看到報錯的頂層有一個自執行的函數,, 函數中包含 exports, require, module, __filename, __dirname 這些我們常用的全局變量。

我在之前的《前端模塊化發展歷程》一文中介紹過。自執行函數也是前端模塊化的實現方案之一,在早期前端沒有模塊化系統的時代,自執行函數可以很好的解決命名空間的問題,並且模塊依賴的其他模塊都可以通過參數傳遞進來。cmd 和 amd 規範也都是依賴自執行函數實現的。

在模塊系統中,每個文件就是一個模塊,每個模塊外面會自動套一個函數,並且定義了導出方式 module.exports 或者 exports,同時也定義了導入方式 require。

let moduleA = (function() {
    module.exports = Promise;
    return module.exports;
})();
複製代碼

2.require 加載模塊

require 依賴 node 中的 fs 模塊來加載模塊文件,fs.readFile 讀取到的是一個字符串。

在 javascrpt 中我們可以通過 eval 或者 new Function 的方式來將一個字符串轉換成 js 代碼來運行。

const name = 'yd';
const str = 'const a = 123; console.log(name)';
eval(str); // yd;
複製代碼

new Function 接收的是一個要執行的字符串,返回的是一個新的函數,調用這個新的函數字符串就會執行了。如果這個函數需要傳遞參數,可以在 new Function 的時候依次傳入參數,最後傳入的是要執行的字符串。比如這裏傳入參數 b,要執行的字符串 str。

const b = 3;
const str = 'let a = 1; return a + b';
const fun = new Function('b', str);
console.log(fun(b, str)); // 4
複製代碼

可以看到 eval 和 Function 實例化都可以用來執行 javascript 字符串,似乎他們都可以來實現 require 模塊加載。不過在 node 中並沒有選用他們來實現模塊化,原因也很簡單因爲他們都有一個致命的問題,就是都容易被不屬於他們的變量所影響。

如下 str 字符串中並沒有定義 a,但是確可以使用上面定義的 a 變量,這顯然是不對的,在模塊化機制中,str 字符串應該具有自身獨立的運行空間,自身不存在的變量是不可以直接使用的。

const a = 1;

const str = 'console.log(a)';

eval(str);

const func = new Function(str);
func();
複製代碼

node 存在一個 vm 虛擬環境的概念,用來運行額外的 js 文件,他可以保證 javascript 執行的獨立性,不會被外部所影響。

雖然我們在外部定義了 hello,但是 str 是一個獨立的模塊,並不在村 hello 變量,所以會直接報錯。

// 引入vm模塊, 不需要安裝,node 自建模塊
const vm = require('vm');
const hello = 'yd';
const str = 'console.log(hello)';
wm.runInThisContext(str); // 報錯
複製代碼

所以 node 執行 javascript 模塊時可以採用 vm 來實現。就可以保證模塊的獨立性了。

3.require 代碼實現

介紹 require 代碼實現之前先來回顧兩個 node 模塊的用法,因爲下面會用得到。

用於處理文件路徑。

basename: 基礎路徑, 有文件路徑就不是基礎路徑,基礎路勁是1.js

extname: 獲取擴展名

dirname: 父級路勁

join: 拼接路徑

resolve: 當前文件夾的絕對路徑,注意使用的時候不要在結尾添加/

__dirname: 當前文件所在文件夾的路徑

__filename: 當前文件的絕對路徑

const path = require('path''s');
console.log(path.basename('1.js'));
console.log(path.extname('2.txt'));
console.log(path.dirname('2.txt'));
console.log(path.join('a/b/c''d/e/f')); // a/b/c/d/e/
console.log(path.resolve('2.txt'));
複製代碼

用於操作文件或者文件夾,比如文件的讀寫,新增,刪除等。常用方法有 readFile 和 readFileSync,分別是異步讀取文件和同步讀取文件。

const fs = require('fs');
const buffer = fs.readFileSync('./name.txt''utf8'); // 如果不傳入編碼,出來的是二進制
console.log(buffer);
複製代碼

fs.access: 判斷是否存在,node10 提供的,exists 方法已經被廢棄, 原因是不符合 node 規範,所以我們採用 access 來判斷文件是否存在。

try {
    fs.accessSync('./name.txt');
} catch(e) {
    // 文件不存在
}
複製代碼

4. 手動實現 require 模塊加載器

首先導入依賴的模塊 path,fs, vm, 並且創建一個 Require 函數,這個函數接收一個 modulePath 參數,表示要導入的文件路徑。

// 導入依賴
const path = require('path'); // 路徑操作
const fs = require('fs'); // 文件讀取
const vm = require('vm'); // 文件執行

// 定義導入類,參數爲模塊路徑
function Require(modulePath) {
    ...
}
複製代碼

在 Require 中獲取到模塊的絕對路徑,方便使用 fs 加載模塊,這裏讀取模塊內容我們使用 new Module 來抽象,使用 tryModuleLoad 來加載模塊內容,Module 和 tryModuleLoad 我們稍後實現,Require 的返回值應該是模塊的內容,也就是 module.exports。

// 定義導入類,參數爲模塊路徑
function Require(modulePath) {
    // 獲取當前要加載的絕對路徑
    let absPathname = path.resolve(__dirname, modulePath);
    // 創建模塊,新建Module實例
    const module = new Module(absPathname);
    // 加載當前模塊
    tryModuleLoad(module);
    // 返回exports對象
    return module.exports;
}
複製代碼

Module 的實現很簡單,就是給模塊創建一個 exports 對象,tryModuleLoad 執行的時候將內容加入到 exports 中,id 就是模塊的絕對路徑。

// 定義模塊, 添加文件id標識和exports屬性
function Module(id) {
    this.id = id;
    // 讀取到的文件內容會放在exports中
    this.exports = {};
}
複製代碼

之前我們說過 node 模塊是運行在一個函數中,這裏我們給 Module 掛載靜態屬性 wrapper,裏面定義一下這個函數的字符串,wrapper 是一個數組,數組的第一個元素就是函數的參數部分,其中有 exports,module. Require,__dirname, __filename, 都是我們模塊中常用的全局變量。注意這裏傳入的 Require 參數是我們自己定義的 Require。

第二個參數就是函數的結束部分。兩部分都是字符串,使用的時候我們將他們包裹在模塊的字符串外部就可以了。

Module.wrapper = [
    "(function(exports, module, Require, __dirname, __filename) {",
    "})"
]

複製代碼

_extensions 用於針對不同的模塊擴展名使用不同的加載方式,比如 JSON 和 javascript 加載方式肯定是不同的。JSON 使用 JSON.parse 來運行。

javascript 使用 vm.runInThisContext 來運行,可以看到 fs.readFileSync 傳入的是 module.id 也就是我們 Module 定義時候 id 存儲的是模塊的絕對路徑,讀取到的 content 是一個字符串,我們使用 Module.wrapper 來包裹一下就相當於在這個模塊外部又包裹了一個函數,也就實現了私有作用域。

使用 call 來執行 fn 函數,第一個參數改變運行的 this 我們傳入 module.exports,後面的參數就是函數外面包裹參數 exports, module, Require, __dirname, __filename

Module._extensions = {
    '.js'(module) {
        const content = fs.readFileSync(module.id, 'utf8');
        const fnStr = Module.wrapper[0] + content + Module.wrapper[1];
        const fn = vm.runInThisContext(fnStr);
        fn.call(module.exports, module.exports, module, Require,_filename,_dirname);
    },
    '.json'(module) {
        const json = fs.readFileSync(module.id, 'utf8');
        module.exports = JSON.parse(json); // 把文件的結果放在exports屬性上
    }
}
複製代碼

tryModuleLoad 函數接收的是模塊對象,通過 path.extname 來獲取模塊的後綴名,然後使用 Module._extensions 來加載模塊。

// 定義模塊加載方法
function tryModuleLoad(module) {
    // 獲取擴展名
    const extension = path.extname(module.id);
    // 通過後綴加載當前模塊
    Module._extensions[extension](module);
}
複製代碼

至此 Require 加載機制我們基本就寫完了,我們來重新看一下。Require 加載模塊的時候傳入模塊名稱,在 Require 方法中使用path.resolve(__dirname, modulePath)獲取到文件的絕對路徑。然後通過 new Module 實例化的方式創建 module 對象,將模塊的絕對路徑存儲在 module 的 id 屬性中,在 module 中創建 exports 屬性爲一個 json 對象。

使用 tryModuleLoad 方法去加載模塊,tryModuleLoad 中使用path.extname獲取到文件的擴展名,然後根據擴展名來執行對應的模塊加載機制。

最終將加載到的模塊掛載 module.exports 中。tryModuleLoad 執行完畢之後 module.exports 已經存在了,直接返回就可以了。

// 導入依賴
const path = require('path'); // 路徑操作
const fs = require('fs'); // 文件讀取
const vm = require('vm'); // 文件執行

// 定義導入類,參數爲模塊路徑
function Require(modulePath) {
    // 獲取當前要加載的絕對路徑
    let absPathname = path.resolve(__dirname, modulePath);
    // 創建模塊,新建Module實例
    const module = new Module(absPathname);
    // 加載當前模塊
    tryModuleLoad(module);
    // 返回exports對象
    return module.exports;
}
// 定義模塊, 添加文件id標識和exports屬性
function Module(id) {
    this.id = id;
    // 讀取到的文件內容會放在exports中
    this.exports = {};
}
// 定義包裹模塊內容的函數
Module.wrapper = [
    "(function(exports, module, Require, __dirname, __filename) {",
    "})"
]
// 定義擴展名,不同的擴展名,加載方式不同,實現js和json
Module._extensions = {
    '.js'(module) {
        const content = fs.readFileSync(module.id, 'utf8');
        const fnStr = Module.wrapper[0] + content + Module.wrapper[1];
        const fn = vm.runInThisContext(fnStr);
        fn.call(module.exports, module.exports, module, Require,_filename,_dirname);
    },
    '.json'(module) {
        const json = fs.readFileSync(module.id, 'utf8');
        module.exports = JSON.parse(json); // 把文件的結果放在exports屬性上
    }
}
// 定義模塊加載方法
function tryModuleLoad(module) {
    // 獲取擴展名
    const extension = path.extname(module.id);
    // 通過後綴加載當前模塊
    Module._extensions[extension](module);
}
複製代碼

5. 給模塊添加緩存

添加緩存也比較簡單,就是文件加載的時候將文件放入緩存在,再去加載模塊時先看緩存中是否存在,如果存在直接使用,如果不存在再去重新嘉愛,加載之後再放入緩存。

// 定義導入類,參數爲模塊路徑
function Require(modulePath) {
    // 獲取當前要加載的絕對路徑
    let absPathname = path.resolve(__dirname, modulePath);
    // 從緩存中讀取,如果存在,直接返回結果
    if (Module._cache[absPathname]) {
        return Module._cache[absPathname].exports;
    }
    // 嘗試加載當前模塊
    tryModuleLoad(module);
    // 創建模塊,新建Module實例
    const module = new Module(absPathname);
    // 添加緩存
    Module._cache[absPathname] = module;
    // 加載當前模塊
    tryModuleLoad(module);
    // 返回exports對象
    return module.exports;
}
複製代碼

6. 自動補全路徑

自動給模塊添加後綴名,實現省略後綴名加載模塊,其實也就是如果文件沒有後綴名的時候遍歷一下所有的後綴名看一下文件是否存在。

// 定義導入類,參數爲模塊路徑
function Require(modulePath) {
    // 獲取當前要加載的絕對路徑
    let absPathname = path.resolve(__dirname, modulePath);
    // 獲取所有後綴名
    const extNames = Object.keys(Module._extensions);
    let index = 0;
    // 存儲原始文件路徑
    const oldPath = absPathname;
    function findExt(absPathname) {
        if (index === extNames.length) {
           return throw new Error('文件不存在');
        }
        try {
            fs.accessSync(absPathname);
            return absPathname;
        } catch(e) {
            const ext = extNames[index++];
            findExt(oldPath + ext);
        }
    }
    // 遞歸追加後綴名,判斷文件是否存在
    absPathname = findExt(absPathname);
    // 從緩存中讀取,如果存在,直接返回結果
    if (Module._cache[absPathname]) {
        return Module._cache[absPathname].exports;
    }
    // 嘗試加載當前模塊
    tryModuleLoad(module);
    // 創建模塊,新建Module實例
    const module = new Module(absPathname);
    // 添加緩存
    Module._cache[absPathname] = module;
    // 加載當前模塊
    tryModuleLoad(module);
    // 返回exports對象
    return module.exports;
}
複製代碼

7. 分析實現步驟

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/avaEO9_63kcDTYd8AlGWBA