你需要了解的 Node.js 模块

1,945 阅读4分钟
原文链接: www.oschina.net

Node 使用两个核心模块来管理模块依赖:

  • require 模块,是个看起来像在全局作用域有效的模块——不需要 require('require')。

  • module 模块,看起来也像是在全局作用域内有效——不需要 require('module')。

你可以把 require 模块当作命令,把 module 模块当作一个组织者,用来组织请求的各个模块。

在 Node 中请求一个模块并不是一个复杂的概念。

const config = require('/path/to/file');

由 require 模块导出的主要对象是一个函数(如上例所用)。 当 Node 使用本地文件路径作为函数的唯一参数调用该 require() 函数时,Node 将执行以下步骤:

  • 解析:找到文件的绝对路径。

  • 加载:确定文件内容的类型.

  • 封装:给文件其私有作用域。 这使得 require 和 module 对象两者都可以下载我们需要的每个文件。

  • 评估:这是 VM 对加载的代码最后需要做的。

  • 缓存:当我们再次需要这个文件时,不再重复所有的步骤。

在本文中,我将尝试用示例解释这些不同的阶段,以及它们是如何影响我们在 Node 中编写模块的方式的。

首先让我们使用终端创建一个目录来存放所有的示例:

mkdir ~/learn-node && cd ~/learn-node

本文剩余部分的所有命令都从 ~/learn-node 目录下运行。

解析本地路径

介绍一下 module 对象。你可以在 REPL session 中查看:

~/learn-node $ node
> module
Module {
  id: '<repl>',
  exports: {},
  parent: undefined,
  filename: null,
  loaded: false,
  children: [],
  paths: [ ... ] }

每一个 module 对象都获取一个 id 属性来标识它。这个 id 通常是文件的完整路径,但是在一个 REPL sessio n中它的值只是 <rep1>。

Node 模板与文件系统上的文件具有一一对应的关系。通过加载文件的内容到内存中来加载模板。

现在 Node 允许多种方式加载一个文件(例如:使用一个相对路径或预配置的路径), 但是之前我们要是想加载一个文件的内容到内存中,我们需要找到那个文件的绝对路径。

现在请求 'find-me' 模块,但不指定路径:

require('find-me');

Node 会按顺序在 module.paths 指定的路径中去寻找 find-me.js。

~/learn-node $ node
> module.paths
[ '/Users/samer/learn-node/repl/node_modules',
  '/Users/samer/learn-node/node_modules',
  '/Users/samer/node_modules',
  '/Users/node_modules',
  '/node_modules',
  '/Users/samer/.node_modules',
  '/Users/samer/.node_libraries',
  '/usr/local/Cellar/node/7.7.1/lib/node' ]

路径列表基本上会是从当前目录到根目录下的每一个 node_modules 目录。它也会包含一些不推荐使用的遗留目录。

如果 Node 在这些目录下仍然找不到 find-me.js,它会抛出 “cannot find module error.(不能找到模块)” 这个错误消息。

~/learn-node $ node
> require('find-me')
Error: Cannot find module 'find-me'
    at Function.Module._resolveFilename (module.js:470:15)
    at Function.Module._load (module.js:418:25)
    at Module.require (module.js:498:17)
    at require (internal/module.js:20:19)
    at repl:1:1
    at ContextifyScript.Script.runInThisContext (vm.js:23:33)
    at REPLServer.defaultEval (repl.js:336:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)
    at REPLServer.onLine (repl.js:533:10)

现在创建一个局部的 node_modules 目录,放入一个 find-me.js,require('find-me') 就能找到它。

~/learn-node $ mkdir node_modules 
~/learn-node $ echo "console.log( 'I am not lost'); " > node_modules/find-me.js
~/learn-node $ node
> require('find-me');
I am not lost
{}
>

如果别的路径下存在另一个 find-me.js 文件,例如在 home 目录下存在 node_modules 目录,其中有一个不同的 find-me.js:

$ mkdir ~/node_modules
$ echo "console.log( 'I am the root of all problems'); " > ~/node_modules/find-me.js

现在 learn-node 目录也包含 node_modules/find-me.js —— 在这个目录下 require('find-me'),那么 home 目录下的 find-me.js 根本不会被加载:

~/learn-node $ node
> require('find-me')
I am not lost
{}
>

如果删除了~/learn-node 目录下的的 node_modules 目录,再次尝试请求 find-me.js,就会使用 home 目录下 node_modules 目录中的 find-me.js 了:

~/learn-node $ rm -r node_modules/
~/learn-node $ node
> require('find-me')
I am the root of all problems
{}
>

请求一个目录

模块不一定是文件。我们也可以在 node_modules 目录下创建一个 find-me 目录,并在其中放一个 index.js 文件。同样的 require('find-me') 会使用这个目录下的 index.js 文件:

~/learn-node $ mkdir -p node_modules/find-me
~/learn-node $ echo "console.log( 'Found again.'); " > node_modules/find-me/index.js
~/learn-node $ node
> require('find-me');
Found again.
{}
>

注意如果存在局部模块,home 下 node_modules 路径中的相应模块仍然会被忽略。

在请求一个目录的时候,默认会使用 index.js,不过我们可以通过 package.json 中的 main 选项来改变起始文件。比如,希望 require('find-me') 在 find-me 目录下去使用另一个文件,只需要在那个目录下添加  package.json 文件来完成这个事情:

~/learn-node $ echo "console.log( 'I rule'); " > node_modules/find-me/start.js
~/learn-node $ echo '{ "name ": "find-me-folder ", "main ": "start.js " }' > node_modules/find-me/package.json
~/learn-node $ node
> require('find-me');
I rule
{}
>

require.resolve

如果你只是想找到模块,并不想执行它,你可以使用 require.resolve 函数。除了不加载文件,它的行为与主函数 require 完全相同。如果文件不存在它会抛出错误,如果找到了指定的文件,它会返回完整路径。

> require.resolve('find-me');
'/Users/samer/learn-node/node_modules/find-me/start.js'
> require.resolve('not-there');
Error: Cannot find module 'not-there'
    at Function.Module._resolveFilename (module.js:470:15)
    at Function.resolve (internal/module.js:27:19)
    at repl:1:9
    at ContextifyScript.Script.runInThisContext (vm.js:23:33)
    at REPLServer.defaultEval (repl.js:336:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)
    at REPLServer.onLine (repl.js:533:10)
    at emitOne (events.js:101:20)
    at REPLServer.emit (events.js:191:7)
>

这很有用,比如,检查一个可选的包是否安装并在它已安装的情况下使用它。

相对路径和绝对路径

除了在 node_modules 目录中查找模块之外,我们也可以把模块放置于任何位置,然后通过相对路径(./ 和 ../)请求,也可以通过以 / 开始的绝对路径请求。

比如,如果 find-me.js 是放在 lib 目录而不是 node_modules 目录下,可以这样请求:

require('./lib/find-me');

文件中的父子关系

创建 lib/util.js 文件并添加一行 console.log 代码来识别它。console.log 会输出模块自身的 module 对象:

~/learn-node $ mkdir lib
~/learn-node $ echo "console.log( 'In util', module); " > lib/util.js

在 index.js 文件中干同样的事情,稍后我们会通过 node 命令执行这个文件。让 index.js 文件请求 lib/util.js: 

~/learn-node $ echo "console.log( 'In index', module); require( './lib/util'); " > index.js

现在用 node 执行 index.js:

~/learn-node $ node index.js
In index Module {
  id: '.',
  exports: {},
  parent: null,
  filename: '/Users/samer/learn-node/index.js',
  loaded: false,
  children: [],
  paths: [ ... ] }
In util Module {
  id: '/Users/samer/learn-node/lib/util.js',
  exports: {},
  parent:
   Module {
     id: '.',
     exports: {},
     parent: null,
     filename: '/Users/samer/learn-node/index.js',
     loaded: false,
     children: [ [Circular] ],
     paths: [...] },
  filename: '/Users/samer/learn-node/lib/util.js',
  loaded: false,
  children: [],
  paths: [...] }

注意到现在的列表中主模块 index (id: '.') 是 lib/util 模块的父模块。不过 lib/util 模块并未作为 index 的子模块列出来。不过那里有个 [Circular] 值因为那里存在循环引用。如果 Node 打印 lib/util 模块对象,它就会陷入一个无限循环。因此这里用 [Circular] 代替了 lib/util 引用。

现在更重要的问题是,如果 lib/util 模块又请求了 index 模块,会发生什么事情?这就是我们需要了解的循环依赖,Node 允许这种情况存在。

在理解它之前,我们先来搞明白 module 对象中的另外一些概念。

exports,module.exports,同步加载模块

在任何模块中,exports 都是一个特殊的对象,如果你注意看,你会发现我们每次输出一个模块对象,他都导出一个空对象属性。我们可以对 exports 对象添加任意属性。例如,我们导出 index.js 和 lib/util.js 的 id 属性:

//在 lib/util.js 顶部添加如下行
exports.id = 'lib/util';
//在 index.js 顶部添加如下行
exports.id = 'index';

当我们现在执行 index.js 的时候,我们将看到这些属性受到模板对象的管理:

~/learn-node $ node index.js
In index Module {
  id: '.',
  exports: { id: 'index' },
  loaded: false,
  ... }
In util Module {
  id: '/Users/samer/learn-node/lib/util.js',
  exports: { id: 'lib/util' },
  parent:
   Module {
     id: '.',
     exports: { id: 'index' },
     loaded: false,
     ... },
  loaded: false,
  ... }

简单起见,我删除了上面输出的一些属性,但要注意 exports 、拥有包含我们在每个模块中定义的属性的方式。你可以在 exports 对象中放尽可能多的属性,你也可以完全替换 exports 对象。例如,改变 exports 对象,使用函数来代替对象,操作如下:

//在index.js中console.log前添加如下一行  
module.exports = function() {};

当你现在运行 index.js 时,你将看到 exports 对象如何作为函数运行:

~/learn-node $ node index.js
In index Module {
  id: '.',
  exports: [Function],
  loaded: false,
  ... }

注意,我们没有使用 exports=function(){} 将 exports 对象转变为函数。我们实际上不能这样做,因为在每一个模块内部,exports 变量仅仅是管理导出属性的 module.exports 的引用。当我们重新赋值 exports 变量时,这个引用会丢失,我们将引入一个新的变量来代替改变的 module.exports 对象。

每个模块中的 module.exports 对象就是通过 require 函数请求那个模块返回的。比如,把 index.js 中的 require('./lib/util') 改为:

const UTIL = require('./lib/util');
console.log('UTIL:', UTIL);

这段代码会输出 lib/util 导出到 UTIL 常量中的属性。现在运行 index.js,输出如下:

UTIL: { id: 'lib/util' }

再来谈谈每个模块的 loaded 属性。到目前为止,每次我们打印一个模块对象的时候,都会看到这个对象的 loaded 属性值为 false。

module 模块使用 loaded 属性来跟踪哪些模块是加载过的(true值),以及哪些模块还在加载中(false 值)。比如我们可以通过调用 setImmediate 来打印 modules 对象,在下一事件循环中看看完成加载的 index.js 模块:

// In index.js
setImmediate(() => {
  console.log('The index.js module object is now loaded!', module)
});

输出是这样的:

The index.js module object is now loaded! Module {
  id: '.',
  exports: [Function],
  parent: null,
  filename: '/Users/samer/learn-node/index.js',
  loaded: true,
  children:
   [ Module {
       id: '/Users/samer/learn-node/lib/util.js',
       exports: [Object],
       parent: [Circular],
       filename: '/Users/samer/learn-node/lib/util.js',
       loaded: true,
       children: [],
       paths: [Object] } ],
  paths:
   [ '/Users/samer/learn-node/node_modules',
     '/Users/samer/node_modules',
     '/Users/node_modules',
     '/node_modules' ] }

注意理解它是如何推迟 console.log,使其在 lib/util.js 和 index.js 加载完成之后再产生输出的。

Node 完成加载模块(并标记)之后 exports 对象就完成了。整个请求/加载某个模块的过程是同步的。因此我们可以在一个事件循环周期过后看到模块已经完成加载。

这也就是说,我们不能异步改变 exports 对象。比如在某个模块中干这样的事情:

fs.readFile('/etc/passwd', (err, data) => {
  if (err) throw err;
  exports.data = data; // Will not work.
});

循环依赖模块

现在来回答关于 Node 循环依赖模块这个重要的问题:如果模块1需要模块2,模块2也需要模块1,会发生什么事情?

为了观察结果,我们在 lib/ 下创建两个文件,module1.js 和 module2.js,它们相互请求对象:

// lib/module1.js
exports.a = 1;
require('./module2');
exports.b = 2;
exports.c = 3;

// lib/module2.js
const Module1 = require('./module1');
console.log('Module1 is partially loaded here', Module1);

运行 module1.js 可以看到:

~/learn-node $ node lib/module1.js
Module1 is partially loaded here { a: 1 }

我们在 module1 完全加载前请求了 module2,而 module2 在未完全加载时又请求了 module1,那么,在那一时刻,能得到的是在循环依赖之前导出的属性。只有 a 属性打印出来了,因为 b 和 c 是在请求了module2 并打印了 module1 之后才导出的。

Node 让这件事变得简单。在加载某个模块的时候,它会创建 exports 对象。你可以在一个模块加载完成之前请求它,但只会得到部分导出的对象,它只包含到目前为止已经定义的项。