We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
随着 Node 的发展,对于前端开发工程师来说,Node的使用场景也变得越来越多,而 Node 中的模块系统是 Node 的基础。那么什么是 Node 模块呢?
它可以说是构建应用程序的基础,也使得函数和变量私有化,不直接对外暴露出来,接下来我们就要介绍进一步详细的介绍 Node 的模块化系统和它最常用的模式。
我们都知道,JavaScript 有一个很大的缺陷就是缺少 namespacing 的概念,程序运行在全局作用域下,很容易被内部应用程序的代码或者是第三方依赖程序的数据所污染,一个很典型的解决方案就使通过 IIFE 来解决,本质上是利用闭包来解决
const module = (() => { const privateOne = () => { // ... } const privateTwo = () => { // ... } const exported = { publicOne: () => { // ... }, publicTwo: [] } return exported; })() console.log(module);
通过上面的代码,我们可以看出,module 变量包含的只有对外暴露的 API,然而剩下的 module 内容是对外不可见的,而这个也是 Node module system 最核心的思想。
module
Node module system
当说 Node Modlues 之前,我们首先先来看一下 CommonJS 规范, CommonJS 它是一个致力于将 JavaScript 生态系统标准化的一个组织,它最出名的一个提议就是我们众所周知的 CommonJS modules ,Node 则是在本规范的基础上构建了他自己的模块系统,并且添加了一些自定义扩展。
CommonJS
CommonJS modules
我们将从以下两个角度来阐述 module 的思想。
为了描述它是怎么工作的,我们来做一个类似的实现
下面的代码主要是模仿Node原始的 require() 函数的功能
require()
首先,我们创建一个函数用来加载一个 module 的内容,将它包裹在一个私有的作用域中
function loadModule(filename, module, require) { const warppedSrc = `(function(module, exports, require) { ${fs.readFileSync(filename, 'utf-8')} })(module, module.exports, require)` eval(warppedSrc); }
module 的源代码被包装到一个函数中,如同 IIFE 那样,这里的区别在于我们传递了一些变量给 module ,特指 module 、 module.exports 和 require ,注意的是我们的 exports 变量实质上是又 module.exports 初始化的,我们接下来会继续讨论这个
module.exports
require
exports
在这个例子中,需要注意的是,我们使用了类似 eval() 或者是 node 的 vm 模块,它们可能会导致一些代码注入攻击的安全性问题,所以我们需要特别注意和避免
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 }
上面的函数模拟了 Nodejs 原生用来加载模块的 require 函数的行为,当然,它只是具有一个雏形,而没有完全准确的反映真实的 require 函数的行为,但是它可以让我们很好的理解 Node 模块系统的内部机制,一个模块怎么被定义和被夹在,我们的自制模块系统具备下面的功能
require.resolve
module id
resolve
export
loadModule
看到这里,我们会发现,其实在 Node 模块系统没有想象中的那么难,真正的技巧在于将模块的代码进行包装,以及创建一个运行时的虚拟环境。
那么通过上面的代码,我们更加细致的进一步阐述 Node Module 的模块加载的原理
很多不熟悉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'); }
相依性地狱(dependency hell)描述的是由于软件之间的依赖性不能被满足从而导致的问题,软件的依赖反过来取决于其他的依赖,但是需要不同的兼容版本。Node 很好的解决了这个问题通过加载不同版本的模块,具体取决于该模块从哪里被加载。这个特性的所有优点都能在 npm 上体现,并且也在 require 函数的 resolving 算法中使用
然我们来快速连接下这个算法,我们都知道,resolve() 函数获取模块名作为输入,然后返回一个模块的全路径,该路金用于加载它的代码也作为该模块唯一的标识。resolcing 算法可以分为以下三个主要分支
resolve()
node_modules
对于文件和包模块,单个文件和文件夹可以匹配到模块名,特别的,算法将尝试匹配一下内容
算法文档
每个包通过 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') 会根据加载的模块的位置加载不同的文件
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() 来使用
require.resolve()
每个模块都会在它第一次被 require 的时候加载和计算,然后随后的require 会返回缓存的版本,这一点通过看我们自制的 require 函数会非常清楚,缓存是提高性能的重要手段,而且他也带来了一些其他的好处
模块的缓存通过变量 require.cache 暴露出来,所以如果需要的话,可以直接获取,一个很常见的使用场景是通过删除 require.cache 的 key 值使得某个模块的缓存失效,但是不建议在非测试环境下去使用这个功能
require.cache
很多人会认为循环依赖是自身设计的问题,但是这确实是在真实的项目中会发生的问题,所以我们很有必要去弄清楚在 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中调用
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
其实造成这个的原因主要是因为缓存的原因,当我们先引入 a.js 的时候,到达去引入 b.js 的时候,这个时候 require.cache 已经有了关于 a.js 的缓存,所以在 b.js 模块中,去引入 a.js 的时候,直接返回的是 require.cache 中关于 a.js 的缓存,也就是不完全的 a.js 模块,对于 b.js 也是一样的操作,才会得出上面的结果
通过观察我们自制的 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 ,任何分配到这个变量的变量都会自动的变成全局变量
global
需要注意的是,污染全局作用域是一个很不好的事情,甚至使得让模块系统的优点消失,所以只有当你自己知道你要做什么时候,才去使用它
模块系统除了成为一个加载依赖的机制意外,也是一个很好的工具去定义 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 的一个扩展功能
CommmonJS
另一个很常见的就是将整个 module.exports 作为一个函数对外暴露,它主要的优点在于只暴露了一个函数,使得提供了一个很清晰的模块的入口,易于理解和使用,这种模式也被社区称为 substack pattern
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 模块是 Node 的基础,本文主要从模块的定义和模块的加载两个角度对Node模块系统进行了阐述,希望能够为大家进一步理解 Node 模块提供帮助,谢谢。
The text was updated successfully, but these errors were encountered:
No branches or pull requests
随着 Node 的发展,对于前端开发工程师来说,Node的使用场景也变得越来越多,而 Node 中的模块系统是 Node 的基础。那么什么是 Node 模块呢?
它可以说是构建应用程序的基础,也使得函数和变量私有化,不直接对外暴露出来,接下来我们就要介绍进一步详细的介绍 Node 的模块化系统和它最常用的模式。
一、Module
1.1 Module 的本质
我们都知道,JavaScript 有一个很大的缺陷就是缺少 namespacing 的概念,程序运行在全局作用域下,很容易被内部应用程序的代码或者是第三方依赖程序的数据所污染,一个很典型的解决方案就使通过 IIFE 来解决,本质上是利用闭包来解决
通过上面的代码,我们可以看出,
module
变量包含的只有对外暴露的 API,然而剩下的module
内容是对外不可见的,而这个也是Node module system
最核心的思想。二、Node modlues
当说 Node Modlues 之前,我们首先先来看一下
CommonJS
规范,CommonJS
它是一个致力于将 JavaScript 生态系统标准化的一个组织,它最出名的一个提议就是我们众所周知的CommonJS modules
,Node 则是在本规范的基础上构建了他自己的模块系统,并且添加了一些自定义扩展。我们将从以下两个角度来阐述 module 的思想。
2.1 模块的加载
为了描述它是怎么工作的,我们来做一个类似的实现
下面的代码主要是模仿Node原始的
require()
函数的功能首先,我们创建一个函数用来加载一个
module
的内容,将它包裹在一个私有的作用域中module
的源代码被包装到一个函数中,如同 IIFE 那样,这里的区别在于我们传递了一些变量给module
,特指module
、module.exports
和require
,注意的是我们的exports
变量实质上是又module.exports
初始化的,我们接下来会继续讨论这个在这个例子中,需要注意的是,我们使用了类似
eval()
或者是 node 的vm
模块,它们可能会导致一些代码注入攻击的安全性问题,所以我们需要特别注意和避免接下来,让我们通过实现我们的
require()
函数,来看看这些变量怎么被引入的上面的函数模拟了 Nodejs 原生用来加载模块的
require
函数的行为,当然,它只是具有一个雏形,而没有完全准确的反映真实的require
函数的行为,但是它可以让我们很好的理解 Node 模块系统的内部机制,一个模块怎么被定义和被夹在,我们的自制模块系统具备下面的功能require.resolve
方法根据传入的模块名生成module id
(通过指定的resolve
算法来生成)module
对象,其中包含两个属性,一个是module id
,另外一个属性是exports
,它的初始值为一个空对象,该属性会被用于保存模块的export
的公共的API代码module
进行cacheloadModule
函数来获取模块的源代码,将初始化的module
对象作为参数传入,因为module
是对象,引用类型,所以模块可以利用module.exports
或者是替换module.exports
来暴露它的公共APImodule.exports
的内容,也就是该模块的公共API看到这里,我们会发现,其实在 Node 模块系统没有想象中的那么难,真正的技巧在于将模块的代码进行包装,以及创建一个运行时的虚拟环境。
那么通过上面的代码,我们更加细致的进一步阐述 Node Module 的模块加载的原理
2.1.1 module.exports VS exports
很多不熟悉Node的开发同学,会对于
module.exports
和exports
非常的困惑,通过上面的代码我们很直观的明白,exports
只是module.exports
的一个引用,而且在模块加载之前它本质上只是一个简单的对象这意味着我们可以将新属性挂载到
exports
引用上如果是对
exports
重新赋值,也不会有影响,因为这个时候exports
是一个新的对象,而不再是module.exports
的引用,所以不会改变module.exports
的内容。所以下面的代码是错误的如果你想暴露的不是一个对象,或者是函数、实例或者是一个字符串,那可以通过
module.exports
来做2.1.2 resolving 算法
相依性地狱(dependency hell)描述的是由于软件之间的依赖性不能被满足从而导致的问题,软件的依赖反过来取决于其他的依赖,但是需要不同的兼容版本。Node 很好的解决了这个问题通过加载不同版本的模块,具体取决于该模块从哪里被加载。这个特性的所有优点都能在 npm 上体现,并且也在
require
函数的 resolving 算法中使用然我们来快速连接下这个算法,我们都知道,
resolve()
函数获取模块名作为输入,然后返回一个模块的全路径,该路金用于加载它的代码也作为该模块唯一的标识。resolcing 算法可以分为以下三个主要分支node_modules
文件夹下寻找匹配的模块,如果没有,则一级一级往上照,直到到达文件系统的根目录对于文件和包模块,单个文件和文件夹可以匹配到模块名,特别的,算法将尝试匹配一下内容
算法文档
每个包通过 npm 安装的依赖会放在
node_modules
文件夹下,这就意味着,按照我们刚刚算法的描述,每个包都会有它自己私有的依赖。通过看上面的文件夹结构,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()
来使用2.1.3 模块缓存(module cache)
每个模块都会在它第一次被
require
的时候加载和计算,然后随后的require
会返回缓存的版本,这一点通过看我们自制的require
函数会非常清楚,缓存是提高性能的重要手段,而且他也带来了一些其他的好处require
相同的模块总是会返回相同的实例模块的缓存通过变量
require.cache
暴露出来,所以如果需要的话,可以直接获取,一个很常见的使用场景是通过删除require.cache
的 key 值使得某个模块的缓存失效,但是不建议在非测试环境下去使用这个功能2.1.4 循环依赖
很多人会认为循环依赖是自身设计的问题,但是这确实是在真实的项目中会发生的问题,所以我们很有必要去弄清楚在 Node 内部是怎么工作的。然我们通过我们自制的
require
函数来看看有没有什么问题定义两个模块
在
main.js
中调用最后的结果是
这个结果揭示了循环依赖的注意事项,虽然在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
也是一样的操作,才会得出上面的结果2.2 模块的定义
通过观察我们自制的
require()
函数的工作机制,我们应该很清楚的知道如何定义一个模块最重要的是要记住在模块里面,除了被分配给
module.exports
的变量,其他的都是该模块私有的,在使用require()
加载后,这些变量的内容将会被缓存并返回。2.2.1 在模块中定义全局变量
即使所有的变量和函数都在模块本身的作用域内声明的,但是仍然可以定义全局变量,事实上,模块系统暴露一个用来定义全局变量的特殊变量
global
,任何分配到这个变量的变量都会自动的变成全局变量需要注意的是,污染全局作用域是一个很不好的事情,甚至使得让模块系统的优点消失,所以只有当你自己知道你要做什么时候,才去使用它
2.2.2 模块定义的技巧
模块系统除了成为一个加载依赖的机制意外,也是一个很好的工具去定义 API,对于 API 设计的主要问题,是去考虑私有和公有功能的平衡,最大的隐藏内部实现细节,对外暴露出 API 的可用性,而且还需要对软件的扩展性和可用性等的平衡
接下来来介绍几种在 Node 中常见的定义模块的方法
2.2.2.1 命名导出
这也是最常见的一种方法,通过将值挂载到
exports
或者是module.exports
上,通过这种方法,对外暴露的对象成为了一个容器或者是命名空间很多Node的核心模块都使用的这种模式
其实在
CommonJS
规范中,只允许使用exports
对外暴露公共成员,因此该方法是唯一的真的符合CommmonJS
规范的,对于通过module.exports
去暴露的,都是Node 的一个扩展功能2.2.2.2 函数导出
另一个很常见的就是将整个
module.exports
作为一个函数对外暴露,它主要的优点在于只暴露了一个函数,使得提供了一个很清晰的模块的入口,易于理解和使用,这种模式也被社区称为substack pattern
该模式的的一个扩展就是将上面提到的命名导出组合起来,虽然它仍然只是提供了一个入口点,但是可以使用次要的功能
虽然看起来暴露一个函数是一个限制,但是它是一个很完美的方式,把重点放在一个函数中,代表该函数是这个模块最重要的功能,而且使得内部私有变量属性变的更透明
Node 的模块化也鼓励我们使用单一职责原则,每个模块应该对单个功能负责,从而保证模块的复用性
2.2.2.3 构造函数导出
将构造函数导出,是一个函数导出的特例,但是区别在于它可以使得用户通过它区创建一个实例,但是我们仍然继承了它的 prototype 属性,类似于类的概念
2.2.2.4 实例导出
我们可以利用
require
的缓存机制轻松的定义从构造函数或者是工厂实例化的实例,可以在不同的模块中共享输出的结果是
该模式很像单例模式,它并不保证整个应用程序的实例的唯一性,因为一个模块很可能存在一个依赖树,所以可能会有多个依赖,但是不是在同一个 package 中
2.2.3 修改其他的模块或者全局作用域
一个模块甚至可以导出任何东西这可以看起来有点不合适;但是,我们不应该忘记一个模块可以修改全局范围和其中的任何对象,包括缓存中的其他模块。请注意,这些通常被认为是不好的做法,但是由于这种模式在某些情况下(例如测试)可能是有用和安全的,有时确实可以利用这一特性,这是值得了解和理解的。我们说一个模块可以修改全局范围内的其他模块或对象。它通常是指在运行时修改现有对象以更改或扩展其行为或应用的临时更改。
以下示例显示了我们如何向另一个模块添加新函数
在上述代码中,必须首先引入 patcher 程序才能使用 logger 模块。
上面的写法是很危险的。主要考虑的是拥有修改全局命名空间或其他模块的模块是具有副作用的操作。换句话说,它会影响其范围之外的实体的状态,这可能导致不可预测的后果,特别是当多个模块与相同的实体进行交互时。想象一下,有两个不同的模块尝试设置相同的全局变量,或者修改同一个模块的相同属性,效果可能是不可预测的(哪个模块胜出?),但最重要的是它会对在整个应用程序产生影响。
三、结束语
Node 模块是 Node 的基础,本文主要从模块的定义和模块的加载两个角度对Node模块系统进行了阐述,希望能够为大家进一步理解 Node 模块提供帮助,谢谢。
The text was updated successfully, but these errors were encountered: