Fork me on GitHub

JS-CommonJS

前端模块化——CommonJS

特点

  • 所有代码都运行在模块作用域,不会污染全局作用域
  • 模块可以多次加载,但是只会在第一次加载运行,然后运行结果被缓存,以后的加载就直接读取缓存,要想模块再次运行,必须清除缓存。
  • 模块加载的顺序,按照其在代码中出现的顺序。

每个文件就是一个模块,有自己的作用域,在一个文件中的变量、函数、类都是私有的对其他文件不可见。
如果多个文件分享变量,必须定义为global对象的属性。

1
global.warning = true;

上面代码的 warning 变量,可以被所有文件读取。当然,这样写法是不推荐的。

Node.js 是 commonJS 规范的主要实践者,它有四个重要的环境变量为模块化的实现提供支持:moduleexportsrequireglobal
用 module.exports 定义当前模块对外输出的接口(不推荐直接用 exports),用 require 加载模块。

module 对象

Node 内部提供一个 Module 构建函数。所有模块都是 Module 的实例。
每个模块中都会有一个内置对象module,表示当前模块。
在一个 node 文件中打印 module

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/fdd/fdd-project/html5/node/test.js',
loaded: false,
children: [],
paths:
[ '/Users/fdd/fdd-project/html5/node/node_modules',
'/Users/fdd/fdd-project/html5/node_modules',
'/Users/fdd/fdd-project/node_modules',
'/Users/fdd/node_modules',
'/Users/node_modules',
'/node_modules' ] }

属性

  • id:模块的唯一标示,根文件为.,引入的子模块就是文件的绝对路径
  • parent:对象,表示调用该模块的模块
  • children:数组,表示该模块调用的其他模块
  • filename:文件的绝对路径
  • paths:模块的地址,与后面的模块查找机制有关
  • loaded:布尔值,表示模块是否已经加载完成
  • exports:模块对外输出的值,加载某个模块,就是加载module.exports属性

如果使用命令行调用模块,比如node a.js,那么module.parent就是 null,如果在其他脚本中调用,比如require('./a.js'),那么module.parent就是调用它的模块。利用这一点可以判断当前模块是否为入口脚本。

1
2
3
4
5
6
7
8
9
if (!module.parent) {
// ran with `node a.js`
app.listen(8088, function() {
console.log('app listening on port 8088');
})
} else {
// used with `require('/.a.js')`
module.exports = app;
}

exports 变量

Node 为每个模块提供一个 exports 变量,指向 module.exports。这等同在每个模块头部,有一行这样的命令。

1
var exports=module.exports

可以为 exports 添加属性或方法

1
2
3
exports.area = function (r) {
return Math.PI * r * r;
};

不能为 exports 指向另一个值,这样 exports 不再与 module.exports 指向同一地址,同时 exports 也不会对外输出。
这意味着,如果一个模块的对外接口,就是一个单一的值,不能使用exports输出,只能使用 module.exports输出。

1
module.exports = function (x){ console.log(x);};

如果你觉得,exportsmodule.exports之间的区别很难分清,一个简单的处理方法,就是放弃使用exports,只使用module.exports

require 命令

Node 使用 CommonJS 模块规范,内置的 require 命令用于加载模块文件。
功能:读取 JS 文件,然后返回该模块的module.exports对象,如果没有发现指定模块。会报错。
加载规则

  1. 默认加载.js文件
  2. 参数
    • 如果参数字符串以’/‘开头,表示一个绝对路径
    • 如果参数字符串以’./‘开头,表示一个相对路径
    • 如果参数字符串不以’/‘或’./‘开头,表示加载的是一个默认提供的核心模块(Node 的核心模块或 node_module 目录已安装的模块)。
    • 如果参数字符串不以“./“或”/“开头,而且是一个路径,比如 require(‘example-module/path/to/file’),则将先找到 example-module 的位置,然后再以它为参数,找到后续路径。
    • 如果指定的模块文件没有发现,Node 会尝试为文件名添加.js、.json、.node 后,再去搜索。.js 件会以文本格式的 JavaScript 脚本文件解析,.json 文件会以 JSON 格式的文本文件解析,.node 文件会以编译后的二进制文件解析。
    • 如果想得到 require 命令加载的确切文件名,使用 require.resolve()方法。

目录的加载规则
通常,我们会把相关的文件会放在一个目录里面,便于组织。这时,最好为该目录设置一个入口文件,让 require 方法可以通过这个入口文件,加载整个目录。
例如,在目录中放置一个 package.json 文件,并且将入口文件写入 main 字段。

1
2
3
// package.json
{ "name" : "some-library",
"main" : "./lib/some-library.js" }

require 发现参数字符串指向一个目录以后,会自动查看该目录的 package.json 文件,然后加载 main 字段指定的入口文件。如果 package.json 文件没有 main 字段,或者根本就没有 package.json 文件,则会加载该目录下的 index.js 文件或 index.node 文件。

模块的缓存
第一次加载某个模块时,Node 会缓存该模块。以后再加载该模块,就直接从缓存取出该模块的 module.exports 属性。

1
2
3
4
require("./test.js");
require("./test.js").message = "hello";
let message = require("./test.js").message;
console.log(message);//hello

为模块添加的 message 依然存在,所以模块并没有重新加载。
所有缓存的模块保存在 require.cache 之中,如果想删除模块的缓存,可以像下面这样写。

1
2
3
4
5
6
//删除指定模块的缓存
// delete require.cache[moduleName];
//删除所有模块的缓存
Object.keys(require.cache).forEach((key) => {
delete require.cache[key];
});

moduleName:”/Users/home/html5/node/test.js”
注意,缓存是根据绝对路径识别模块的,如果同样的模块名,但是保存在不同的路径,require 命令还是会重新加载该模块。

require.main
用来判断模块是直接执行,还是被调用执行。
直接执行的时候(node module.js),require.main 属性指向模块本身。

1
require.main===module;//true

调用执行的时候(通过 require 加载该脚本执行),上面的表达式返回 false。

模块的加载机制

输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。

1
2
3
4
5
6
7
8
9
10
11
12
13
//test.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = { counter, incCounter };

//main.js
var counter = require("./test").counter;
var incCounter = require("./test").incCounter;
console.log(counter); //3
incCounter();
console.log(counter); //3

上面代码说明,counter 输出以后,test.js 模块内部的变化就影响不到 counter 了。

require 的内部处理流程
require不是全局命令,而是指向当前模块的module.require命令,而后者又调用 Node 的内部命令Module._load

1
2
3
4
5
6
7
8
9
Module._load = function(request, parent, isMain) {
// 1. 检查 Module._cache,是否缓存之中有指定模块
// 2. 如果缓存之中没有,就创建一个新的Module实例
// 3. 将它保存到缓存
// 4. 使用 module.load() 加载指定的模块文件,
// 读取文件内容之后,使用 module.compile() 执行文件代码
// 5. 如果加载/解析过程报错,就从缓存删除该模块
// 6. 返回该模块的 module.exports
};

上面的第4步,采用module.compile(),执行指定模块的脚本,逻辑如下。

1
2
3
4
5
6
Module.prototype._compile = function(content, filename) {
// 1. 生成一个require函数,指向module.require
// 2. 加载其他辅助方法到require
// 3. 将文件内容放到一个函数之中,该函数可调用 require
// 4. 执行该函数
};

第 1 步和第 2 步,require 函数及其辅助方法主要如下。

  • require(): 加载外部模块
  • require.resolve():将模块名解析到一个绝对路径
  • require.main:指向主模块
  • require.cache:指向所有缓存的模块
  • require.extensions:根据文件的后缀名,调用不同的执行函数

一旦 require 函数准备完毕,整个所要加载的脚本内容,就被放到一个新的函数之中,这样可以避免污染全局环境。该函数的参数包括 require、module、exports,以及其他一些参数。

1
2
3
(function (exports, require, module, __filename, __dirname) {
// YOUR CODE INJECTED HERE!
});

Module._compile 方法是同步执行的,所以 Module._load 要等它执行完成,才会向用户返回 module.exports 的值。

CommonJS 模块的循环加载

CommonJS 模块的重要特性是加载时执行,即脚本代码在 require 的时候,就会全部执行。一旦出现某个模块被”循环加载”,就只输出已经执行的部分,还未执行的部分不会输出。

1
2
3
4
5
6
//a.js
exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完毕');

上面代码之中,a.js脚本先输出一个 done 变量,然后加载另一个脚本文件b.js。注意,此时a.js代码就停在这里,等待b.js执行完毕,再往下执行。

1
2
3
4
5
6
//b.js
exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');

上面代码之中,b.js 执行到第二行,就会去加载 a.js,这时,就发生了“循环加载”。系统会去 a.js 模块对应对象的 exports 属性取值,可是因为 a.js 还没有执行完,从 exports 属性只能取回已经执行的部分,而不是最后的值。
a.js 已经执行的部分,只有一行。exports.done = false;
因此,对于 b.js 来说,它从 a.js 只输入一个变量 done,值为 false。
然后,b.js 接着往下执行,等到全部执行完毕,再把执行权交还给 a.js。于是,a.js 接着往下执行,直到执行完毕。

1
2
3
4
//main.js
var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);

执行 main.js,运行结果如下。

1
2
3
4
5
6
7
$ node main.js

在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true

上面的代码证明了两件事。一是,在 b.js 之中,a.js 没有执行完毕,只执行了第一行。二是,main.js 执行到第二行时,不会再次执行 b.js,而是输出缓存的 b.js 的执行结果,即它的第四行exports.done = true;

-------------本文结束感谢阅读-------------