淺談Node中module的實現原理

xuerensusu發表於2018-05-21

曾幾何時,Javascript還沒那麼牛逼,幾乎所有人都覺得它是用來做網頁特效的指令碼而已。彼時倉促建立出來的javascript的自身缺點被各種吐槽。隨著web的發展,Javascript如今是媳婦熬成婆,應用越來越廣泛。

雖然Javascript自身很努力,但是還是缺乏一項重要功能,那就是模組。畢竟Python有require,PHP有include和require。js通過<script>標籤引入的方式雖說也沒問題,但是缺乏組織和約束,也很難達到安全和易用。所以CommonJS規範的提出簡直就是革命性的。

現在我們就來說說在Node中的CommonJS模組的規範和實現。

淺談Node中module的實現原理

CommonJS的模組規範

CommonJS對模組定義分為三部分:模組定義、模組引用和模組標識。

  1. 模組定義:

首先建立一個a.js的檔案,在裡面寫上:

module.exports = 'hello world';
複製程式碼

在Node中,一個檔案就是一個模組,module.exports物件可以匯出當前模組的方法或者變數。以上的程式碼就是將字串hello world匯出,上下文就提供了require()方法來引入外部模組。

  1. 模組引用:

建立一個b.js的檔案,在裡面寫上:

let b = require('./b.js'); //.js可以不寫,這我們後面會講到
console.log(b); // hello world
複製程式碼

3)模組標識: 模組標識其實就是require()方法裡的引數,它可以以 . 和 .. 開頭的相對路徑,也可以是絕對路徑。可以沒有檔名字尾.js,後面會講到為何可以沒有字尾。


Node的模組實現

上面的程式碼就是最簡單的模組的使用。那麼在我們這幾行簡單的程式碼背後,在實現過程中究竟是什麼樣的過程呢?我們一點點來分析。

Node在引入模組經歷3個步驟:路徑分析、檔案定位和編譯執行

在Node中模組有兩類:一類是Node提供的核心模組,另一類是使用者自己編寫的檔案模組。

部分的核心模組直接載入在記憶體中,所以引入這部分模組時,檔案定義和編譯執行都可以省略,在路徑分析中優先判斷,載入速度也是最快的。

另外需要知曉的是,Node引入的模組都會進行快取,以減少二次引用時的開銷。它快取的是編譯和執行後的物件,而不是和瀏覽器一樣,快取的是檔案。


  1. 路徑分析 上面已經說了,require()方法裡引數叫模組標識,路徑分析其實就是基於識別符號來查詢的。模組識別符號在Node中分為以下幾類:

    • 核心模組,比如http、fs、path等。
    • . 或 .. 開始的相對路徑檔案模組。
    • 以/開始的絕對路徑檔案模組。
    • 非路徑形式的檔案模組,如自定義的connect模組。
  • 核心模組的優先順序僅次於快取載入,由於在Node原始碼編譯中已經被編譯為二進位制程式碼,所以載入過程是最快的。

  • 相對路徑檔案模組在分析路徑時,require()方法會將路徑轉為真實路徑,並以絕對路徑最為索引,將編譯執行後的結果放入快取,以使二次引用時載入更快。

  • 自定義模組是特殊的檔案模組,可能是檔案或者包的形式,也是查詢最慢的一種模組。

Node在定位檔案模組的具體檔案時制定的查詢策略可以表現為一個路徑陣列。

在js檔案中console.log(module.paths);
放到任意目錄中執行;
就會得到類似以下的陣列:

[ '/Users/lq/Desktop/node_modules', //當前檔案目錄下的node_modules目錄
  '/Users/lq/node_modules',//父目錄下的node_modules目錄
  '/Users/node_modules',//父目錄的父目錄下的node_modules目錄
  '/node_modules' ]//沿路徑向上逐級遞迴,直到找到根目錄下的node_modules目錄
複製程式碼

當前檔案的路徑越深,模組查詢就越耗時,這是自定義模組載入慢的原因。

  1. 檔案定位

require()在分析識別符號的時候,可能會出現沒有傳遞檔案擴充名的情況。CommonJS模組規範是允許這種情況出現的,不過Node會按照.js、.json、.node的順序補足擴充名。依次呼叫fs模組同步阻塞式地判斷檔案是否存在。

在這個過程中,Node對CommonJS模組規範進行了一定的支援。首先,Node會在當前目錄下查詢包描述檔案package.json,通過JSON.parse()解析出包描述物件,取出main屬性指定的檔名進行定位,如果缺少擴充名,就進入上面說的擴充名分析步驟,按順序補足擴充名再查詢。如果main屬性指定的檔名錯誤或者沒有包描述檔案package.json,Node會將index當作預設檔名,依次查詢index.js、index.json、index.node。如果所有的路徑陣列都遍歷了,還是沒有找到,就會丟擲錯誤。

  1. 模組編譯 在定位到具體的檔案後,Node會新建一個模組物件,然後根據路徑進行編譯。根據不同的擴充名操作不同的方法。
  • 如果是.js檔案,通過fs模組同步讀取檔案後編譯執行
  • 如果是.json檔案,通過fs模組同步讀取檔案後,用JSON.parse()解析返回結果
  • 如果是.node檔案,通過dlopen()方法載入最後編譯生成的檔案。這是C/C++編寫的擴充檔案,本人在後面實現原理的過程中予以忽略。

說了這麼多,下面直接進入實現環節

先建立一個a.js檔案,寫入:

module.exports = 'hello world';
複製程式碼

再建立一個b.js,寫入:

let b = require('./a.js');
console.log(b); //hello world
複製程式碼

列印的結果是hello wrold。這是Node自帶的require方法。現在我們來實現下我們自己的require方法。

我們直接在b.js裡修改下:

//引入Node的核心模組
let fs = require('fs');
let path = require('path');
let vm = require('vm');

function Module(p) {
    this.id = p; //當前模組的標識,也就是絕對路徑
    this.exports = {}; //每個模組都有exports屬性,新增一個
    this.loaded = false; //是否已經載入完
}
//對檔案內容進行頭尾包裝
Module.wrapper = ['(function(exports,require,module){', '})']

//所有的載入策略
Module._extensions = {
    '.js': function (module) { //讀取js檔案,增加一個閉包
        let script = fs.readFileSync(module.id, 'utf8');
        let fn = Module.wrapper[0] + script + Module.wrapper[1];//包裝在一個閉包裡
        vm.runInThisContext(fn).call(module.exports, module.exports, myRequire, module);//通過runInThisContext()方法執行不汙染全域性
        return module.exports;

    },
    '.json': function (module) {
        return JSON.parse(fs.readFileSync(module.id, 'utf8')); //讀取檔案
    }
}

Module._cacheModule = {} //存放快取

Module._resolveFileName = function (moduleId) { //根據傳入的路徑引數返回一個絕對路徑的方法
    let p = path.resolve(moduleId);
    if (!path.extname(moduleId)) { //如果沒有傳檔案字尾
        let arr = Object.keys(Module._extensions); //將物件的key轉成陣列
        for (let i = 0; i < arr.length; i++) { //循壞陣列新增字尾
            let file = p + arr[i];
            try {
                fs.accessSync(file); //檢視檔案是否存在,存在的就返回
                return file;
            } catch (e) {
                console.log(e); //不存在報錯
            }
        }
    } else {
        return p; //如果已經傳遞了檔案字尾,直接返回絕對路徑
    }
}

Module.prototype.load = function (filepath) { //模組載入的方法
    let ext = path.extname(filepath);
    let content = Module._extensions[ext](this);
    return content;
}

function myRequire(moduleId) { //自定義的myRequire方法
    let p = Module._resolveFileName(moduleId); //將傳遞進來的模組標示轉成絕對路徑
    if (Module._cacheModule[p]) { //如果模組已經存在
        return Module._cacheModule[p].exports; //直接返回編譯和執行之後的物件
    }
    let module = new Module(p); //模組不存在,先建立一個新的模組物件
    let content = module.load(p); //模組載入後的內容
    Module._cacheModule[p] = module;
    module.exports = content;
    return module.exports;
}

let b = myRequire('./a.js');
console.log(b);
複製程式碼

這樣就可以通過自己的myRequire()方法拿到a.js裡的字串hello world了。當然,module的原始碼不止這麼多,有興趣的可以自己檢視。本文只是說明下module載入的原理。有寫的不夠嚴謹的地方,望諒解。如有錯漏,可指出,定及時修改。

參考

部分內容根據《深入淺出Node.js》一書整理

相關文章