实现“乞丐版”的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源码中实现的版原本交差。

相关文章
相关标签/搜索