模块定义模式

模块系统除了是一种加载依赖的机制之外,也是一种定义 API 的工具。 与 API 设计相关的任何其他问题一样,要考虑的主要因素是私有功能和公共功能之间的平衡。 目的是最大限度地提高信息隐藏和 API 可用性,同时平衡这些与其他软件质量,例如可扩展性和代码重用。

在本节中,我们将分析 Node.js 中定义模块的一些最流行的模式,例如命名导出、导出函数、类和实例以及猴子修补(monkey patching)。 每一种方法都有自己的信息隐藏、可扩展性和代码重用之间的平衡。

命名导出

公开公共 API 的最基本方法是使用命名导出,这涉及将我们想要公开的值分配给 exports(或 module.exports)引用的对象的属性。 通过这种方式,生成的导出对象成为一组相关功能的容器或命名空间。

以下代码显示了实现此模式的模块:

// file logger.js
exports.info = (message) => {
    console.log(`info: ${message}`)
}

exports.verbose = (message) => {
    console.log(`verbose: ${message}`)
}
javascript

然后导出的函数可作为加载模块的属性使用,如以下代码所示:

// file main.js
const logger = require('./logger')
logger.info('This is an informational message')
logger.verbose('This is a verbose message')
javascript

大多数 Node.js 核心模块都使用这种模式。 然而,CommonJS 规范只允许使用exports 变量来公开公共成员。 因此,命名导出模式是唯一真正与 CommonJS 规范兼容的模式。 module.exports 的使用是 Node.js 提供的扩展,用于支持更广泛的模块定义模式,我们接下来将看到这些模式。

导出函数

最流行的模块定义模式之一是将整个 module.exports 变量重新分配给函数值。 这种模式的主要优点是它允许您仅公开单个函数,这为模块提供了清晰的入口点,使其更易于理解和使用; 它还很好地遵循了小表面积(small surface)的原则。 这种定义模块的方式在社区中也被称为子堆栈模式,以其最多产的采用者之一 James Halliday(昵称子堆栈 - https://github.com/substack )命名。 在以下示例中查看此模式:

// file logger.js
module.exports = (message) => {
    console.log(`info: ${message}`)
}
javascript

此模式的一个可能的扩展是使用导出的函数作为其他公共 API 的命名空间。 这是一个非常强大的组合,因为它仍然为模块提供了单个入口点(主要导出函数)的清晰度,同时它允许我们公开具有辅助或更高级用例的其他功能。 下面的代码向我们展示了如何使用导出的函数作为命名空间来扩展我们之前定义的模块:

module.exports.verbose = (message) => {
    console.log(`verbose: ${message}`)
}
javascript

此代码演示了如何使用我们刚刚定义的模块:

// file main.js
const logger = require('./logger')
logger('This is an informational message')
logger.verbose('This is a verbose message')
javascript

尽管仅导出一个函数似乎是一种限制,但实际上,这是一种完美的方式,可以强调单个功能(模块最重要的功能),同时减少次要或内部方面的可见性,而这些方面反而暴露出来 作为导出函数本身的属性。 Node.js 的模块化极大地鼓励采用单一职责原则(SRP):每个模块都应该负责单个功能,并且该职责应该完全由模块封装。

导出一个类

导出类的模块是导出函数的模块的特例。 不同之处在于,通过这种新模式,我们允许用户使用构造函数创建新实例,但我们也让他们能够扩展其原型并创建新类。 以下是此模式的示例:

class Logger {
    constructor (name) {
        this.name = name
    }
    log (message) {
        console.log(`[${this.name}] ${message}`)
    }
    info (message) {
        this.log(`info: ${message}`)
    }
    verbose (message) {
        this.log(`verbose: ${message}`)
    }
}

module.exports = Logger
javascript

并且,我们可以按如下方式使用前面的模块:

// file main.js
const Logger = require('./logger')
const dbLogger = new Logger('DB')
dbLogger.info('This is an informational message')
const accessLogger = new Logger('ACCESS')
accessLogger.verbose('This is a verbose message')
javascript

导出类仍然为模块提供单个入口点,但与子堆栈模式相比,它公开了更多的模块内部结构。 另一方面,在扩展其功能时,它提供了更多的能力。

导出实例

我们可以利用 require() 的缓存机制来轻松定义从构造函数或工厂创建的有状态实例,这些实例可以在不同的模块之间共享。 以下代码显示了此模式的示例:

// file logger.js
class Logger {
    constructor (name) {
        this.count = 0
        this.name = name
    }
    log (message) {
        this.count++
        console.log('[' + this.name + '] ' + message)
    }
}
module.exports = new Logger('DEFAULT')
javascript

然后可以按如下方式使用这个新定义的模块:

// main.js
const logger = require('./logger')
logger.log('This is an informational message')
javascript

因为模块被缓存,所以每个需要 logger 模块的模块实际上总是检索对象的相同实例,从而共享其状态。 这种模式非常类似于创建单例。 但是,它不能保证实例在整个应用程序中的唯一性,就像传统的单例模式一样。 在分析解析算法时,我们发现一个模块可能会在应用程序的依赖树中被多次安装。 这会导致同一逻辑模块的多个实例,所有实例都在同一 Node.js 应用程序的上下文中运行。 我们将在第 7 章 “创意设计模式” 中更详细地分析单例模式及其注意事项。

这种模式的一个有趣的细节是,即使我们没有显式导出该类,它也不排除创建新实例的机会。 事实上,我们可以依靠导出实例的构造函数属性来构造相同类型的新实例:

const customLogger = new logger.constructor('CUSTOM')
customLogger.log('This is an informational message')
javascript

正如你所看到的,通过使用 logger.constructor(),我们可以实例化新的 Logger 对象。 请注意,必须谨慎使用或完全避免使用此技术。 考虑一下,如果模块作者决定不显式导出该类,他们可能希望将该类保持为私有。

修改其他模块或全局范围

模块甚至可以不导出任何内容。 这似乎有点不合时宜; 但是,我们不应该忘记,模块可以修改全局范围及其中的任何对象,包括缓存中的其他模块。 请注意,这些通常被认为是不好的做法,但由于这种模式在某些情况下(例如,用于测试)可能有用且安全,并且有时会在现实项目中使用,因此值得了解。

我们说过一个模块可以修改全局范围内的其他模块或对象; 好吧,这就是所谓的猴子补丁(monkey patching)。 它通常是指在运行时修改现有对象以更改或扩展其行为或应用临时修复的做法。

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

// file patcher.js

// ./logger is another module
require('./logger').customMessage = function () {
    console.log('This is a new functionality')
}
javascript

使用我们的新补丁模块就像编写以下代码一样简单:

// file main.js

require('./patcher')
const logger = require('./logger')
logger.customMessage()
javascript

使用此处描述的技术可能非常危险。 主要担心的是,拥有修改全局命名空间或其他模块的模块是一种具有副作用的操作。 换句话说,它会影响其范围之外的实体的状态,这可能会产生不易预测的后果,特别是当多个模块与同一实体交互时。 想象一下有两个不同的模块尝试设置相同的全局变量,或修改同一模块的相同属性。 影响可能是不可预测的(哪个模块获胜?),但最重要的是它会对整个应用程序产生影响。

因此,再次谨慎使用此技术,并确保您了解这样做时所有可能的副作用。

如果您想要一个真实的示例来了解它的用途,请查看 nock (nodejsdp.link/nock),该模块允许您在测试中模拟 HTTP 响应。 nock 的工作方式是通过猴子修补 Node.js http 模块并更改其行为,以便它将提供模拟响应而不是发出真正的 HTTP 请求。 这允许我们的单元测试在不访问实际生产 HTTP 端点的情况下运行,这在为依赖第三方 API 的代码编写测试时非常方便。

至此,我们应该对 CommonJS 以及它常用的一些模式有了相当完整的了解。 在下一节中,我们将探讨 ECMAScript 模块,也称为 ESM。