ホームページ >ウェブフロントエンド >jsチュートリアル >ノードモジュールシステムとそのモードの詳細な説明
この記事では、主に Node モジュール システムとそのモードについての簡単な説明を紹介します。編集者はそれが非常に優れていると考えたので、参考として共有します。編集者をフォローして見てみましょう。皆さんのお役に立てれば幸いです。
モジュールはアプリケーション構築の基礎であり、関数と変数をプライベートにし、外部に直接公開しないようにします
Node.js ファイルを作成するために、Node のモジュラー システムとその最も一般的に使用されるモードを紹介します。相互に呼び出すことができる、Node.js はシンプルなモジュール システムを提供します。
モジュールは Node.js アプリケーションの基本コンポーネントであり、ファイルとモジュールは 1 対 1 に対応します。言い換えれば、Node.js ファイルはモジュールであり、JavaScript コード、JSON、またはコンパイルされた C/C++ 拡張機能である可能性があります。
モジュールの本質
JavaScript の大きな欠陥は、プログラムがグローバル スコープで実行され、内部アプリケーション コードやサードパーティ依存プログラムで簡単に使用できることであることは誰もが知っています。データが汚染されている場合、非常に一般的な解決策は IIFE を通じて解決することであり、これは基本的にクロージャを使用して解決します
const module = (() => { const privateOne = () => { // ... } const privateTwo = () => { // ... } const exported = { publicOne: () => { // ... }, publicTwo: [] } return exported; })() console.log(module);
上記のコードを通して、モジュール変数には外部に公開された API のみが含まれていることがわかります。残りのモジュールのコンテンツは外の世界からは見えません。これは、Node モジュール システムの中核となる考え方でもあります。
Node モジュールの説明
CommonJS は、JavaScript エコシステムの標準化に特化した組織です。その最も有名な提案の 1 つは、この仕様に基づいて独自のモジュール システムを構築し、いくつかのカスタムを追加したものです。拡張機能がどのように機能するかを説明するために、上記のモジュールの基本的なアイデアを使用して、同様の実装を自分で作成できます。
独自のモジュールローダーを作成します
次のコードは主に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() やノードなどの 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 モジュール システムの内部、つまりモジュールの仕組みをよく理解することができます。が定義されて挟まれている場合、自作モジュールシステムには以下の機能があります
モジュール名はパラメータとして渡され、最初に行うことは渡されたモジュール名に従ってrequire.resolveメソッドを呼び出すことです。モジュール ID (指定された解決アルゴリズムによって生成されます)
モジュールがロードされている場合は、キャッシュから直接取得されます
モジュールがロードされていない場合は、モジュール オブジェクトを初期化します。 2 つの属性、1 つはモジュール ID、もう 1 つはエクスポートです。その初期値は空のオブジェクトです。この属性は、モジュールのエクスポート
モジュール Cache
モジュールを定義する
自家製の 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算法可以分为以下三个主要分支
文件模块(File modules),如果模块名是以"/"开始,则被认为是绝对路径开始,如果是以"./"开始,则表示为相对路径,它从使用该模块的位置开始计算加载模块的位置
核心模块(core modules),如果模块名不是"/"、"./"开始的话,该算法会首先去搜索Node的核心模块
包模块(package modules),如果通过模块名没有在核心模块中找到,那么就会继续在当前目录下的node_modules文件夹下寻找匹配的模块,如果没有,则一级一级往上照,直到到达文件系统的根目录
对于文件和包模块,单个文件和文件夹可以匹配到模块名,特别的,算法将尝试匹配一下内容
3cc21616234705871e1d585c6bd47d2a.js
3cc21616234705871e1d585c6bd47d2a/index.js
在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')会根据加载的模块的位置加载不同的文件
myApp/foo.js 加载的是 /myApp/node_modules/depA/index.js
myApp/node_modules/depB/bar.js 加载的是 /myApp/node_modules/depB/node_modules/depA/index.js
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函数会非常清楚,缓存是提高性能的重要手段,而且他也带来了一些其他的好处
使得在模块依赖关系中,循环依赖变得可行
它保证了在给定的包中,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模块。
上面的写法是很危险的。主要考虑的是拥有修改全局命名空间或其他模块的模块是具有副作用的操作。换句话说,它会影响其范围之外的实体的状态,这可能导致不可预测的后果,特别是当多个模块与相同的实体进行交互时。想象一下,有两个不同的模块尝试设置相同的全局变量,或者修改同一个模块的相同属性,效果可能是不可预测的(哪个模块胜出?),但最重要的是它会对在整个应用程序产生影响。
相关推荐:
以上がノードモジュールシステムとそのモードの詳細な説明の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。