CommonJS modules in Node.js

Introduction

最初的 JavaScript 设计时没有模块化相关的概念,但是随着前端的发展及其工程化的引进和借鉴,相关的模块化思想也被引入了前端领域,其中CommonJS的模块化解决方案一直屹立不倒。为了更好的理解CommonJS,本篇文章依据Module:CommonJS modules相关文档,借鉴loader.js的相关源码,自己手动实现一个CommonJS module;

The Module Wrapper

每个被 require 的模块,在执行之前都被一个函数包裹,类似于:

1
2
3
(function (exports, require, module, __filename, __dirname) {
//Module code actually lives in here
});

这样做可以防止模块内的定义的变量提升到全局作用域,同时提供了moduleexports等变量供当前模块使用

What require() does:

require()加载模块的顺序具体可以参考这段伪代码,简单概括如下:

  1. 优先加载诸如path,fs等核心模块
  2. 先从缓存中找对应的模块是否已加载,没有则进行下一步
  3. 加载对应的以/, ./, ../开头的文件,如果不带文件后缀,则按照.js, .json,.node依次尝试,有的话加载对应的文件内容
  4. 加载对应的以/, ./, ../开头的文件夹,先查找文件夹下的package.json,如果package.json定义了”main”,则加载”main”对应的内容,没有则寻找对应的文件夹下的 index 文件,然后按照第三步的加载文件顺序依次查找并加载
  5. 加载对应的node_modules文件夹下的内容
  6. 抛出”not found”错误

对于最新版本的Node.js则增加了ECMAScript modules相关的加载规则,有兴趣的可以了解一下。

Module Caching

在加载模块时,require()会先尝试从缓存中获取对应的 module,对应的每个缓存值的数据结构类似于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
id: '/xbrave/debug/foo.js',
path: '/xbrave/debug',
exports: {},
parent: Module {
id: '.',
path: '/xbrave/debug',
exports: {},
parent: null,
filename: '/xbrave/debug/index.js',
loaded: false,
children: [Array],
paths: [Array]
},
filename: '/xbrave/debug/foo.js',
loaded: true,
}

可以理解为模块的缓存是以文件的绝对路径形式为 key 值,将对应的 module 进行缓存。这种形式的缓存,使得 module 的表现类似于单例模式。但是需要注意的是:

  1. 对于大小写不敏感(case-insensitive)的文件系统或者操作系统(比如说 windows 操作系统),虽然require('./foo')require('./FOO')实际上是加载的同一个文件的内容,但是加载的结果是两个不同的结果.
  2. 加载node_modules文件夹下的内容,同样的require('foo')可能返回不同的结果,具体取决于require()时的文件路径

module.exports VS exports

初学者可能会对两者有所误解,只需要记住,exports最初和module.exports指向的是同一个对象,而最终暴露给其它模块使用的是module.exports

Build Your Own CommonJS Module

有了上述的一些基础和概述,我们尝试简单实现一下一个简单的CommonJS Module

MyOwnModule Class

参考上述Module Caching所描述的 module 结构和源码我们先定义一个 MyOwnModule 类:

1
2
3
4
5
6
7
8
9
const path = require('path');

function MyOwnModule(id = '') {
this.id = id;
this.path = path.dirname(id);
this.exports = {};
this.filename = null;
this.loaded = false;
}

这里省略了源码里 parent 和 children 相关的属性

MyOwnModule.prototype.require()

require()的作用和加载过程我们已经有所了解,中间涉及到一个缓存对象,这里我们定义为:

1
MyOwnModule._cache = Object.create(null);

然后require内部调用相关的_load()方法, 主要做的工作是:

  1. 通过 _resolveFilename()方法获取相应的文件绝对路径
  2. 然后从缓存中找模块是否已加载,加载了直接返回对应的缓存内容,否则新生成一个 Module 实例,再返回对应的加载结果:
1
2
3
4
5
6
7
8
9
10
11
MyOwnModule.prototype.require = function (id) {
return MyOwnModule._load(id);
};
MyOwnModule._load = function (request) {
const filename = MyOwnModule._resolveFilename(request);
const cachedModule = MyOwnModule._cache[filename];
if (cachedModule !== undefined) return cachedModule.exports;
const module = new MyOwnModule(filename);
module.load(filename);
return module.exports;
};

MyOwnModule._resolveFilename()

_resolveFilename的方法在源码中比较复杂,这里我们简单实现一下,主要做的工作和之前说到加载顺序一致:

  1. 获取文件的 resolve 路径,并检测有没有文件后缀
  2. 如果有后缀直接返回,没有的按照_extensions定义的顺序依次寻找,找到的直接返回对应的路径
1
2
3
4
5
6
7
8
9
10
11
12
MyOwnModule._resolveFilename = function (request) {
const filename = path.resolve(request);
const ext = path.extname(filename);
if (!ext) {
const extensions = Object.keys(MyOwnModule._extensions);
for (let i = 0; i <= extensions; i++) {
const currentFile = `${filename}${extensions[i]}`;
if (fs.existsSync(currentFile)) return currentFile;
}
}
return filename;
};

MyOwnModule.prototype.load()

load()方法主要是根据对应的文件后缀依次处理文件内容,然后将当前 Module 的内部属性loaded置为 true。

1
2
3
4
5
MyOwnModule.prototype.load = function (filename) {
const extname = path.extname(filename);
MyOwnModule._extensions[extname](this, filename);
this.loaded = true;
};

我们平常开发中一般涉及到的是.js.json的文件后缀,我们分别对两个文件后缀的内容进行处理

vm.runInThisContext(code[, options])理解

官方释义:

vm.runInThisContext() compiles code, runs it within the context of the current global and returns the result. Running code does not have access to local scope, but does have access to the current global object.

官方 demo:

1
2
3
4
5
6
7
8
const vm = require('vm');
let localVar = 'initial value';

const vmResult = vm.runInThisContext('localVar = "vm";');
console.log(`vmResult: '${vmResult}', localVar: '${localVar}'`);

const evalResult = eval('localVar = "eval";');
console.log(`evalResult: '${evalResult}', localVar: '${localVar}'`);

.js文件后缀

.js文件后缀的处理,主要是调用了vm.runInThisContext方法将拼接好的字符串代码转换为实际代码,并在最外层包裹了一层,将对应的五个变量注入了模块内部。其中vm.runInThisContext此方法用于创建一个独立的沙箱运行空间,code 内的代码可以访问外部的global对象,但是不能访问其他变量,类似于new Function()。相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
MyOwnModule._extensions = Object.create(null);
MyOwnModule._extensions['.js'] = function (module, filename) {
const fileContent = fs.readFileSync(filename, 'utf-8');
module._compile(fileContent, filename);
};

MyOwnModule.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});',
];

MyOwnModule.prototype._compile = function (content, filename) {
const wrappedContent =
MyOwnModule.wrapper[0] + content + MyOwnModule.wrapper[1];
const compiler = vm.runInThisContext(wrappedContent, {
filename,
lineOffset: 0,
displayErrors: true,
});
const dirname = path.dirname(filename);
compiler.call(
this.exports,
this.exports,
this.require,
this,
filename,
dirname
); //将this指向this.exports,并赋值相应的五个变量供模块内使用
};

.json文件后缀

.json文件后缀的文件处理就比较简单了,读取文件内容,然后调用JSON.parse()方法返回处理之后的结果:

1
2
3
4
MyOwnModule._extensions['.json'] = function (module, filename) {
const content = fs.readFileSync(filename, 'utf-8');
module.exports = JSON.parse(content);
};

Overall

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
const path = require('path');
const fs = require('fs');
const vm = require('vm');

function MyOwnModule(id = '') {
this.id = id;
this.path = path.dirname(id);
this.exports = {};
this.filename = null;
this.loaded = false;
}

MyOwnModule._cache = Object.create(null);
MyOwnModule._extensions = Object.create(null);

MyOwnModule.prototype.require = function (id) {
return MyOwnModule._load(id);
};

MyOwnModule._load = function (request) {
const filename = MyOwnModule._resolveFilename(request);
const cachedModule = MyOwnModule._cache[filename];
if (cachedModule !== undefined) return cachedModule.exports;
const module = new MyOwnModule(filename);
MyOwnModule._cache[filename] = module;
module.load(filename);
return module.exports;
};

MyOwnModule._resolveFilename = function (request) {
const filename = path.resolve(__dirname, request);
const ext = path.extname(filename);
if (!ext) {
const extensions = Object.keys(MyOwnModule._extensions);
for (let i = 0; i <= extensions; i++) {
const currentFile = `${filename}${extensions[i]}`;
if (fs.existsSync(currentFile)) return currentFile;
}
}
return filename;
};

MyOwnModule.prototype.load = function (filename) {
const extname = path.extname(filename);
MyOwnModule._extensions[extname](this, filename);
this.loaded = true;
};

MyOwnModule._extensions['.js'] = function (module, filename) {
const fileContent = fs.readFileSync(filename, 'utf-8');
module._compile(fileContent, filename);
};

MyOwnModule.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});',
];

MyOwnModule.prototype._compile = function (content, filename) {
const wrappedContent =
MyOwnModule.wrapper[0] + content + MyOwnModule.wrapper[1];
const compiler = vm.runInThisContext(wrappedContent, {
filename,
lineOffset: 0,
displayErrors: true,
});
const dirname = path.dirname(filename);
compiler.call(
this.exports,
this.exports,
this.require,
this,
filename,
dirname
);
};

MyOwnModule._extensions['.json'] = function (module, filename) {
const fileContent = fs.readFileSync(filename, 'utf-8');
module.exports = JSON.parse(fileContent);
};

Summary

  1. Node.jsCommonJS module并没有什么特别的地方,主要难点在于对于.js文件的处理方式和vm.runInThisContext的理解
  2. 每个模块对应的exports,require,module, __filename, __dirname都不是全局变量,而是模块加载的时候注入的

Reference

  1. Module:CommonJS modules official document
  2. Node.js loader.js Source Code

Comments