单例模式在Node.js中是否需要?

112

我最近读到了这篇文章,讲述如何在Node.js中编写单例模式。我知道require的文档说明

模块在第一次加载后会被缓存。多次调用require('foo')不会导致模块代码被多次执行。

因此,似乎每个所需的模块都可以轻松地用作单例,而无需使用单例样板代码。

问题:

上述文章是否提供了创建单例的解决方案?


2
这是一个关于此主题的5分钟解释(在v6和npm3之后编写):https://medium.com/@lazlojuly/are-node-js-modules-singletons-764ae97519af - lazlojuly
10个回答

151
以上所有的都过于复杂了。有一种思路认为,设计模式显示了实际语言的缺陷。
基于原型的面向对象编程语言(无类)根本不需要单例模式。你只需在运行时创建一个单例对象,然后使用它即可。
至于Node中的模块,是的,默认情况下它们会被缓存,但是可以进行调整,例如如果您想要热加载模块更改。
但是,如果您想要在各个地方使用共享对象,将其放入模块导出中就可以了。不要用“单例模式”来复杂化它,在JavaScript中没有必要这样做。

33
很奇怪没有人得到点赞……给你一个+1,“有一种观点认为设计模式显示了实际语言的缺陷。” - Esailija
81
单例模式并不是一种反面模式。 - wprl
5
@herby,看起来这是对单例模式过于具体化(因此不正确)的定义。 - wprl
24
文档中写道:“多次调用require('foo') 可能不会导致模块代码被执行多次。”它说“可能不会”,而不是“一定不会”,因此从我的角度来看,询问如何确保在应用程序中仅创建一次模块实例是一个有效的问题。 - xorcus
9
这个答案并不正确,这是误导性的。正如下面@mike所指出的,一个模块可能会被加载多次,导致有两个实例。我遇到了这个问题,尽管我只有一个Knockout的拷贝,但由于该模块被加载了两次,导致出现了两个实例。 - dgaviola
显示剩余8条评论

75

这基本上与nodejs缓存有关,简而言之。

https://nodejs.org/api/modules.html#modules_caching

(v 6.3.1)

缓存

在第一次加载后,模块会被缓存。这意味着(除其他外)每次调用require('foo')时,如果解析到相同的文件,则返回完全相同的对象。

对require('foo')进行多次调用可能不会导致模块代码被执行多次。 这是一个重要的特性。借助它,可以返回“部分完成”的对象,从而即使会导致循环,也允许加载传递依赖项。

如果要多次执行模块代码,请导出一个函数并调用该函数。

模块缓存注意事项

模块是根据其已解析的文件名缓存的。由于模块可能根据调用模块的位置(从node_modules文件夹加载)解析为不同的文件名,因此不能保证如果解析到不同的文件,则require('foo')始终会返回完全相同的对象。

此外,在不区分大小写的文件系统或操作系统上,不同的已解析文件名可能指向相同的文件,但缓存仍将视它们为不同的模块并多次重新加载文件。例如,require('./foo')和require('./FOO')返回两个不同的对象,而与./foo和./FOO是否是相同的文件无关。

那么简单来说:

如果您需要一个Singleton,请导出一个对象

如果您不需要一个Singleton,请导出一个函数(并在该函数中执行/返回等等)。

<注意>非常明确,如果您正确执行此操作,它应该有效,请参考https://dev59.com/ZmYs5IYBdhLWcg3wDfp5#33746703(Allen Luce的答案)。它通过代码解释了由于不同的解析文件名导致缓存失败时会发生什么。但是,如果您始终解析为相同的文件名,它应该有效。

更新2016

使用es6符号在node.js中创建真正的单例 另一种解决方案: 在此链接中

更新2020

此答案涉及CommonJS(Node.js的自己的导入/导出模块方式)。Node.js很可能会转换到ECMAScript Modules: https://nodejs.org/api/esm.html (如果您不知道,ECMAScript是JavaScript的真实名称)

迁移到ECMAScript时,现在请阅读以下内容:https://nodejs.org/api/esm.html#esm_writing_dual_packages_while_avoiding_or_minimizing_hazards


7
如果您想要一个单例;导出一个对象...这会有所帮助,谢谢。 - danday74
2
这种做法有很多问题,本页其他地方已经提到了。但是从本质上讲,这个概念是有效的,也就是说,在既定的名义情况下,这个答案中的声明是正确的。如果你想要一个快速而简单的单例模式,这个方法可能会起作用——只是不要用这段代码来发射任何航天飞机。 - Adam Tolley
@AdamTolley "在此页面的其他地方给出了许多原因",您是指符号链接文件还是拼错文件名,这些似乎不使用相同的缓存?文档中确实说明了与大小写不敏感的文件系统或操作系统相关的问题。关于符号链接,您可以在此处阅读更多内容https://github.com/nodejs/node/issues/3402。如果您正在符号链接文件或不理解您的操作系统和节点,则不应接近航空航天工业;),但我确实理解您的观点^^。 - basickarl
2
@KarlMorrison - 仅仅因为文档没有保证,或者它似乎是未指定的行为,或者任何其他不信任该特定语言行为的合理原因。也许缓存在另一个实现中工作方式不同,或者您喜欢在REPL中工作并完全颠覆了缓存功能。我的观点是缓存是实现细节,将其用作单例等效物是一个巧妙的技巧。我喜欢巧妙的技巧,但它们应该有所区别,这就是全部 - (也没有人使用Node发射航天飞机,我只是开玩笑) - Adam Tolley
那么如果我想要一个单例类,它实际上在构造函数中接受参数怎么办?在这种情况下,我不能只导出一个对象。 - Rose

31

编号. 当 Node 的模块缓存失败时,单例模式也会失效。我修改了示例代码,使其在 OSX 上能够有意义地运行:

var sg = require("./singleton.js");
var sg2 = require("./singleton.js");
sg.add(1, "test");
sg2.add(2, "test2");

console.log(sg.getSocketList(), sg2.getSocketList());

这将产生作者预期的输出:

{ '1': 'test', '2': 'test2' } { '1': 'test', '2': 'test2' }

但是一个小修改就会破坏缓存。在OSX上,按照以下步骤进行操作:

var sg = require("./singleton.js");
var sg2 = require("./SINGLETON.js");
sg.add(1, "test");
sg2.add(2, "test2");

console.log(sg.getSocketList(), sg2.getSocketList());

或者,在 Linux 上:

% ln singleton.js singleton2.js

然后将sg2的require行更改为:

var sg2 = require("./singleton2.js");

然后,bam,这个单例模式被击败了:

{ '1': 'test' } { '2': 'test2' }

我不知道有没有什么可接受的方法来解决这个问题。如果你真的想要创建类似单例的东西,并且可以容忍污染全局命名空间(以及可能导致的许多问题),你可以将作者的getInstance()exports行更改为:

singleton.getInstance = function(){
  if(global.singleton_instance === undefined)
    global.singleton_instance = new singleton();
  return global.singleton_instance;
}

module.exports = singleton.getInstance();

话虽如此,但我在生产系统中从未遇到需要执行此类操作的情况。我也从未感到需要在JavaScript中使用单例模式。


阅读这篇文章很有趣。然而,最终的结论是:你必须“刻意地”破坏你的代码(在这种情况下是节点缓存机制),才能打破Node.JS中的单例模式! - iaforek
虽然我有意地采取了一些措施来展示单例假设可能被违反的一种方式,但并不能保证类似的情况不会在程序员没有意图的情况下出现。 - Allen Luce

22

进一步查看 Modules 文档中的 模块缓存注意事项:

模块是基于其已解析的文件名进行缓存的。由于模块可能基于调用模块的位置 (从 node_modules 文件夹加载) 解析为不同的文件名,因此如果解析到不同的文件,则 不能保证 require('foo') 总是返回完全相同的对象。

因此,根据您要求模块的位置不同,可能会获得不同实例的模块。

听起来像模块并不是创建单例的简单解决方案。

编辑: 或许它们。就像 @mkoryak 一样,我无法想出一个单个文件可能解析为不同文件名的情况(不使用符号链接)。但是 (如 @JohnnyHK 所述),在不同的 node_modules 目录中存在多个文件副本,每个副本将被分别加载和存储。


好的,我已经读了三遍,但仍然想不出一个例子,它会解析为不同的文件名。能帮忙吗? - mkoryak
1
@mkoryak 我认为这是指当您从node_modules中要求两个不同的模块,每个模块依赖于相同的模块,但是在每个不同模块的node_modules子目录下有单独的依赖模块副本时的情况。 - JohnnyHK
@mike 你说得对,当模块通过不同路径引用时会被多次实例化。我在为服务器模块编写单元测试时遇到了这种情况。我需要一种单例实例。如何实现? - Sushil
一个例子可能是相对路径。例如,假设require('./db')在两个不同的文件中, db模块的代码会执行两次。 - willscripted
9
由于节点模块系统不区分大小写,我遇到了一个很恶心的 bug。在一个文件中,我调用了 require('../lib/myModule.js');,而在另一个文件中,我调用了 require('../lib/mymodule.js');,但它们没有返回相同的对象。为此我感到非常困扰。 - heyarne
@mike 我的问题是关于语句 ....由于模块可能根据调用模块的位置解析为不同的文件名.. 对我来说,这个语句意味着nodejs使用相对路径(而不是绝对路径)作为缓存键,因为它依赖于调用模块的位置。是这样吗? - emilly

19
在Node.js中(或者说在浏览器的JS中),像这样的单例完全是不必要的。由于模块是具有状态的并且被缓存的,因此您提供的链接中给出的示例可以更简单地重写为:
var socketList = {};

exports.add = function (userId, socket) {
    if (!socketList[userId]) {
        socketList[userId] = socket;
    }
};

exports.remove = function (userId) {
    delete socketList[userId];
};

exports.getSocketList = function () {
    return socketList;
};
// or
// exports.socketList = socketList

5
文档中说“可能不会”导致模块代码被多次执行,因此有可能会被调用多次,如果再次执行此代码,则socketList将被重置为空列表。 - Jonathan.
4
@Jonathan。该引用所在的文档背景似乎非常有说服力地表明,“may not”被用作RFC风格的“MUST NOT”。 - Michael
6
“may”这个词很有趣。想想看,一个单词在否定时可以表示“可能不是”或“绝对不是”,真是别有用心。 - OJFord
1
may not” 是在开发过程中使用 npm link 模块时适用的。因此,在使用依赖于单个实例(例如 eventBus)的模块时要小心。 - mediafreakch

14

这里唯一使用ES6类的答案

// SummaryModule.js
class Summary {

  init(summary) {
    this.summary = summary
  }

  anotherMethod() {
    // do something
  }
}

module.exports = new Summary()

使用以下方式来要求此单例:

const summary = require('./SummaryModule')
summary.init(true)
summary.anotherMethod()

唯一的问题在于您无法向类构造函数传递参数,但可以通过手动调用init方法来规避该问题。


问题是“是否需要单例模式”,而不是“如何编写单例模式”。 - mkoryak
@danday74 我们如何在另一个类中使用相同的实例 summary,而不需要重新初始化它? - M Faisal Hameed
1
在Node.js中,只需在另一个文件中引用它... const summary = require('./SummaryModule') ... 它将是相同的实例。您可以通过在需要它的一个文件中创建成员变量并设置其值,然后在需要它的另一个文件中获取其值来测试此功能。它应该是设置的值。 - danday74

12

在JavaScript中实现单例不需要任何特殊的东西,这篇文章中的代码也可以这样写:

var socketList = {};

module.exports = {
      add: function() {

      },

      ...
};

如果在node.js之外(例如在浏览器的js中),您需要手动添加包装函数(在node.js中会自动完成):

var singleton = function() {
    var socketList = {};
    return {
        add: function() {},
        ...
    };
}();

正如 @Allen Luce 指出的那样,如果节点缓存失败,则单例模式也会失败。 - rakeen
一个带参数的例子会很有帮助,因为这与Node.js缓存有关。 - Rose

7

在JS中,单例模式是可以的,但不需要太冗长。

如果您需要一个单例模式,在node中,例如在您的服务器层中使用相同的ORM / DB实例来跨多个文件使用,那么可以将引用压入全局变量中。

只需编写一个模块,如果不存在,则创建全局变量,然后返回对其的引用。

@allen-luce 在其脚注代码示例中做得很好,如下所示:

singleton.getInstance = function(){
  if(global.singleton_instance === undefined)
    global.singleton_instance = new singleton();
  return global.singleton_instance;
};

module.exports = singleton.getInstance();

需要注意的是,使用new关键字并非必要。任何旧的对象、函数、IIFE等都可以工作-这里没有发生面向对象编程的神秘现象。

如果你在函数内部闭包一些对象,并使该函数成为全局函数,则即使重新分配全局变量,也不会覆盖已经从它创建的实例-尽管这可能并不实用。


你不需要那些。你可以只做 module.exports = new Foo(),因为 module.exports 不会再次执行,除非你做了一些非常愚蠢的事情。 - mkoryak
你绝对不应该依赖于实现的副作用。如果你需要一个单一实例,只需将其绑定到全局变量中,以防实现发生更改。 - Adam Tolley
以上答案也是对原问题的误解,即“我应该在JS中使用单例模式还是语言本身使它们变得不必要?”这似乎也是许多其他答案存在的问题。我坚持建议不要使用require实现来替代适当的、明确的单例实现。 - Adam Tolley

1

如果您想使用类,那么这是最简洁和最美观的方法

module.exports = new class foo {...}

1
保持简单。

foo.js

function foo() {

  bar: {
    doSomething: function(arg, callback) {
      return callback('Echo ' + arg);
    };
  }

  return bar;
};

module.exports = foo();

然后只需:

var foo = require(__dirname + 'foo');
foo.doSomething('Hello', function(result){ console.log(result); });

问题是“是否需要单例模式”,而不是“如何编写单例模式”。 - mkoryak

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接