在 Node.js 中引入模块:你所须要知道的一切都在这里

本文做者:Jacob Beltran

编译:胡子大哈 javascript

翻译原文:huziketang.com/blog/posts/…

英文链接:Requiring modules in Node.js: Everything you need to knowhtml

Node 中有两个核心模块来对模块依赖进行管理:前端

  • require 模块。全局范围生效,不须要 require('require')
  • module 模块。全局范围生效,不须要 require('module')

你能够把 require 当作是命令行,而把 module 当作是全部引入模块的组织者。java

在 Node 中引入模块并非什么复杂的概念,见下面例子:node

const config = require('/path/to/file');复制代码

require 引入的对象主要是函数。当 Node 调用 require() 函数,而且传递一个文件路径给它的时候,Node 会经历以下几个步骤:react

  • Resolving:找到文件的绝对路径;
  • Loading:判断文件内容类型;
  • Wrapping:打包,给这个文件赋予一个私有做用范围。这是使 requiremodule 模块在本地引用的一种方法;
  • Evaluating:VM 对加载的代码进行处理的地方;
  • Caching:当再次须要用这个文件的时候,不须要重复一遍上面步骤。

本文中,我会用不一样的例子来解释上面的各个步骤,而且介绍在 Node 中它们对咱们写的模块有什么样的影响。json

为了方便你们看文章和理解命令,我首先建立一个目录,后面的操做都会在这个目录中进行。api

mkdir ~/learn-node && cd ~/learn-node复制代码

文章中接下来的部分都会在 ~/learn-node 文件夹下运行。浏览器

1. Resolving - 解析本地路径

首先来为你介绍 module 对象,能够先在控制台中看一下:缓存

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

每个模块都有 id 属性来惟一标示它。id 一般是文件的完整路径,可是在控制台中通常显示成 <repl>

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 目录中执行 require('find-me') 的时候,因为 learn-node 有本身的 node_modules/find-me.js,这时不会加载 home 目录下的 find-me.js

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

假设咱们把 learn-node 目录下的 node_modules 移到 ~/learn-node,再从新执行 require('find-me') 的话,按照上面规定的顺序查找文件,这时候 home 目录下的 node_modules 就会被使用了。

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

require 一个文件夹

模块不必定非要是文件,也能够是个文件夹。咱们能够在 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.
    {}
    >复制代码

这里注意,咱们本目录下建立了 node_modules 文件夹,就不会使用 home 目录下的 node_modules 了。

当引入一个文件夹的时候,默认会去找 index.js 文件,这也能够手动控制指定到其余文件,利用 package.jsonmain 属性就能够。例如,咱们执行 require('find-me'),而且要从 find-me 文件夹下的 start.js 文件开始解析,那么用 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');复制代码

文件的 parent-child 关系

建立一个文件 lib/util.js 而且写一行 console.log 在里面来标识它,固然,这个 console.log 就是模块自己。

~/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 的 parent 属性中。而 lib/util 并无被列到 index 的 children 属性,而是用一个 [Circular] 代替的。这是由于这是个循环引用,若是这里使用 lib/util 的话,那就变成一个无限循环了。这就是为何在 index 中使用 [Circular] 来替代 lib/util

那么重点来了,若是在 lib/util 中引入了 index 模块会怎么样?这就是咱们所谓的模块循环依赖问题,在 Node 中是容许这样作的。

可是 Node 如何处理这种状况呢?为了更好地理解这一问题,咱们先来了解一下模块对象的其余知识。

2. Loading - exports,module.exports,和模块的同步加载

在全部的模块中,exports 都是一个特殊的对象。若是你有注意的话,上面咱们每次打印模块信息的时候,都有一个是空值的 exports 属性。咱们能够给这个 exports 对象加任何想加的属性,例如在 index.jslib/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 对象变成函数,作法以下:

// 在 index.js 的 console.log 前面添加这行
    module.exports = function() {};复制代码

当你执行 index.js 的时候,你会看到以下信息:

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

这里注意咱们没有使用 export = function() {} 来改变 exports 对象。没有这样作是由于在模块中的 exports 变量其实是 module.exports 的一个引用,而 module.exports 才是控制全部对外属性的。exportsmodule.exports 指向同一块内存,若是把 exports 指向一个函数,那么至关于改变了 exports 的指向,exports 就再也不是引用了。即使你改变了 exportsmodule.exports 也是不变的。

模块的 module.exports 是一个模块的对外接口,就是当你使用 require 函数时所返回的东西。例如把 index.js 中的代码改一下:

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

上面的代码将会捕获 lib/util 中输出的属性,赋值给 UTIL 常量。当执行 index.js 的时候,最后一行将会输出:

UTIL: { id: 'lib/util' }复制代码

接下来聊一下 loaded 属性。上面咱们每次输出模块信息,都能看到一个 loaded 属性,值是 false

module 模块使用 loaded 属性来追踪哪些模块已经加载完毕,哪些模块正在加载。例如咱们能够调用 setImmediate 来打印 module 对象,用它能够看到 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' ] }复制代码

能够注意到 lib/util.jsindex.js 都已经加载完毕了。

当一个模块加载完成的时候,exports 对象才完整,整个加载的过程都是同步的。这也是为何在一个事件循环后全部的模块都处于彻底加载状态的缘由。

这也意味着不能异步改变 exports 对象,例如,对任何模块作下面这样的事情:

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

模块循环依赖

咱们如今来回答上面说到的循环依赖的问题:模块 1 依赖模块 2,模块 2 也依赖模块 1,会发生什么?

如今来建立两个文件,lib/module1.jslib/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。这种状态下,咱们从 exports 对象中能获得的就是在发生循环依赖以前的这部分。上面代码中,只有 a 属性被引入,由于 bc 都须要在引入 module2 以后才能加载进来。

Node 使这个问题简单化,在一个模块加载期间开始建立 exports 对象。若是它须要引入其余模块,而且有循环依赖,那么只能部分引入,也就是只能引入发生循环依赖以前所定义的这部分。

JSON 和 C/C++ 扩展文件

咱们可使用 require 函数本地引入 JSON 文件和 C++ 扩展文件,理论上来说,不须要指定其扩展名。

若是没有指定扩展名,Node 会先尝试将其按 .js 文件来解析,若是不是 .js 文件,再尝试按 .json 文件来解析。若是都不是,会尝试按 .node 二进制文件解析。可是为了使程序更清晰,当引入除了 .js 文件的时候,你都应该指定文件扩展名。

若是你要操做的文件是一些静态配置值,或者是须要按期从外部文件中读取的值,那么引入 JSON 是很好的一个选择。例若有以下的 config.json 文件:

{
      "host": "localhost",
      "port": 8080
    }复制代码

咱们能够直接像这样引用:

const { host, port } = require('./config');
    console.log(`Server will run at http://${host}:${port}`);复制代码

运行上面的代码会获得这样的输出:

Server will run at http://localhost:8080复制代码

若是 Node 按 .js.json 解析都失败的话,它会按 .node 解析,把这个文件当作一个已编译的扩展模块来解析。

Node 文档中有一个 C++ 写的示例扩展文件,它只暴露出一个 hello() 函数,而且函数输出 “world”。

你可使用 node-gyp 包编译 .cc 文件,生成 .addon 文件。只须要配置 binding.gyp 文件来告诉 node-gyp 须要作什么就能够了。

当你有了 addon.node 文件(名字你能够在 binding.gyp 中随意配置)之后,你就能够在本地像引入其余模块同样引入它了:

const addon = require('./addon');
    console.log(addon.hello());复制代码

能够经过 require.extensions 来查看对三种文件的支持状况:

能够清晰地看到 Node 对每种扩展名所使用的函数及其操做:对 .js 文件使用 module._compile;对 .json 文件使用 JSON.parse;对 .node 文件使用 process.dlopen

3. Wrapping - 你在 Node 中所写的全部代码都会被打包成函数

Node 的打包模块不是很好理解,首先要先知道 exports / module.exports 的关系。

咱们能够用 exports 对象来输出属性,可是不能直接对 exports 进行赋值(替换整个 exports 对象),由于它仅仅是 module.exports 的引用。

exports.id = 42; // This is ok.
    exports = { id: 42 }; // This will not work.
    module.exports = { id: 42 }; // This is ok.复制代码

在介绍 Node 的打包过程以前先来了解另外一个问题,一般状况下,在浏览器中咱们在脚本中定义一个变量:

var answer = 42;复制代码

这种方式定义之后,answer 变量就是一个全局变量了。其余脚本中依然能够访问。而 Node 中不是这样,你在一个模块中定义一个变量,程序的其余模块是不能访问的。Node 是如何作到的呢?

答案很简单,在编译成模块以前,Node 把模块代码都打包成函数,能够用 modulewrapper 属性来查看。

~ $ node
    > require('module').wrapper
    [ '(function (exports, require, module, __filename, __dirname) { ',
      '\n});' ]
    >复制代码

Node 并不直接执行你所写的代码,而是把你的代码打包成函数后,执行这个函数。这就是为何一个模块的顶层变量的做用域依然仅限于本模块的缘由。

这个打包函数有 5 个参数:exportsrequiremodule__filename__dirname。函数使变量看起来全局生效,但实际上只在模块内生效。全部的这些参数都在 Node 执行函数时赋值。exports 定义成 module.exports 的引用;requiremodule 都指定为将要执行的这个函数;__filename__dirname 指这个打包模块的绝对路径和目录路径。

在脚本的第一行输入有问题的代码,就能看到 Node 打包的行为;

~/learn-node $ echo "euaohseu" > bad.js
    ~/learn-node $ node bad.js
    ~/bad.js:1
    (function (exports, require, module, __filename, __dirname) { euaohseu
                                                                  ^
    ReferenceError: euaohseu is not defined复制代码

注意这里报告出错误的就是打包函数。

另外,模块都打包成函数了,咱们可使用 arguments 关键字来访问函数的参数:

~/learn-node $ echo "console.log(arguments)" > index.js
    ~/learn-node $ node index.js
    { '0': {},
      '1':
       { [Function: require]
         resolve: [Function: resolve],
         main:
          Module {
            id: '.',
            exports: {},
            parent: null,
            filename: '/Users/samer/index.js',
            loaded: false,
            children: [],
            paths: [Object] },
         extensions: { ... },
         cache: { '/Users/samer/index.js': [Object] } },
      '2':
       Module {
         id: '.',
         exports: {},
         parent: null,
         filename: '/Users/samer/index.js',
         loaded: false,
         children: [],
         paths: [ ... ] },
      '3': '/Users/samer/index.js',
      '4': '/Users/samer' }复制代码

第一个参数是 exports 对象,初始为空;requiremodule 对象都是即将执行的 index.js 的实例;最后两个参数是文件路径和目录路径。

打包函数的返回值是 module.exports。在模块内部,可使用 exports 对象来改变 module.exports 属性,可是不能对 exports 从新赋值,由于它只是 module.exports 的引用。

至关于以下代码:

function (require, module, __filename, __dirname) {
      let exports = module.exports;
      // Your Code...
      return module.exports;
    }复制代码

若是对 exports 从新赋值(改变整个 exports 对象),那它就不是 module.exports 的引用了。这是 JavaScript 引用的工做原理,不只仅是在这里是这样。

4. Evaluating - require 对象

require 没有什么特别的,一般做为一个函数返回 module.exports 对象,函数参数是一个模块名或者一个路径。若是你想的话,尽能够根据本身的逻辑重写 require 对象。

例如,为了达到测试的目的,咱们但愿全部的 require 都默认返回一个 mock 值来替代真实的模块返回值。能够简单地实现以下:

require = function() {
      return { mocked: true };
    }复制代码

这样重写了 require 之后,每一个 require('something') 调用都会返回一个模拟对象。

require 对象也有本身的属性。上面已经见过了 resolve 属性,它的任务是处理引入模块过程当中的解析步骤,上面还提到过 require.extensions 也是 require 的属性。还有 require.main,它用于判断一个脚本是否应该被引入仍是直接执行。

例如,在 print-in-frame.js 中有一个 printInFrame 函数。

// In print-in-frame.js
    const printInFrame = (size, header) => {
      console.log('*'.repeat(size));
      console.log(header);
      console.log('*'.repeat(size));
    };复制代码

函数有两个参数,一个是数字类型参数 size,一个是字符串类型参数 header。函数功能很简单,这里不赘述。

咱们想用两种方式使用这个文件:

1.直接使用命令行:

~/learn-node $ node print-in-frame 8 Hello复制代码

传递 8 和 “Hello” 两个参数进去,打印 8 个星星包裹下的 “Hello”。

2.使用 require。假设所引入的模块对外接口是 printInFrame 函数,咱们能够这样调用:

const print = require('./print-in-frame');
    print(5, 'Hey');复制代码

传递的参数是 5 和 “Hey”。

这是两种不一样的用法,咱们须要一种方法来判断这个文件是做为独立的脚原本运行,仍是须要被引入到其余的脚本中才能执行。可使用简单的 if 语句来实现:

if (require.main === module) {
      // 这个文件直接执行(不须要 require)
    }复制代码

继续演化,可使用不一样的调用方式来实现最初的需求:

// In print-in-frame.js
    const printInFrame = (size, header) => {
      console.log('*'.repeat(size));
      console.log(header);
      console.log('*'.repeat(size));
    };
    if (require.main === module) {
      printInFrame(process.argv[2], process.argv[3]);
    } else {
      module.exports = printInFrame;
    }复制代码

当文件不须要被 require 时,直接经过 process.argv 调用 printInFrame 函数便可。不然直接把 module.exports 变成 printInFrame 就能够了,即模块接口是 printInFrame

5. Caching - 全部的模块都会被缓存

对缓存的理解特别重要,我用简单的例子来解释缓存。

假设你有一个 ascii-art.js 文件,打印很酷的 header:

咱们想要在每次 require 这个文件的时候,都打印出 header。因此把这个文件引入两次:

require('./ascii-art') // 显示 header
    require('./ascii-art') // 不显示 header.复制代码

第二个 require 不会显示 header,由于模块被缓存了。Node 把第一个调用缓存起来,第二次调用的时候就不加载文件了。

能够在第一次引入文件之后,使用 require.cache 来看一下都缓存了什么。缓存中其实是一个对象,这个对象中包含了引入模块的属性。咱们能够从 require.cache 中把相应的属性删掉,以使缓存失效,这样 Node 就会从新加载模块而且将其从新缓存起来。

对于这个问题,这并非最有效的解决方案。最简单的解决方案是把 ascii-art.js 中的打印代码打包成一个函数,而且 export 这个函数。这样当咱们引入 ascii-art.js 文件时,咱们获取到的是这个函数,因此能够每次都能打印出想要的内容了:

require('./ascii-art')() // 打印出 header.
require('./ascii-art')() // 也会打印出 header.复制代码

总结

这就是我所要介绍的内容。回顾一下通篇,分别讲述了:

  • Resolving
  • Loading
  • Wrapping
  • Evaluating
  • Caching

即解析、加载、打包、VM功能处理和缓存五大步骤,以及五大步骤中每一个步骤都涉及到了什么内容。

若是本文对你有帮助,欢迎关注个人专栏-前端大哈,按期发布高质量前端文章。


我最近正在写一本《React.js 小书》,对 React.js 感兴趣的童鞋,欢迎指点

相关文章
相关标签/搜索