Node.js 中的模块机制

Node.js 中的模块机制

为了让 Node.js 的文件可以相互调用,Node.js 提供了一个简单的模块系统,模块是 Node.js 应用程序的基本组成部分,文件和模块是一一对应的,换言之,一个 Node.js 文件就是一个模块,这个文件可能是 JavaScript 代码、JSON 或者编译过的 C/C++ 扩展

CommonJS 规范

Node.js 遵循 CommonJS 规范,该规范的核心思想是允许模块通过 require 方法来同步加载所要依赖的其他模块,然后通过 exportsmodule.exports 来导出需要暴露的接口,CommonJS 规范是为了解决 JavaScript 的作用域问题而定义的模块形式,可以使每个模块它自身的命名空间中执行,下面是一个简单的示例

1
2
3
4
5
6
// a.js
module.exports = (a, b) => a + b

// b.js
const add = require('./a')
console.log(add(2, 3))

CommonJS 也有浏览器端的实现,其原理是现将所有模块都定义好并通过 id 索引,这样就可以方便的在浏览器环境中解析了,可以参考 require1ktiny-browser-require 源码

经常与 CommonJS 规范一起出现的还有 AMD 规范和 CMD 规范,在这里就不详细展开,感兴趣的可以参考 JavaSript 模块规范 - AMD 规范与 CMD 规范介绍,总结的很棒

模块分类

Node.js 中,模块主要可以分为以下几种类型

  • 核心模块,包含在 Node.js 源码中,被编译进 Node.js 可执行二进制文件 JavaScript 模块,也叫 Native 模块,比如常用的 HTTPfs 等等
  • C/C++ 模块,也叫 built-in 模块,一般我们不直接调用,而是在 native module 中调用,然后我们再 require
  • Native 模块,比如我们在 Node.js 中常用的 BufferfsosNative 模块,其底层都有调用 built-in 模块
    • 如对于 Native 模块 Buffer,还是需要借助 builtin node_buffer.cc 中提供的功能来实现大容量内存申请和管理,目的是能够脱离 V8 内存大小使用限制
  • 第三方模块,非 Node.js 源码自带的模块都可以统称第三方模块,比如 expressWebpack 等等
    • JavaScript 模块,这是最常见的,我们开发的时候一般都写的是 JavaScript 模块
    • JSON 模块,就是一个 JSON 文件
    • C/C++ 扩展模块,使用 C/C++ 编写,编译之后后缀名为 .node

源码的目录结构如下

1
2
3
4
5
6
7
8
9
10
11
├── benchmark      // 一些 Node.js 性能测试代码
├── deps // Node.js 依赖
├── doc // 文档
├── lib // Node.js 对外暴露的 js 模块源码
├── src // Node.js 的 C/C++ 源码文件,内建模块
├── test // 单元测试
├── tools // 编译时用到的工具
├── doc // api 文档
├── vcbuild.bat // win 平台 makefile 文件
├── node.gyp // node-gyp 构建编译任务的配置文件
...

模块对象

每个模块内部,都有一个 module 对象,代表当前模块,它有以下属性

  • module.id,模块的识别符,通常是带有绝对路径的模块文件名
  • module.filename,模块的文件名,带有绝对路径
  • module.loaded,返回一个布尔值,表示模块是否已经完成加载
  • module.parent,返回一个对象,表示调用该模块的模块
  • module.children,返回一个数组,表示该模块要用到的其他模块
  • module.exports,表示模块对外输出的值

模块加载机制

简单来说,模块加载机制也就是 require 函数执行的主要流程,在 Node.js 中模块加载一般会经历三个步骤,『路径分析』、『文件定位』、『编译执行』,按照模块的分类,按照以下顺序进行优先加载

  • 系统缓存,模块被执行之后会会进行缓存,首先是先进行缓存加载,判断缓存中是否有值
  • 系统模块,也就是原生模块,这个优先级仅次于缓存加载,部分核心模块已经被编译成二进制,省略了『路径分析』、『文件定位』,直接加载到了内存中,系统模块定义在 Node.js 源码的 lib 目录下
  • 文件模块,优先加载以 .../ 开头的,如果文件没有加上扩展名,会依次按照 .js.json.node 进行扩展名补足尝试,那么在尝试的过程中也是以同步阻塞模式来判断文件是否存在
    • 从性能优化的角度来看待,.json.node 最好还是加上文件的扩展名
  • 目录做为模块,这种情况发生在文件模块加载过程中,也没有找到,但是发现是一个目录的情况,这个时候会将这个目录当作一个『包』来处理
    • Node.js 这块采用了 Commonjs 规范,先会在项目根目录查找 package.json 文件,取出文件中定义的 main 属性 ("main": "lib/hello.js") 描述的入口文件进行加载
    • 如果也没加载到,则会抛出默认错误: Error: Cannot find module 'lib/hello.js'
  • node_modules 目录加载,对于系统模块、路径文件模块都找不到,Node.js 会从当前模块的父目录进行查找,直到系统的根目录

我们在上面介绍了 Node.js 模块机制的一些基本内容,下面我们就来看一些 Node.js 模块当中可能涉及到的一些问题,主要有下面这些

  • 模块中的 moduleexports__dirname__filenamerequire 来自何方?
  • module.exportsexports 有什么区别?
  • 模块之间循环依赖是否会陷入死循环?
  • require 函数支持导入哪几类文件?
  • require 函数执行的主要流程是什么?
  • Node.js 模块与前端模块的异同
  • Node.js 中的 VM 模块是做什么用的?

模块中的 module、exports、dirname、filename 和 require 来自何方?

针对于这个问题,我们手动的试一下就知道了,新建一个 index.js 文件,输入以下内容

1
2
3
4
5
console.log(module)
console.log(exports)
console.log(__dirname)
console.log(__filename)
console.log(require)

执行完以上代码,控制台的输出如下,我们忽略掉输出对象中的大部分属性,只保留一些比较重要的,如下

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
Module { ========================================================> module
id: '.',
exports: {},
parent: null,
filename: 'index.js',
loaded: false,
children: [],
// 模块查找路径
paths: []
}

{} ==============================================================> exports

/Users/Desktop/test =============================================> __dirname

/Users/Desktop/test/index.js ====================================> __filename

{
[Function: require] ===========================================> require
resolve: { [Function: resolve] paths: [Function: paths] },
// Module对象
main: Module { ... },
extensions: { '.js': [Function], '.json': [Function], '.node': [Function] },
cache: { ... }
}

通过控制台的输出值,我们可以清楚地看出每个变量的值,在执行代码之前,Node.js 会对要执行的代码进行封装,至于到底是如何封装的,可以见下方 require 函数支持导入哪几类文件?,如下所示

1
2
3
(function(exports, require, module, __filename, __dirname) {
// 模块的代码
})

这里我们就清楚了,模块中的 moduleexports__dirname__filenamerequire 这些对象都是函数的输入参数,在调用包装后的函数时传入

module.exports 与 exports 的区别

我们先来看一行代码

1
console.log(module.exports === exports)   // true

可以发现,输出为 true,再看下面这样

1
2
3
exports.id = 1                            // 方式一,可以正常导出
exports = { id: 1 } // 方式二,无法正常导出
module.exports = { id: 1 } // 方式三,可以正常导出

为什么方式二无法正常导出呢?这里可以参考上面的 moduleexports 输出的对应值来理解,如果 module.exports === exports 执行的结果为 true,那么表示模块中的 exports 变量与 module.exports 属性是指向同一个对象,当使用方式二 exports = { id: 1 } 的方式会改变 exports 变量的指向,这时与 module.exports 属性指向不同的变量,而当我们导入某个模块时,是导入 module.exports 属性指向的对象

如果想要深入了解,可以参考之前整理过的一篇文章内容 exports、module.exports 和 export、export default,分析的很详细

模块之间循环依赖是否会陷入死循环?

我们先来看看什么是循环依赖,所谓循环依赖就是,当模块 a 执行时需要依赖模块 b 中定义的属性或方法,而在导入模块 b 中,发现模块 b 同时也依赖模块 a 中的属性或方法,即两个模块之间互相依赖,这种现象我们称之为循环依赖,我们来验证一下

1
2
3
4
5
6
7
8
9
// a.js
exports.a = 1
exports.b = 2
require('./b')
exports.c = 3

// b.js
const a = require('./a')
console.log(a)

当在控制台运行 a.js 之后可以发现程序正常运行,并不会出现死循环,但『只会输出相应模块已加载的部分数据』,如下

1
{ a: 1, b: 2 }

所以我们可以得出结论,在启动 a.js 的时候,会加载 b.js,那么在 b.js 中又加载了 a.js,但是此时 a.js 模块还没有执行完,返回的是一个 a.js 模块的 exports 对象『未完成的副本』给到 b.js 模块(因此是不会陷入死循环的),然后 b.js 完成加载之后将 exports 对象提供给了 a.js 模块

require 函数支持导入哪几类文件?

require 函数对象中,有一个 extensions 属性,顾名思义表示它支持的扩展名,支持的文件类型主要有 .js.json.node,在上面输出的 require 函数对象中我们已经可以了解到了

1
2
3
4
5
6
7
8
{ 
[Function: require] ===========================================> require
resolve: { [Function: resolve] paths: [Function: paths] },
// Module对象
main: Module { ... },
extensions: { '.js': [Function], '.json': [Function], '.node': [Function] },
cache: { ... }
}

我们再来深入一下,其实模块内的 require 函数对象是通过 lib/internal/module.js 文件中的 makeRequireFunction 函数创建的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function makeRequireFunction(mod) {
const Module = mod.constructor

function require(path) {
try {
exports.requireDepth += 1
return mod.require(path)
} finally {
exports.requireDepth -= 1
}
}

// Enable support to add extra extension types.
require.extensions = Module._extensions
require.cache = Module._cache
return require
}

可以发现,在导入模块时,最终还是通过调用 Module 对象的 require() 方法来实现模块导入,在上面代码中,我们可以发现这一行 require.extensions = Module._extensions,在 lib/module.js 文件当中我们可以发现以下的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Native extension for .js
Module._extensions['.js'] = function (module, filename) {
var content = fs.readFileSync(filename, 'utf8')
module._compile(internalModule.stripBOM(content), filename)
}

// Native extension for .json
Module._extensions['.json'] = function (module, filename) {
var content = fs.readFileSync(filename, 'utf8')
try {
module.exports = JSON.parse(internalModule.stripBOM(content))
} catch (err) {
err.message = filename + ': ' + err.message
throw err
}
}

//Native extension for .node
Module._extensions['.node'] = function (module, filename) {
return process.dlopen(module, path.toNamespacedPath(filename))
}

这是 Node.js 针对处理的几种文件类型,这里我们主要看处理 .js 类型文件

1
2
3
4
5
// Native extension for .js
Module._extensions['.js'] = function (module, filename) {
var content = fs.readFileSync(filename, 'utf8')
module._compile(internalModule.stripBOM(content), filename)
}

可以发现,首先我们会以同步的方式读取对应的文件内容,然后在使用 module._compile() 方法对文件的内容进行编译

1
2
3
4
5
6
7
8
9
Module.prototype._compile = function (content, filename) {

// ...

// create wrapper function
var wrapper = Module.wrap(content)

// ...
}

在这里,我们主要关注 var wrapper = Module.wrap(content) 这一行,调用 Module 内部的封装函数对模块的原始内容进行封装

1
2
3
4
5
6
7
8
Module.wrap = function (script) {
return Module.wrapper[0] + script + Module.wrapper[1]
}

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

看到这里我们就可以明白,原来模块中的原始内容是在这个阶段进行包装的,包装后的格式为

1
2
3
(function (exports, require, module, __filename, __dirname) {
// 模块原始内容
})

这也就解释了之前的模块中的 exportsrequiremodule__filename__dirname 来自何方

require 函数执行的主要流程是什么?

在之前的章节中我们已经了解到了 require 函数执行的主要流程,其实就是模块加载机制,在加载对应模块前,我们首先需要定位文件的路径,文件的定位是通过 Module 内部的 _resolveFilename() 方法来实现,简化版的相关的伪代码描述如下

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
从 Y 路径的模块 require(X)
1. 如果 X 是一个核心模块,
a. 返回核心模块
b. 结束
2. 如果 X 是以 '/' 开头
a. 设 Y 为文件系统根目录
3. 如果 X 是以 './''/''../' 开头
a. 加载文件(Y + X)
b. 加载目录(Y + X)
4. 加载Node模块(X, dirname(Y))
5. 抛出 "未找到"

加载文件(X)
1. 如果 X 是一个文件,加载 X 作为 JavaScript 文本,结束
2. 如果 X.js 是一个文件,加载 X.js 作为 JavaScript 文本,结束
3. 如果 X.json 是一个文件,解析 X.json 成一个 JavaScript 对象,结束
4. 如果 X.node 是一个文件,加载 X.node 作为二进制插件,结束

加载索引(X)
1. 如果 X/index.js 是一个文件,加载 X/index.js 作为 JavaScript 文本,结束
3. 如果 X/index.json 是一个文件,解析 X/index.json 成一个 JavaScript 对象,结束
4. 如果 X/index.node 是一个文件,加载 X/index.node 作为二进制插件,结束

加载目录(X)
1. 如果 X/package.json 是一个文件,
a. 解析 X/package.json,查找 "main" 字段
b. let M = X + (json main 字段)
c. 加载文件(M)
d. 加载索引(M)
2. 加载索引(X)

加载Node模块(X, START)
1. let DIRS=NODE_MODULES_PATHS(START)
2. for each DIR in DIRS:
a. 加载文件(DIR/X)
b. 加载目录(DIR/X)

NODE_MODULES_PATHS(START)
1. let PARTS = path split(START)
2. let I = count of PARTS - 1
3. let DIRS = []
4. while I >= 0,
a. if PARTS[I] = "node_modules" CONTINUE
b. DIR = path join(PARTS[0 .. I] + "node_modules")
c. DIRS = DIRS + DIR
d. let I = I - 1
5. return DIRS

下面就简单的看一下内部的 Module 对象的 require() 方法

1
2
3
4
5
6
7
8
9
10
11
12
// Loads a module at the given file path. Returns that module's
// `exports` property.
Module.prototype.require = function (id) {
if (typeof id !== 'string') {
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'id', 'string', id)
}
if (id === '') {
throw new errors.Error('ERR_INVALID_ARG_VALUE',
'id', id, 'must be a non-empty string')
}
return Module._load(id, this, /* isMain */ false)
}

通过源码可以发现,其本质上是调用了 Module._load() 方法

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
// 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) {

// 解析文件的具体路径
var filename = Module._resolveFilename(request, parent, isMain)

// 优先从缓存中获取
var cachedModule = Module._cache[filename]
if (cachedModule) {
updateChildren(parent, cachedModule, true)
// 导出模块的 exports 属性
return cachedModule.exports
}

// 判断是否为 native module,如 fs、http 等
if (NativeModule.nonInternalExists(filename)) {
debug('load native module %s', request)
return NativeModule.require(filename)
}

// Don't call updateChildren(), Module constructor already does.
// 创建新的模块对象
var module = new Module(filename, parent)

if (isMain) {
process.mainModule = module
module.id = '.'
}

// 缓存新建的模块
Module._cache[filename] = module

// 尝试进行模块加载
tryModuleLoad(module, filename)

return module.exports
}

可以发现,与我们之前的模块加载机制是完全类似的,这里存在一个小问题,模块首次被加载后,会被缓存在 Module._cache 属性中,但有些时候,我们修改了已被缓存的模块,希望其它模块导入时,获取到更新后的内容的话该怎么处理呢?针对这种情况,我们可以使用以下方法清除指定缓存的模块,或清理所有已缓存的模块

1
2
3
4
5
6
7
// 删除指定模块的缓存
delete require.cache[require.resolve('/* 被缓存的模块名称 */')]

// 删除所有模块的缓存
Object.keys(require.cache).forEach(function (key) {
delete require.cache[key]
})

Node.js 模块与前端模块的异同

通常有一些模块可以同时适用于前后端,但是在浏览器端通过 script 标签的载入 JavaScript 文件的方式与 Node.js 不同,Node.js 在载入到最终的执行中,进行了包装,使得每个文件中的变量天然的形成在一个闭包之中,不会污染全局变量,而浏览器端则通常是裸露的 JavaScript 代码片段,所以为了解决前后端一致性的问题,类库开发者需要将类库代码包装在一个闭包内,比如 underscore 的定义方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(function () {
// Establish the root object, `window` in the browser, or `global` on the server.
var root = this
var _ = function (obj) {
return new wrapper(obj)
}
if (typeof exports !== 'undefined') {
if (typeof module !== 'undefined' && module.exports) {
exports = module.exports = _
}
exports._ = _
} else if (typeof define === 'function' && define.amd) {
// Register as a named module with AMD.
define('underscore', function () {
return _
})
} else {
root['_'] = _
}
}).call(this)

首先,它通过 function 定义构建了一个闭包,将 this 作为上下文对象直接 call 调用,以避免内部变量污染到全局作用域,续而通过判断 exports 是否存在来决定将局部变量 _ 绑定给 exports,并且根据 define 变量是否存在,作为处理在实现了 AMD 规范环境 下的使用案例

仅只当处于浏览器的环境中的时候,this 指向的是全局对象(window 对象),才将 _ 变量赋在全局对象上,作为一个全局对象的方法导出,以供外部调用,所以在设计前后端通用的 JavaScript 类库时,都有着以下类似的判断

1
2
3
4
5
if (typeof exports !== 'undefined') {
exports.EventProxy = EventProxy
} else {
this.EventProxy = EventProxy
}

即,如果 exports 对象存在,则将局部变量挂载在 exports 对象上,如果不存在,则挂载在全局对象上

Node.js 中的 VM 模块是做什么用的?

VM 模块提供了一系列 API 用于在 V8 虚拟机环境中编译和运行代码,JavaScript 代码可以被编译并立即运行,或编译、保存然后再运行

vm.runInThisContext(code[, options])

vm.runInThisContext() 在当前的 global 对象的上下文中编译并执行 code,最后返回结果,运行中的代码无法获取本地作用域,但可以获取当前的 global 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
const vm = require('vm')
let localVar = 'initial value'

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

const evalResult = eval('localVar = "eval";')
console.log('evalResult:', evalResult)
console.log('localVar:', localVar)

// vmResult: 'vm', localVar: 'initial value'
// evalResult: 'eval', localVar: 'eval'

正因 vm.runInThisContext() 无法获取本地作用域,故 localVar 的值不变,相反 eval() 确实能获取本地作用域,所以 localVar 的值被改变了

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×