實現「乞丐版」的CommonJS模塊加載

概述

有什麼用?

最近看到「乞丐版」的Promise實現,因此想實現一個「乞丐版」的CommonJS規範的模塊加載。但願由此:html

  • 完全理解CommonJS規範;
  • 爲其它環境(如QuickJs)提供模塊加載功能,以複用npm上的模塊包;
  • 給其餘前端面試官增長一道面試題(手動黑人問號臉);

規範說明

CommonJS規範相信你們都不陌生,Node.js正是由於實現了CommonJS規範,纔有了模塊加載能力,和在此基礎上出現的蓬勃的生態。簡言之:前端

  1. 每一個文件就是一個模塊,有本身的做用域。模塊經過exportsmodule.exports對外暴露方法、屬性或對象。
  2. 模塊能夠被其它模塊經過require引用,若是屢次引用同一個模塊,會使用緩存而不是從新加載。
  3. 模塊被按需加載,沒被使用的模塊不會被加載。

若是還不熟悉CommonJS規範,建議先閱讀CommonJS ModulesNode.js Modules文檔說明。node

核心實現

初始化模塊

首先,咱們初始化一個自定義的Module對象,用於包裝文件對應的模塊。git

class Module {
    constructor(id) {
        this.id = id;
        this.filename = id;
        this.loaded = false;
        this.exports = {};
        this.children = [];
        this.parent = null;

        this.require = makeRequire.call(this);
    }
}
複製代碼

這裏主要講解下this.exportsthis.require,其它屬性主要都是用於輔助模塊的加載。github

this.exports保存的是文件解析出來的模塊對象(你能夠認爲就是你在模塊文件中定義的module.exports)。它在初始化的時候是個空對象,這也能說明爲何在循環依賴(circular require)時,在編譯階段取不到目標模塊屬性的值。舉個小板慄:面試

// a.js
const b = require('./b');

exports.value = 'a';

console.log(b.value);   // a.value: undefined
console.log(b.valueFn()); // a.value: a
複製代碼
// b.js
const a = require('./a');

exports.value = `a.value: ${a.value}`;  // 編譯階段,a.value === undefined

exports.valueFn = function valueFn() {
    return `a.value: ${a.value}`;       // 運行階段,a.value === a
};
複製代碼

this.require是用於模塊加載的方法(就是你在模塊代碼中用的那個require),經過它咱們能夠加載模塊依賴的其它子模塊。npm

實現require

接下來咱們看下require的實現。json

咱們知道,當咱們使用相對路徑require一個模塊時,其相對的是當前模塊的__dirname,這也就是爲何咱們須要爲每一個模塊都定義一個獨立的require方法的緣由。api

const cache = {};

function makeRequire() {
    const context = this;
    const dirname = path.dirname(context.filename);
    
    function resolve(request) {}

    function require(id) {
        const filename = resolve(id);

        let module;
        if (cache[filename]) {
            module = cache[filename];
            if (!~context.children.indexOf(module)) {
                context.children.push(module);
            }
        } else {
            module = new Module(filename);
            (module.parent = context).children.push(module);
            (cache[filename] = module).compile();
        }

        return module.exports;
    }

    require.cache = cache;
    require.resolve = resolve;

    return require;
}
複製代碼

注意這裏執行的前後順序:緩存

  1. 先從一個全局緩存cache裏面查找目標模塊是否已存在?查找依據是模塊文件的完整路徑。
  2. 若是不存在,則使用模塊文件的完整路徑實例化一個新的Module對象,同時推入父模塊的children中。
  3. 將第2步建立的module對象存入cache中。
  4. 調用第2步建立的module對象的compile方法,此時模塊代碼纔會真正被解析和執行。
  5. 返回module.exports,即咱們在模塊中對外暴露的方法、屬性或對象。

第3和第4的順序很重要,若是這兩步反過來了,則會致使在循環依賴(circular require)時進入死循環。

文件路徑解析

上述代碼中有一個require.resolve方法,用於解析模塊完整文件路徑。正是這個方法,幫助咱們找到了千千萬萬的模塊,而不須要每次都寫完整的路徑。

Node.js官方文檔中,用完善的僞代碼描述了該查找過程:

require(X) from module at path Y
1. If X is a core module,
   a. return the core module
   b. STOP
2. If X begins with '/'
   a. set Y to be the filesystem root
3. If X begins with './' or '/' or '../'
   a. LOAD_AS_FILE(Y + X)
   b. LOAD_AS_DIRECTORY(Y + X)
   c. THROW "not found"
4. LOAD_NODE_MODULES(X, dirname(Y))
5. THROW "not found"
複製代碼

對應的實現代碼:

const coreModules = { os, }; // and other core modules
const extensions = ['', '.js', '.json', '.node'];
const NODE_MODULES = 'node_modules';
const REGEXP_RELATIVE = /^\.{0,2}\//;

function resolve(request) {
    if (coreModules[request]) {
        return request;
    }

    let filename;
    if (REGEXP_RELATIVE.test(request)) {
        let absPath = path.resolve(dirname, request);
        filename = loadAsFile(absPath) || loadAsDirectory(absPath);
    } else {
        filename = loadNodeModules(request, dirname);
    }

    if (!filename) {
        throw new Error(`Can not find module '${request}'`);
    }

    return filename;
}
複製代碼

若是對如何從目錄、文件或node_modules中查找的過程感興趣,請看後面的完整代碼。這些過程也是根據Node.js官方文檔中的僞代碼實現的。

編譯模塊

最後,咱們須要把文件中的代碼編譯成JS環境中真正可執行的代碼。

function compile() {
    const __filename = this.filename;
    const __dirname = path.dirname(__filename);

    let code = fs.readFile(__filename);
    if (path.extname(__filename).toLowerCase() === '.json') {
        code = 'module.exports=' + code;
    }
    const wrapper = new Function('exports', 'require', 'module', '__filename', '__dirname', code);
    wrapper.call(this, this.exports, this.require, this, __filename, __dirname);

    this.loaded = true;
}
複製代碼

compile方法中,咱們主要作了:

  1. 使用文件IO讀取代碼文本內容。
  2. 提供對json格式文件的支持。
  3. 使用new Function生成一個方法。
  4. modulemodule.exportsrequire__dirname__filename做爲參數,執行該方法
  5. loaded標記爲true

完整代碼

這裏完整的實現了一個能夠運行在QuickJs引擎之上的CommonJS模塊加載器。QuickJs引擎實現了ES6的模塊加載功能,可是沒有提供CommonJS模塊加載的功能。

固然,若是你真的在面試的時候遇到了這個問題,建議仍是拿Node.js源碼中實現的版原本交差。

相關文章
相關標籤/搜索