概述
有什么用?
最近看到“乞丐版”的Promise
实现,所以想实现一个“乞丐版”的CommonJS
规范的模块加载。希望由此:
- 彻底理解
CommonJS
规范; - 为其它环境(如QuickJs)提供模块加载功能,以复用
npm
上的模块包; - 给其他前端面试官增加一道面试题(手动黑人问号脸);
规范说明
CommonJS
规范相信大家都不陌生,Node.js
正是因为实现了CommonJS
规范,才有了模块加载能力,和在此基础上出现的蓬勃的生态。简言之:
- 每个文件就是一个模块,有自己的作用域。模块通过
exports
或module.exports
对外暴露方法、属性或对象。 - 模块可以被其它模块通过
require
引用,如果多次引用同一个模块,会使用缓存而不是重新加载。 - 模块被按需加载,没被使用的模块不会被加载。
如果还不熟悉CommonJS
规范,建议先阅读CommonJS Modules和Node.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.exports
和this.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;
}
注意这里执行的先后顺序:
- 先从一个全局缓存
cache
里面查找目标模块是否已存在?查找依据是模块文件的完整路径。 - 如果不存在,则使用模块文件的完整路径实例化一个新的
Module
对象,同时推入父模块的children
中。 - 将第2步创建的
module
对象存入cache
中。 - 调用第2步创建的
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
方法中,我们主要做了:
- 使用文件IO读取代码文本内容。
- 提供对
json
格式文件的支持。 - 使用
new Function
生成一个方法。 - 将
module
、module.exports
、require
、__dirname
、__filename
作为参数,执行该方法 - 将
loaded
标记为true
。
完整代码
这里完整的实现了一个可以运行在QuickJs引擎之上的CommonJS
模块加载器。QuickJs引擎实现了ES6
的模块加载功能,但是没有提供CommonJS
模块加载的功能。
当然,如果你真的在面试的时候遇到了这个问题,建议还是拿Node.js
源码中实现的版本来交差。