实现“乞丐版”的CommonJS模块加载

1,832

概述

有什么用?

最近看到“乞丐版”的Promise实现,所以想实现一个“乞丐版”的CommonJS规范的模块加载。希望由此:

  • 彻底理解CommonJS规范;
  • 为其它环境(如QuickJs)提供模块加载功能,以复用npm上的模块包;
  • 给其他前端面试官增加一道面试题(手动黑人问号脸);

规范说明

CommonJS规范相信大家都不陌生,Node.js正是因为实现了CommonJS规范,才有了模块加载能力,和在此基础上出现的蓬勃的生态。简言之:

  1. 每个文件就是一个模块,有自己的作用域。模块通过exportsmodule.exports对外暴露方法、属性或对象。
  2. 模块可以被其它模块通过require引用,如果多次引用同一个模块,会使用缓存而不是重新加载。
  3. 模块被按需加载,没被使用的模块不会被加载。

如果还不熟悉CommonJS规范,建议先阅读CommonJS ModulesNode.js Modules文档说明。

核心实现

初始化模块

首先,我们初始化一个自定义的Module对象,用于包装文件对应的模块。

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,其它属性主要都是用于辅助模块的加载。

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),通过它我们可以加载模块依赖的其它子模块。

实现require

接下来我们看下require的实现。

我们知道,当我们使用相对路径require一个模块时,其相对的是当前模块的__dirname,这也就是为什么我们需要为每个模块都定义一个独立的require方法的原因。

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源码中实现的版本来交差。