>웹 프론트엔드 >JS 튜토리얼 >노드 모듈 시스템 및 모드에 대한 자세한 설명

노드 모듈 시스템 및 모드에 대한 자세한 설명

小云云
小云云원래의
2018-01-18 13:27:401159검색

이 글은 주로 Node 모듈 시스템과 그 모드에 대한 간략한 논의를 소개합니다. 편집자는 그것이 꽤 좋다고 생각하므로 이제 공유하고 참고용으로 제공하겠습니다. 편집자를 따라 살펴보겠습니다. 모두에게 도움이 되기를 바랍니다.

모듈은 애플리케이션 구축의 기초이며 함수와 변수를 비공개로 만들어 외부에 직접 노출되지 않게 합니다. 다음으로 Node의 모듈 시스템과 가장 일반적으로 사용되는 모드를 소개하겠습니다

Node.js 파일을 만들기 위해 Node.js는 서로 호출할 수 있는 간단한 모듈 시스템을 제공합니다.

모듈은 Node.js 애플리케이션의 기본 구성 요소이며 파일과 모듈은 일대일 대응을 갖습니다. 즉, Node.js 파일은 JavaScript 코드, JSON 또는 컴파일된 C/C++ 확장일 수 있는 모듈입니다.

모듈의 본질

우리 모두는 JavaScript의 큰 결함이 네임스페이스 개념이 없다는 것을 알고 있습니다. 프로그램은 전역 범위에서 실행되며 내부 응용 프로그램 코드 또는 타사 종속 프로그램에서 쉽게 사용할 수 있습니다. 데이터 오염에 대한 매우 일반적인 해결책은 IIFE를 통해 해결하는 것인데, IIFE는 이를 해결하기 위해 본질적으로 클로저를 사용합니다

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

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

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

 return exported;
})()

console.log(module);

위 코드를 통해 모듈 변수에는 외부에 노출된 API만 포함되어 있음을 알 수 있습니다. 나머지 모듈 내용은 외부 세계에 보이지 않는데, 이것이 Node 모듈 시스템의 핵심 아이디어이기도 합니다.

노드 모듈 설명

CommonJS는 JavaScript 생태계 표준화에 전념하는 조직입니다. 가장 유명한 제안 중 하나는 잘 알려진 CommonJS 모듈입니다. Node는 이 사양을 기반으로 자체 모듈 시스템을 구축했습니다. 확장 기능이 어떻게 작동하는지 설명하기 위해 위에서 언급한 모듈의 필수 아이디어를 사용하여 유사한 구현을 직접 만들 수 있습니다.

나만의 모듈 로더 만들기

다음 코드는 주로 Node의 원래 require() 함수의 기능을 모방합니다

먼저 모듈의 내용을 로드하고 이를 비공개 범위에 래핑하는 함수를 만듭니다. 소스 코드

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

 eval(warppedSrc);
}

의 모듈은 IIFE와 마찬가지로 함수로 래핑됩니다. 여기서 차이점은 모듈, 특히 module.exports 및 require에 일부 변수를 전달한다는 것입니다. 본질적으로 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
}

위 함수는 기본적으로 사용되는 require 함수의 동작을 시뮬레이션합니다. 물론 이는 프로토타입일 뿐이며 실제 require 함수의 동작을 완전히 반영하지는 않지만 Node 모듈 시스템의 내부 메커니즘, 모듈이 어떻게 작동하는지 잘 이해할 수 있게 해줍니다. 가 정의되고 샌드위치되어 있으므로 자체 제작 모듈 시스템에는 다음과 같은 기능이 있습니다

  1. 모듈 이름이 매개변수로 전달되며, 가장 먼저 해야 할 일은 전달된 모듈 이름에 따라 require.resolve 메소드를 호출하는 것입니다. 모듈 ID(지정된 해결 알고리즘에 의해 생성됨)

  2. 모듈이 로드된 경우 캐시에서 직접 가져옵니다.

  3. 모듈이 로드되지 않은 경우 다음을 포함하는 모듈 객체를 초기화합니다. 두 가지 속성 중 하나는 모듈 ID이고 다른 하나는 내보내기입니다. 이 속성은 모듈 내보내기

  4. 모듈 Cache

  5. Call의 공개 API 코드를 저장하는 데 사용됩니다. 위에서 정의한 loadModule 함수를 사용하여 모듈의 소스 코드를 얻고 초기화된 모듈 객체를 매개변수로 전달합니다. 모듈은 객체이자 참조 유형이므로 모듈은 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函数

위 내용은 노드 모듈 시스템 및 모드에 대한 자세한 설명의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.