module 在 nodejs 里是一个非常核心的内容,本文通过结合 nodejs 的源码简单介绍 nodejs 中模块的加载方式和缓存机制。如果有理解错误的地方,请及时提醒纠正。
ppt 地址:http://47.93.21.106/sharing/m...
CommonJS
提到 nodejs 中的模块,就不能不提到 CommonJS。大部分人应该都知道 nodejs 的模块规范是基于 CommonJS 的,但其实 CommonJS 不仅仅定义了关于模块的规范,完整的规范在这里:CommonJS。内容不多,感兴趣的同学可以浏览一下。当然重点是在 模块 这一章,如果仔细读一下 CommonJS 中关于模块的规定,可以发现和 node 中的模块使用是非常吻合的。
Contract
CommonJS 中关于模块的规定主要有三点:
-
Require
模块引入的方式和行为,涉及到常用的 `require()`。
-
Module Context
模块的上下文环境,涉及到 `module` 和 `exports`。
-
Module Identifiers
模块的标识,主要用于模块的引入
Usage
在 node.js 里使用模块的方式很简单,一般我们都是这么用的:
// format.js
const moment = require('moment');
/* 格式化时间戳 */
exports.formatDate = function (timestamp) {
return moment(timestamp).format('YYYY-MM-DD HH:mm:ss');
}
上面是一个 format.js 文件,内容比较简单,引入了 moment 模块,并导出了一个格式化时间的方法供其他模块使用。
但是大家有没有考虑过,这里的 require
和 exports
是在哪里定义的,为什么我们可以直接拿来使用呢?
实际上,nodejs 加载文件的时候,会在文件头尾分别添加一段代码:
头部添加
(function (exports, require, module, filename, dirname) {
尾部添加
});
最后处理成了一个函数,然后才进行模块的加载:
(function (exports, require, module, __filename, __dirname) {
// format.js
const moment = require('moment');
/* 格式化时间戳 */
exports.formatDate = function (timestamp) {
return moment(timestamp).format('YYYY-MM-DD HH:mm:ss');
}
});
所以 exports
, require
, module
其实都是在调用这个函数的时候传进来的了。
这里还有两个比较细微的点,也是在很多面试题里面会出现的
通过
var
、let
、const
定义的变量变成了局部变量;没有通过关键字声明的变量会泄露到全局exports
是一个形参,改变exports
的引用不会起作用
第一点是作用域的问题,第二点可以问到 js 的参数传递是值传递还是引用传递。
证明
当然,如果只是这样讲,好像只是我的一面之词,怎么证明 nodejs 确实是这样包装的呢,这里可以用两个例子来证明:
➜ echo 'dvaduke' > bad.js
➜ node bad.js
/Users/sunhengzhe/Documents/learn/node/modules/demos/bad.js:1
(function (exports, require, module, __filename, __dirname) { dvaduke
^
ReferenceError: dvaduke is not defined
at Object. (/Users/sunhengzhe/Documents/learn/node/modules/demos/bad.js:1:63)
at Module._compile (module.js:569:30)
at Object.Module._extensions..js (module.js:580:10)
at Module.load (module.js:503:32)
at tryModuleLoad (module.js:466:12)
at Function.Module._load (module.js:458:3)
at Function.Module.runMain (module.js:605:10)
at startup (bootstrap_node.js:158:16)
at bootstrap_node.js:575:3
我在 bad.js 里面随便输入了一个单词,然后运行这个文件,可以看到运行结果会抛出异常。在异常信息里面我们会惊讶地发现 node 把那行函数头给打印出来了,而在 bad.js 里面是只有那个单词的。
➜ echo 'console.log(arguments)' > arguments.js
➜ node arguments.js
{ '0': {},
'1':
{ [Function: require]
resolve: [Function: resolve],
main:
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/arguments.js',
loaded: false,
children: [],
paths: [Array] },
extensions: { '.js': [Function], '.json': [Function], '.node': [Function] },
cache: { '/Users/sunhengzhe/Documents/learn/node/modules/demos/arguments.js': [Object] } },
'2':
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/arguments.js',
loaded: false,
children: [],
paths:
[ '/Users/sunhengzhe/Documents/learn/node/modules/demos/node_modules',
'/Users/sunhengzhe/Documents/learn/node/modules/node_modules',
'/Users/sunhengzhe/Documents/learn/node/node_modules',
'/Users/sunhengzhe/Documents/learn/node_modules',
'/Users/sunhengzhe/Documents/node_modules',
'/Users/sunhengzhe/node_modules',
'/Users/node_modules',
'/node_modules' ] },
'3': '/Users/sunhengzhe/Documents/learn/node/modules/demos/arguments.js',
'4': '/Users/sunhengzhe/Documents/learn/node/modules/demos' }
在 arguments.js 这个文件里打印出 argumens 这个参数,我们知道 arguments 是函数的参数,那么打印结果可以很好的说明 node 往函数里传入了什么参数:第一个是 exports
,现在当然是空,第二个是 require
,是一个函数,第三个是 module
对象,还有两个分别是 __filename
和 __dirname
源码
发现这个地方之后我相信大家都会对 nodejs 的源码感兴趣,而 nodejs 本身是开源的,我们可以在 github 上找到 nodejs 的源码:node
实际上包装模块的代码就在 /lib/module.js
里面:
Module.prototype._compile = function(content, filename) {
content = internalModule.stripShebang(content);
// create wrapper function
var wrapper = Module.wrap(content);
var compiledWrapper = vm.runInThisContext(wrapper, {
filename: filename,
lineOffset: 0,
displayErrors: true
});
// ...
}
_compile
函数是编译 nodejs 文件会执行的方法,函数中的 content
就是我们文件中的内容,可以看到调用了一个 Module.wrap 方法,那么 Module.wrap 做了什么呢?这里需要找到另一个文件,包含内置模块定义的 /lib/internal/bootstrap_node.js
,里面有对 wrap 的操作:
NativeModule.wrap = function(script) {
return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};
NativeModule.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
确实是前面说到的,添加函数头尾的内容。
彩蛋?
其实知道这个处理之后,我们可以开一些奇怪的脑洞,比如写一段好像会报错的文件:
// inject.js
});
(function () {
console.log('amazing');
这个文件看起来没头没尾,但是经过 nodejs 的包装后,是可以运行的,会打印出 amazing
,看起来很有意思。
Core
上面只是带大家看了一下 module.js 里的一小段代码,实际上如果要搞明白 nodejs 模块运作的机制,有三个文件是比较核心的:
/lib/module.js
加载非内置模块/lib/internal/module.js
提供一些相关方法/lib/internal/bootstrap_node
定义了加载内置模块的 NativeModule,同时这也是 node 的入口文件
我们知道 node 的底层是 C 语言编写的,node 运行是,会调用 node.cc 这个文件,然后会调用 bootstrap_node 文件,在 bootstrap_node 中,会有一个 NativeModule 来加载 node 的内置模块,包括 module.js,然后通过 module.js 加载非内置模块,比如用户自定义的模块。(所以说模块是多么基础)
调用关系如下:
Module
下面重点介绍一下 module。在 nodejs 里面,通常一个文件就代表了一个模块,而 module 这个对象就代表了当前这个模块。我们可以尝试打印一下 module:
echo "console.log(module)" > print-module.js
node print-module.js
打印结果如下:
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/sunhengzhe/Documents/learn/node/modules/demo-1.js',
loaded: false,
children: [],
paths:
[ '/Users/sunhengzhe/Documents/learn/node/modules/node_modules',
'/Users/sunhengzhe/Documents/learn/node/node_modules',
'/Users/sunhengzhe/Documents/learn/node_modules',
'/Users/sunhengzhe/Documents/node_modules',
'/Users/sunhengzhe/node_modules',
'/Users/node_modules',
'/node_modules' ] }
可以看到 module 这个对象有很多属性,exports 我们先不说了,它就是这个模块需要导出的内容。filename 也不说了,文件的路径名。paths 很明显,是当前文件一直到根路径的所有 node_modules 路径,查找第三方模块时会用到它。我们下面介绍一下 id、parent、children 和 loaded。
module.id
在 nodejs 里面,模块的 id 分两种情况,一种是当这个模块是入口文件时,此时模块的 id 为 .
,另一种当模块不是入口文件时,此时模块的 id 为模块的文件路径。
举个例子,当文件是入口文件时:
➜ echo 'console.log(module.id)' > demo-1-single-file.js
➜ node demo-1-single-file.js
.
此时 id 为 .
当文件不是入口文件时:
➜ cat demo-2-require-other-file.js
const other = require('./demo-1-single-file');
console.log('self id:', module.id);
➜ node demo-2-require-other-file.js
/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-1-module-id/demo-1-single-file.js
self id: .
运行 demo-2-require-other-file.js,首先打印出 demo-1-single-file 的内容,可以发现此时 demo-1-single-file 的 id 是它的文件名:因为它现在不是入口文件了。而作为入口文件的 demo-2-require-other-file.js 的 id 变成了 .
module.parent & module.children
这两个含义很明确,是模块的调用方和被调用方。
如果我们直接打印一个入口文件的 module,结果如下:
➜ echo 'console.log(module)' > demo-1-single-file.js
➜ node demo-1-single-file.js
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-1-single-file.js',
loaded: false,
children: [],
paths:
[...] }
篇幅限制,就不显示 paths 了。可以看到 parent 为 null:因为没有人调用它;children 为空:因为它没有调用别的模块。那么我们再新建一个文件引用一下这个模块:
➜ cat demo-2-require-other-file.js
require('./demo-1-single-file');
console.log(module);
➜ node demo-2-require-other-file.js
Module {
id: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-1-single-file.js',
exports: {},
parent:
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-2-require-other-file.js',
loaded: false,
children: [ [Circular] ],
paths:
[...] },
filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-1-single-file.js',
loaded: false,
children: [],
paths:
[...] }
------------------------
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-2-require-other-file.js',
loaded: false,
children:
[ Module {
id: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-1-single-file.js',
exports: {},
parent: [Circular],
filename: '/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-2-parent/demo-1-single-file.js',
loaded: true,
children: [],
paths: [Array] } ],
paths:
[...] }
上面输出了两个 module,为了方便阅读,我用分割线分隔了一下。第一个 module 是 demo-1-single-file 打印出来的,它的 parent 现在有值了,因为 demo-2-require-other-file.js 引用它了。它的 children 依旧是空,毕竟它没有引用别人。
而 demo-2-require-other-file.js 的 parent 为 null,children 有值了,可以看到就是 demo-1-single-file。
注意里面还出现了 [Circular],因为 demo-1-single-file 的 parent 的 children 就是它自己,为了防止循环输出,nodejs 在这里省略掉了,应该很好理解。
module.loaded
loaded 从字面意思上也好理解,代表这个模块是否已经加载完了。但我们会发现在上面的所有输出中,loaded 都是 false。
➜ cat demo-1-print-sync.js
console.log(module.loaded);
➜ node demo-1-print-sync.js
false
我们可以在 node 的下一个 tick 里面去输出,就能得到正确的 loaded 的值了:
➜ cat demo-2-print-next-tick.js
setImmediate(function () {
console.log(module.loaded);
});
➜ node demo-2-print-next-tick.js
true
模块的加载
模块到底是如何加载的?在 /lib/module.js
里,可以找到模块加载的函数 _load
,这里 node 的注释很好地描述了加载的次序:
// Check the cache for the requested file.
// 1. If a module already exists in the cache: return its exports object.
// 2. If the module is native: call `NativeModule.require()` with the
// filename and return the result.
// 3. Otherwise, create a new module for the file and save it to the cache.
// Then have it load the file contents before returning its exports
// object.
Module._load = function(request, parent, isMain) {
//...
翻译一下,大概就是这个流程:
有缓存(二次加载)
直接读取缓存内容
-
无缓存(首次加载或清空缓存之后)
路径分析
文件定位
编译执行
无缓存
首先看一下无缓存的情况。nodejs 首先需要对文件进行定位,找到文件才能进行加载,其实所有的细节都隐藏在了 require
方法里面,我们调用 require,nodejs 返回模块对象,那么 require 是怎么找到我们需要的模块的呢?
简单来讲,大致是:
尝试加载核心模块
-
尝试以文件形式加载
X
X.js
X.json
X.node
尝试作为目录查找,寻找 package.json 文件,尝试加载 main 字段指定的文件
尝试作为目录查找,寻找 index.js、index.json、index.node
尝试作为第三方模块进行加载
抛出异常
这里涉及的代码细节比较复杂,建议先直接阅读 nodejs 的官方文档,文档对定位的顺序描述的非常详细:https://nodejs.org/dist/latest-v8.x/docs/api/modules.html#modules_all_together
缓存
如果有缓存的话,会直接返回缓存内容。比如这里有个文件,内容就是打印一行星号:
➜ cat print.js
console.log('********');
如果我们在另一个文件里引入这个文件两次,那么会输出两行星号吗?
➜ demo-5-cache cat demo-1-just-print-multiply.js
require('./print');
require('./print');
➜ demo-5-cache node demo-1-just-print-multiply.js
********
答案是不会的,因为第一次 require 后,nodejs 会把文件缓存起来,第二次 require 直接取得缓存的内容,参考 /lib/module.js
中的代码:
Module._load = function(request, parent, isMain) {
var cachedModule = Module._cache[filename];
if (cachedModule) {
// 更新 parent 的 children
updateChildren(parent, cachedModule, true);
return cachedModule.exports;
}
//...
Module._cache[filename] = module;
tryModuleLoad(module, filename);
//...
}
清空缓存
那么,如果我们要清空缓存,势必需要清除 Module._cache
中的内容。然而在文件里,我们只能拿到 module 对象,拿不到 Module 类:
➜ cat demo-2-get-Module.js
console.log(Module)
➜ node demo-2-get-Module.js
/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-5-cache/demo-2-get-Module.js:1
(function (exports, require, module, __filename, __dirname) { console.log(Module)
^
ReferenceError: Module is not defined
at Object. (/Users/sunhengzhe/Documents/learn/node/modules/demos/demo-5-cache/demo-2-get-Module.js:1:75)
at Module._compile (module.js:569:30)
at Object.Module._extensions..js (module.js:580:10)
at Module.load (module.js:503:32)
at tryModuleLoad (module.js:466:12)
at Function.Module._load (module.js:458:3)
at Function.Module.runMain (module.js:605:10)
at startup (bootstrap_node.js:158:16)
at bootstrap_node.js:575:3
但是是否没有办法去清空缓存了呢?当然是有的。这里我们先看 require 是怎么来的。
之前提到,require 是通过函数参数的方式传入模块的,那么我们可以看一下,传入的 require 的到底是什么?回到 _compile
方法:
Module.prototype._compile = function(content, filename) {
content = internalModule.stripShebang(content);
// create wrapper function
var wrapper = Module.wrap(content);
var compiledWrapper = vm.runInThisContext(wrapper, {
filename: filename,
lineOffset: 0,
displayErrors: true
});
// ...
var require = internalModule.makeRequireFunction(this);
result = compiledWrapper.call(this.exports, this.exports, require, this,
filename, dirname);
// ...
return result;
}
简化后的代码如上,函数内容经过包装之后生成了一个新的函数 compiledWrapper
,然后把一些参数传了进去。我们可以看到 require 是从一个 makeRequireFunction
的函数中生成的。
而 makeRequireFunction
函数是在 /lib/internal/module.js
中定义的,看下代码:
function makeRequireFunction(mod) {
const Module = mod.constructor;
function require(path) {
try {
exports.requireDepth += 1;
return mod.require(path);
} finally {
exports.requireDepth -= 1;
}
}
function resolve(request) {
return Module._resolveFilename(request, mod);
}
require.resolve = resolve;
require.main = process.mainModule;
// Enable support to add extra extension types.
require.extensions = Module._extensions;
require.cache = Module._cache;
return require;
}
如果我们直接打印 require,其实就和这里面定义的 require 是一样的:
➜ cat demo-1-require.js
console.log(require.toString());
➜ node demo-1-require.js
function require(path) {
try {
exports.requireDepth += 1;
return mod.require(path);
} finally {
exports.requireDepth -= 1;
}
}
其实这个 require 也没有做什么事情,又调用了 mod 的 require,而 mod 是通过 makeRequireFunction
传进来的,传入的是 this,所以归根到底,require 是 module 原型上的方法,也就是 module.prototype.require,参考 /lib/module.js
中的代码。
当然这里我们先不用追究 require 的实现方式,而是注意到 makeRequireFunction
中对 require 的定义,我们可以发现一行关于 _cache 的代码:
function makeRequireFunction(mod) {
// ...
function require(path) {}
require.cache = Module._cache;
//..
return require;
}
所以 nodejs 很贴心地,把 Module._cache
返回给我们了,其实只要清空 require.cache 即可。而根据上面的代码,Module._cache
是通过 filename 来作为缓存的 key 的,所以我们只需要清空模块对应的文件名。
针对上面提到的例子,清空 print.js
的缓存:
require('./print');
// delete cache
delete require.cache[require.resolve('./print')];
require('./print');
然后再打印一下
➜ node demo-1-just-print-multiply.js
********
********
就是两行星号了。
这里用到了 require 的一个 resolve 方法,它和直接调用 require 方法比较像,都能找到模块的绝对路径名,但直接 require 还会加载模块,而 require.resolve()
只会找到文件名并返回。所以这里利用文件名将 cache 里对应的内容删除了。
调试 nodejs 的源码
本文介绍了一些 nodejs 中的源码内容,在学习 nodejs 的过程中,如果想查看 nodejs 的源码(我觉得这是一个必备的过程),那么就需要去调试源码,打几个 log 看一下是不是和你预期的一致,这里说一下怎么调试 nodejs 的源码。
下载 node 源码
[email protected]:nodejs/node.git
进入源码目录执行
./configure
&make -j
上一步之后会在
${源码目录}/out/Release/node
里生成一个执行文件,将这个文件作为你的 node 执行文件。每次修改源码后重新执行
make
命令。
比如修改代码之后,运行 make
,然后这样运行文件即可:
➜ /Users/sunhengzhe/Documents/project/source-code/node/out/Release/node demo-1-single-file.js
参考
朴灵《深入浅出 Node.js》
Node.js Documentation
requiring-modules-in-node-js-everything-you-need-to-know