最近看到“乞丐版”的Promise
实现,因此想实现一个“乞丐版”的CommonJS
规范的模块加载。但愿由此:html
CommonJS
规范;npm
上的模块包;CommonJS
规范相信你们都不陌生,Node.js
正是由于实现了CommonJS
规范,才有了模块加载能力,和在此基础上出现的蓬勃的生态。简言之:前端
exports
或module.exports
对外暴露方法、属性或对象。require
引用,若是屡次引用同一个模块,会使用缓存而不是从新加载。若是还不熟悉CommonJS
规范,建议先阅读CommonJS Modules和Node.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.exports
和this.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
的实现。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;
}
复制代码
注意这里执行的前后顺序:缓存
cache
里面查找目标模块是否已存在?查找依据是模块文件的完整路径。Module
对象,同时推入父模块的children
中。module
对象存入cache
中。module
对象的compile
方法,此时模块代码才会真正被解析和执行。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
方法中,咱们主要作了:
json
格式文件的支持。new Function
生成一个方法。module
、module.exports
、require
、__dirname
、__filename
做为参数,执行该方法loaded
标记为true
。这里完整的实现了一个能够运行在QuickJs引擎之上的CommonJS
模块加载器。QuickJs引擎实现了ES6
的模块加载功能,可是没有提供CommonJS
模块加载的功能。
固然,若是你真的在面试的时候遇到了这个问题,建议仍是拿Node.js
源码中实现的版原本交差。