首頁 >web前端 >js教程 >Node模組系統及其模式詳解

Node模組系統及其模式詳解

小云云
小云云原創
2018-01-18 13:27:401132瀏覽

本文主要介紹了淺談Node模組系統及其模式,小編覺得蠻不錯的,現在分享給大家,也給大家做個參考。一起跟著小編過來看看吧,希望能幫助大家。

模組是建立應用程式的基礎,也讓函數和變數私有化,不直接對外暴露出來,接下來我們就要介紹Node的模組化系統和它最常用的模式

#為了讓Node.js的檔案可以互相調用,Node.js提供了一個簡單的模組系統。

模組是Node.js 應用程式的基本組成部分,檔案和模組是一一對應的。換言之,一個 Node.js 檔案就是一個模組,而這個檔案可能是JavaScript 程式碼、JSON 或編譯過的C/C++ 擴充功能。

module的本質

我們都知道,JavaScript有一個很大的缺陷就是缺少namespacing的概念,程式運行在全域作用域下,很容易被內部應用程式的程式碼或第三方依賴程式的資料所污染,一個很典型的解決方案就使透過IIFE來解決,本質上是利用閉包來解決

const module = (() => {
 const privateOne = () => {
  // ... 
 }

 const privateTwo = () => {
  // ... 
 }

 const exported = {
  publicOne: () => {
   // ...
  },
  publicTwo: []
 }

 return exported;
})()

console.log(module);

透過上面的程式碼,我們可以看出,module變數包含的只有對外暴露的API,然而剩下的module內容是對外不可見的,而這個也是Node module system最核心的想法。

Node modules 說明

CommonJS是一個致力於將JavaScript生態系統標準化的一個組織,它最出名的一個提議就是我們眾所周知的CommonJS modules,Node在本規範的基礎上建構了他自己的模組系統,並且添加了一些自訂擴展,為了描述它是怎麼工作的,我們可以使用上面所提到的module的本質的思想,自己做一個類似的實現。

自製一個module loader

下面的程式碼主要是模仿Node原始的require()函數的功能

首先,我們建立一個函數用來載入一個module的內容,將它包裹在一個私有的作用域中

function loadModule(filename, module, require) {
 const warppedSrc = `(function(module, mexports, require) {
  ${fs.readFileSync(filename, 'utf-8')}
 })(module, module.exports, require)`

 eval(warppedSrc);
}

module的源代碼被包裝到一個函數中,如同IIFE那樣,這裡的差異在於我們傳遞了一些變數給module,特別指module、module.exports和require,注意的是我們的exports變數實質上是又module.exports初始化的,我們接下來會繼續討論這個

*在這個例子中,需要注意的是,我們使用了類似eval()或是node的vm模組,它們可能會導致一些程式碼注入攻擊的安全性問題,所以我們需要特別注意並避免

接下來,讓我們透過實作我們的require()函數,來看看這些變數怎麼被引入的

#
const require = (moduleName) => {
 console.log(`Required invoked for module: ${moduleName}`);
 const id = require.resolve(moduleName);
 if(require.cache[id]) {
  return require.cache[id].exports;
 }

 // module structure data
 const module = {
  exports: {},
  id: id
 }

 // uodate cache
 require.cache[id] = module;

 // load the module
 loadModule(id, module, require);

 // return exported variables
 return module.exports;
}

require.cache = {};
require.resolve = (moduleName) => {
 // resolve a full module id from the moduleName
}

##上面的函數模擬了Nodejs原生用來載入模組的require函數的行為,當然,它只是具有一個雛形,而沒有完全準確的反映真實的require函數的行為,但是它可以讓我們很好的理解Node模組系統的內部機制,一個模組怎麼被定義和被夾在,我們的自製模組系統具備下面的功能

  1. #模組名被當作參數傳入,首先要做的事情時呼叫require.resolve方法根據傳入的模組名稱產生module id(透過指定的resolve演算法來產生)

  2. 如果該模組已經載入過了,那麼直接會從快取中獲得

  3. 如果該模組還沒有被載入過,我們會初始化一個module對象,其中包含兩個屬性,一個是module id,另外一個屬性是exports,它的初始值為一個空對象,這個屬性會被用來保存模組的export的公共的API程式碼

  4. 將該module進行cache

  5. 呼叫我們上面定義的loadModule函數來取得模組的原始碼,將初始化的module物件作為參數傳入,因為module是對象,引用類型,所以模組可以利用module.exports或替換module.exports來暴露它的公共API

  6. #最後,回傳給呼叫者module.exports的內容,也就是該模組的公共API

看到這裡,我們會發現,其實在Node 模組系統沒有想像中的那麼難,真正的技巧在於將模組的程式碼包裝,以及創造一個運行時的虛擬環境。

定義一個模組

透過觀察我們自製的require()函數的工作機制,我們應該很清楚的知道如何定義一個模組

const dependency = require('./anotherModule');

function log() {
 console.log(`get another ${dependency.username}`);
}

module.exports.run = () => {
 log();
}


// anotherModule.js

module.exports = {
 username: 'wingerwang'
}

最重要的是要記住在模組裡面,除了被指派給module.exports的變量,其他的都是該模組私有的,在使用require()載入後,這些變數的內容將會被快取並傳回。

定義全域變數

即使所有的變數和函數都在模組本身的作用域內宣告的,但是仍然可以定義全域變數,事實上,模組系統暴露一個用來定義全域變數的特殊變數global,任何分配到這個變數的變數都會自動的變成全域變數

需要注意的是,污染全局作用域是一个很不好的事情,甚至使得让模块系统的优点消失,所以只有当你自己知道你要做什么时候,才去使用它

module.exports VS exports

很多不熟悉Node的开发同学,会对于module.exports和exports非常的困惑,通过上面的代码我们很直观的明白,exports只是module.exports的一个引用,而且在模块加载之前它本质上只是一个简单的对象

这意味着我们可以将新属性挂载到exports引用上

exports.hello = () => {
 console.log('hello');
}

如果是对exports重新赋值,也不会有影响,因为这个时候exports是一个新的对象,而不再是module.exports的引用,所以不会改变module.exports的内容。所以下面的代码是错误的

exports = () => {
 console.log('hello');
}

如果你想暴露的不是一个对象,或者是函数、实例或者是一个字符串,那可以通过module.exports来做

module.exports = () => {
 console.log('hello');
}

require函数是同步的

另外一个重要的我们需要注意的细节是,我们自建的require函数是同步的,事实上,它返回模块内容的方法很简单,并且不需要回调函数。Node内置的require()函数也是如此。因此,对于module.exports内容必须是同步的

// incorret code
setTimeout(() => {
 module.exports = function(){}
}, 100)

这个性质对于我们定义模块的方法十分重要,使得限制我们在定义模块的时候使用同步的代码。这也是为什么Node提供了很多同步API给我们的最重要的原因之一

如果我们需要定义一个异步操作来进行初始化的模块,我们也可以这么做,但是这种方法的问题是,我们不能保证require进来的模块能够准备好,后续我们会讨论这个问题的解决方案

其实,在早期的Node版本里,是有异步的require方法的,但是因为它的初始化时间和异步I/O所带来的性能消耗而废除了

resolving 算法

相依性地狱(dependency hell)描述的是由于软件之间的依赖性不能被满足从而导致的问题,软件的依赖反过来取决于其他的依赖,但是需要不同的兼容版本。Node很好的解决了这个问题通过加载不同版本的模块,具体取决于该模块从哪里被加载。这个特性的所有优点都能在npm上体现,并且也在require函数的resolving 算法中使用

然我们来快速连接下这个算法,我们都知道,resolve()函数获取模块名作为输入,然后返回一个模块的全路径,该路金用于加载它的代码也作为该模块唯一的标识。resolcing算法可以分为以下三个主要分支

  1. 文件模块(File modules),如果模块名是以"/"开始,则被认为是绝对路径开始,如果是以"./"开始,则表示为相对路径,它从使用该模块的位置开始计算加载模块的位置

  2. 核心模块(core modules),如果模块名不是"/"、"./"开始的话,该算法会首先去搜索Node的核心模块

  3. 包模块(package modules),如果通过模块名没有在核心模块中找到,那么就会继续在当前目录下的node_modules文件夹下寻找匹配的模块,如果没有,则一级一级往上照,直到到达文件系统的根目录

对于文件和包模块,单个文件和文件夹可以匹配到模块名,特别的,算法将尝试匹配一下内容

  1. 3cc21616234705871e1d585c6bd47d2a.js

  2. 3cc21616234705871e1d585c6bd47d2a/index.js

  3. 在3cc21616234705871e1d585c6bd47d2a/package main中指定的目录/文件

算法文档

每个包通过npm安装的依赖会放在node_modules文件夹下,这就意味着,按照我们刚刚算法的描述,每个包都会有它自己私有的依赖。

myApp
├── foo.js
└── node_modules
 ├── depA
 │ └── index.js
 └── depB
  │
  ├── bar.js
  ├── node_modules
  ├── depA
  │ └── index.js
  └── depC
    ├── foobar.js
    └── node_modules
     └── depA
      └── index.js

通过看上面的文件夹结构,myApp、depb和depC都依赖depA,但是他们都有自己私有的依赖版本,根据上面所说的算法的规则,当使用require('depA')会根据加载的模块的位置加载不同的文件

  1. myApp/foo.js 加载的是 /myApp/node_modules/depA/index.js

  2. myApp/node_modules/depB/bar.js 加载的是 /myApp/node_modules/depB/node_modules/depA/index.js

  3. myApp/node_modules/depB/depC/foobar.js 加载的是 /myApp/node_modules/depB/depC/node_modules/depA/index.js

resolving算法是保证Node依赖管理的核心部分,它的存在使得即便应用程序拥有成百上千个包的情况下也不会出现冲突和版本不兼容的问题

当我们使用require()时,resolving算法对于我们是透明的,然后,如果需要的话,也可以在模块中直接通过调用require.resolve()来使用

模块缓存(module cache)

每个模块都会在它第一次被require的时候加载和计算,然后随后的require会返回缓存的版本,这一点通过看我们自制的require函数会非常清楚,缓存是提高性能的重要手段,而且他也带来了一些其他的好处

  1. 使得在模块依赖关系中,循环依赖变得可行

  2. 它保证了在给定的包中,require相同的模块总是会返回相同的实例

模块的缓存通过变量require.cache暴露出来,所以如果需要的话,可以直接获取,一个很常见的使用场景是通过删除require.cache的key值使得某个模块的缓存失效,但是不建议在非测试环境下去使用这个功能

循环依赖

很多人会认为循环依赖是自身设计的问题,但是这确实是在真实的项目中会发生的问题,所以我们很有必要去弄清楚在Node内部是怎么工作的。然我们通过我们自制的require函数来看看有没有什么问题

定义两个模块

// a.js
exports.loaded = false;
const b = require('./b.js');

module.exports = {
 bWasLoaded: b.loaded,
 loaded: true
}

// b.js
exports.loaded = false;
const a = require('./a.js');

module.exports = {
 aWasLoaded: a.loaded,
 loaded: true
}

在main.js中调用

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

最后的结果是

{ bWasLoaded: true, loaded: true }
{ aWasLoaded: false, loaded: true }

这个结果揭示了循环依赖的注意事项,虽然在main主模块require两个模块的时候,它们已经完成了初始化,但是a.js模块是没有完成的,这种状态将会持续到它把模块b.js加载完,这种情况需要我们值得注意

其实造成这个的原因主要是因为缓存的原因,当我们先引入a.js的时候,到达去引入b.js的时候,这个时候require.cache已经有了关于a.js的缓存,所以在b.js模块中,去引入a.js的时候,直接返回的是require.cache中关于a.js的缓存,也就是不完全的a.js模块,对于b.js也是一样的操作,才会得出上面的结果

模块定义技巧

模块系统除了成为一个加载依赖的机制意外,也是一个很好的工具去定义API,对于API设计的主要问题,是去考虑私有和公有功能的平衡,最大的隐藏内部实现细节,对外暴露出API的可用性,而且还需要对软件的扩展性和可用性等的平衡

接下来来介绍几种在Node中常见的定义模块的方法

命名导出

这也是最常见的一种方法,通过将值挂载到exports或者是module.exports上,通过这种方法,对外暴露的对象成为了一个容器或者是命名空间

// logger.js

exports.info = function(message) {
 console.log('info:' + message);
}

exports.verbose = function(message) {
 console.log('verbose:' + message)
}

// main.js
const logger = require('./logger.js');
logger.info('hello');
logger.verbose('world');

很多Node的核心模块都使用的这种模式

其实在CommonJS规范中,只允许使用exports对外暴露公共成员,因此该方法是唯一的真的符合CommmonJS规范的,对于通过module.exports去暴露的,都是Node的一个扩展功能

函数导出

另一个很常见的就是将整个module.exports作为一个函数对外暴露,它主要的优点在于只暴露了一个函数,使得提供了一个很清晰的模块的入口,易于理解和使用,这种模式也被社区称为substack pattern

// logger.js
module.exports = function(message) {
 // ...
}

该模式的的一个扩展就是将上面提到的命名导出组合起来,虽然它仍然只是提供了一个入口点,但是可以使用次要的功能

module.exports.verbose = function(message) {
 // ...
}

虽然看起来暴露一个函数是一个限制,但是它是一个很完美的方式,把重点放在一个函数中,代表该函数是这个模块最重要的功能,而且使得内部私有变量属性变的更透明

Node的模块化也鼓励我们使用单一职责原则,每个模块应该对单个功能负责,从而保证模块的复用性

构造函数导出

将构造函数导出,是一个函数导出的特例,但是区别在于它可以使得用户通过它区创建一个实例,但是我们仍然继承了它的prototype属性,类似于类的概念

class Logger {
 constructor(name) {
  this.name = name;
 }

 log(message) {
  // ...
 }

 info(message) {
  // ...
 }

 verbose(message) {
  // ...
 }
}

const Logger = require('./logger');
const dbLogger = new Logger('DB');
// ...

实例导出

我们可以利用require的缓存机制轻松的定义从构造函数或者是工厂实例化的实例,可以在不同的模块中共享

// count.js
function Count() {
 this.count = 0;
}

Count.prototype.add = function() {
 this.count++;
}

module.exports = new Count();

// a.js
const count = require('./count');

count.add();
console.log(count.count)

// b.js

const count = require('./count');

count.add();
console.log(count.count)

// main.js

const a = require('./a');
const b = require('./b');

输出的结果是

1
2

该模式很像单例模式,它并不保证整个应用程序的实例的唯一性,因为一个模块很可能存在一个依赖树,所以可能会有多个依赖,但是不是在同一个package中

修改其他的模块或者全局作用域

一个模块甚至可以导出任何东西这可以看起来有点不合适;但是,我们不应该忘记一个模块可以修改全局范围和其中的任何对象,包括缓存中的其他模块。请注意,这些通常被认为是不好的做法,但是由于这种模式在某些情况下(例如测试)可能是有用和安全的,有时确实可以利用这一特性,这是值得了解和理解的。我们说一个模块可以修改全局范围内的其他模块或对象。它通常是指在运行时修改现有对象以更改或扩展其行为或应用的临时更改。

以下示例显示了我们如何向另一个模块添加新函数

// file patcher.js
// ./logger is another module
require('./logger').customMessage = () => console.log('This is a new functionality');

// file main.js
require('./patcher');
const logger = require('./logger');
logger.customMessage();

在上述代码中,必须首先引入patcher程序才能使用logger模块。

上面的写法是很危险的。主要考虑的是拥有修改全局命名空间或其他模块的模块是具有副作用的操作。换句话说,它会影响其范围之外的实体的状态,这可能导致不可预测的后果,特别是当多个模块与相同的实体进行交互时。想象一下,有两个不同的模块尝试设置相同的全局变量,或者修改同一个模块的相同属性,效果可能是不可预测的(哪个模块胜出?),但最重要的是它会对在整个应用程序产生影响。

相关推荐:

node模块与npm包管理工具分析

node模块机制与异步处理详解_node.js

Node.js中如何使用async函数

以上是Node模組系統及其模式詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn