本文会模拟实现nodejs中一个简版的导入本地模块的方法,主要用来理解node实现commonjs规范的思想。欢迎拍砖指正。
nodejs中的模块化,通过module.exports导出,通过require函数导入。在node中,每一个文件都是一个模块,我们可以抽象理解为每一个module都是Module这个类的一个实例。
所以实现nodejs中的模块化,其实就是要实现一个Module类和一个require方法
Module类
module的特点:
-
每一个文件都是一个
module,每一个module都对应一个文件的绝对路径。 -
每一个module上,都有一个exports对象。
所以一个Module类大概长这样:
function Module(id) {
this.id = id; // 文件路径
this.exports = {}; // 如果不导出内容,module.exports默认是一个空对象
}
require方法
整体来说,require方法的实现分为三步
- 获取文件路径,找到对应模块
- 加载模块,读取模块内容
- 返回模块导出的内容
根据以上三步,我们定义一个自己的导入方法:
function req (filename) {
// 第一步,通过指定模块的路径filename,找到对应模块
let module = new Module(filename);
// 第二步,加载模块
module.load();
// 返回导出的内容
return module.exports
}
在使用原生require方法时,有可能传入的是一个相对路径,有可能不带扩展名,所以直接将传入的路径作为模块的文件路径不靠谱,我们接下来解决这两个问题。
1、处理相对路径的问题
const path = require('path')
// 在Module上定义一个处理方法
Module.resolveFileName = function (filename) {
// 获取绝对路径
let absPath = path.resolve(__dirname, filename);
// 判断路径是否存在
let flag = fs.existsSync(absPath);
if (!flag) {
throw Error('文件不存在');
}
return absPath
}
2、处理可能不带扩展名的问题
node中扩展名为.js和.json的文件都可以作为模块导入,并且导入时的处理逻辑不一样,所以我们既要处理扩展名,也要对这两种文件类型的导入做区分处理。
我们在Module上挂一个对象,用来放可能的扩展名和对应的处理方法:
Module.extensions = {
'.js' () {
},
'.json' () {
}
}
那么,上面的resolveFileName和req会变成:
const path = require('path')
Module.resolveFileName = function (filename) {
// 获取绝对路径
let absPath = path.resolve(__dirname, filename);
// 判断路径是否存在
let flag = fs.existsSync(absPath);
let current = absPath;
if (!flag) { // 有可能是因为没有扩展名才没找到
let keys = Object.keys(Module.extensions);
for (let i = 0; i < keys.length; i++) {
current = absPath + keys[i]
// 加上扩展名再找一遍
let flag = fs.existsSync(current);
if (flag) {
// 找到就退出
break
} else {
// 还是没找到
current = null
}
}
}
if (!current) {
throw Error('文件不存在');
}
// 返回模块的路径
return current
}
function req (filename) {
let absPath = Module.resolveFileName(filename);
let module = new Module(absPath);
module.load();
return module.exports
}
到这里,我们算完成了req方法的第一步,第二步就是加载模块了,也就是module.load方法。
像上面说的一样,模块有.js类型的也有.json类型的,所以其实load方法很简单,调用对应扩展名的方法去处理模块就可以了。
Module.prototype.load = function () {
//获取文件的扩展名
let ext = path.extname(this.id);
//将当前module传过去,根据当前模块的扩展名,调用对应的方法
Module.extensions[ext](this)
}
那么究竟扩展名对应的方法是怎么处理的呢?
我们先看导入.json文件的方法
// .json模块的导入,只需将.json文件中的内容,读取过来就行了
const fs = require('fs');
Module.extensions = {
'.js' () {
},
'.json' (module) {
let script = fs.readFileSync(module.id, 'utf8');
module.exports = JSON.parse(script)
}
}
接下来处理.js文件。
我们都知道,nodejs中的模块化实现了commonjs规范,那它是通过什么方式来实现的呢?其实很简单,在文件的内容外面包裹一个匿名函数。
具体来说:
- 读取模块内容
- 将内容包裹在一个匿名函数中
- 执行这个匿名函数,也就相当于执行了模块中的代码
- 将模块中赋值给
module.exports的内容返回
这里最主要的就是第2、3步。
举例来说,有一个模块a.js,里面的内容是module.exports = 'hello world',有一个模块b.js,在b.js中实例化Module,得到一个module对象,接着在b.js里,把a.js中的那一行代码读取过来,然后在外面包裹上一个匿名函数,并传入一些参数,如刚刚实例化的module等等,然后得到一个函数:
(function (module) {
module.exports = 'hello world'
})
执行这个函数,这时候,module.exports = 'hello world'这行代码的意思就相当于,将传入的module下的exports属性赋值为hello
执行完成后,我们再将module.exports作为返回值return,如此一来,在b.js中,我们就获取到了a.js中导出的内容了。
当然,这里面还有一些问题,比如,读取a.js的内容,返回的是字符串,怎么才能变成函数并且执行呢?
这个问题的答案分为两步:
-
既然返回的是字符串,那么就采用字符串拼接的方式,拼成一个“函数”字符串
// 下面实际是一个字符串,仿佛函数执行了toString()一样 ` (function(module, exports, require, __filename, __dirname) { module.exports = 'hello world' }) ` -
将“函数”字符串变成一个真正的能执行的函数,这里需要用到
vm模块
下面用代码实现:
const path = require('path')
const fs = require('fs');
const vm = require('vm')
// 一个模块中,为什么可以直接取到module, exports, require, __filename, __dirname这些值?
// 其实是因为包裹在模块外面的匿名函数在执行时将这些都作为参数传过去了
Module.wrapper = [ // 为了拼接出来的函数好看点,加上\r\n换个行
'(function(module, exports, require, __filename, __dirname) {\r\n',
'\r\n})'
]
Module.extensions = {
'.js' (module) {
// 首先读取模块的内容,读出来的其实是字符串
let script = fs.readFileSync(module.id, 'utf8');
// 将模块内容,拼接成一个匿名函数的字符串形式
let fnStr = Module.wrapper[0] + script + Module.wrapper[1];
// 这里借助vm模块,将函数字符串转变为真正的函数
let fn = vm.runInThisContext(fnStr);
// 最后执行函数,根据顺序传入参数
fn.call(
module.exports,// node中,模块里的this,指向的是module.exports,所以这里call一下,改变this的指向
module, // 当前模块
module.exports,
req, // require方法,模块可以直使用这个函数,导入其他模块
module.id, // 当前模块的绝对路径
path.dirname(module.id) // 当前模块的文件夹路径
);
},
'.json' (module) {
let script = fs.readFileSync(module.id, 'utf8');
module.exports = JSON.parse(script)
}
}
到这里两种模块的导入就算是都实现了。
其实最后还剩一个问题,就是重复导入的时候,我们知道,被导入的模块只会执行一次。这是因为node内部做了缓存。我们也可以在req方法中模拟一个缓存:
Module.cache = {}
function req (filename) {
let absPath = Module.resolveFileName(filename);
// 如果缓存里已经有这个模块了,就直接返回module.exports的内容,不再重复执行
if (Module.cache[absPath]) {
return Module.cache[absPath].exports
}
let module = new Module(absPath);
// 将当前模块存到缓存里
Module.cache[absPath] = module;
module.load();
return module.exports
}
下面是完整代码:
function Module (id) {
this.id = id;
this.exports = {}
}
Module.wrapper = [
'(function(module, exports, require, __filename, __dirname) {\r\n',
'\r\n})'
]
Module.extensions = {
'.js' (module) {
let script = fs.readFileSync(module.id, 'utf8');
let fnStr = Module.wrapper[0] + script + Module.wrapper[1];
let fn = vm.runInThisContext(fnStr);
fn.call(module.exports, module, module.exports, req, module.id, path.dirname(module.id))
},
'.json' (module) {
let script = fs.readFileSync(module.id, 'utf8');
module.exports = JSON.parse(script)
}
}
Module.prototype.load = function () {
let ext = path.extname(this.id);
Module.extensions[ext](this)
}
Module.resolveFileName = function (filename) {
let absPath = path.resolve(__dirname, filename);
let flag = fs.existsSync(absPath);
let current = absPath;
if (!flag) {
let keys = Object.keys(Module.extensions);
for (let i = 0; i < keys.length; i++) {
current = absPath + keys[i]
let flag = fs.existsSync(current);
if (flag) {
break
} else {
current = null
}
}
}
if (!current) {
throw Error('文件不存在');
}
return current
}
Module.cache = {}
function req (filename) {
let absPath = Module.resolveFileName(filename);
if (Module.cache[absPath]) {
return Module.cache[absPath].exports
}
let module = new Module(absPath);
Module.cache[absPath] = module;
module.load();
return module.exports
}
实现过程有一些不严谨的地方,就像开头所说,主要是为了理解思想。嘿嘿,欢迎各位拍砖指正。