Node.js中相同模块是否会被加载多次?
发布于 3年前 作者 jeffz 2710 次浏览

原文链接

JavaScript的包管理一直是个软肋,我很难想象,连这一基础功能都没有做好的语言,现在居然会如此流行。在我看来,其实JavaScript流行的最主要元素还是把持了浏览器,而Web应用在这几年掀起了一阵腥风血雨。任意一门语言,只要能像JavaScript般被标准采纳,被所有浏览器接受,它都能“成功”,真是所谓宿命。

当然,既然它流行了,既然人们想要用它做大事了,就要开始为它制定一些模块的约定。这几天我为Jscex实现AMD规范的时候,便深刻体会到模块化的优势。Node.js也使用了CommonJS模块机制,最近在InfoQ上有一篇文章讨论了这方面的问题。这篇文章提到Node.js在载入模块时,如果之前该模块已经加载过则不会有重复开销,因为模块加载有缓存机制。这篇文章是我初审的,当时也正好在思考Jscex在Node.js使用时的模块化问题,但研究了它的规则之后,我发现在某些情况下还是可能加载多次。现在我们便来分析这个问题。

当我们使用require方法加载另一模块的时候,Node.js会去查询一系列的目录。我们可以从module.paths中得到这些路径,例如:

[ '/Users/jeffz/Projects/node-test/node_modules',
  '/Users/jeffz/Projects/node_modules',
  '/Users/jeffz/node_modules',
  '/Users/node_modules',
  '/node_modules']

这里是我在运行/User/jeffz/Projects/node-test目录下一个模块时得到的结果。可见,Node.js会从当前模块所在目录的node_modules(这里怎么不遵守Unix习惯,而使用了下划线呢?)开始找起,如果没找到再会去找上级目录的node_modules,直到根目录为止。当然,实际情况下还会有NODE_PATH环境变量标识的目录等等。当模块的位置确定之后,Node.js便会查看这个位置的模块是否已经被加载,如果已加载,则直接返回。

简单地说,Node.js是根据模块所在路径来缓存模块的。

这么看来,“相同模块是否会被加载多次”这个问题,其实就演变成了“相同模块是否会出现在不同路径里”。简单想来这似乎不太可能,因为如果我们要使用某个模块的时候,它的位置总是确定的。例如,使用npm安装的模块,总是会出现在当前目录的node_modules里,加载时总是会找到相同的路径。那么,在“间接”依赖相同模块的情况下呢?

例如我们想要使用Express框架,于是使用npm来安装,便会得到:

$ npm install express
express[@2](/user/2).5.2 ./node_modules/express 
├── mkdirp[@0](/user/0).0.7
├── qs[@0](/user/0).4.0
├── mime[@1](/user/1).2.4
└── connect[@1](/user/1).8.5

可见,Express依赖了其他一些模块,它们都存放在express模块自己的目录里面,例如./node_modules/express/node_modules/mime。好,假如我们项目自身也要使用mime项目,我们自然也可以使用npm来安装:

$ npm install mime
mime[@1](/user/1).2.4 ./node_modules/mime 

于是我们最终得到的是这样的结构:

./node_modules
├── <span style="color: red"><strong>mime</strong>
└── express
    └── node_modules
        ├── mkdirp
        ├── qs
        ├── <span style="color: red"><strong>mime</strong>
        └── connect

请注意,这里的mime模块便出现在两个位置上,它们名称版本都一致,完全是一个模块。那么试想,如果我们在自己的代码里加载的mime模块,以及express内部加载的mime模块是同一个吗?显然不是,可见,在这里相同的模块被重复加载了两次,产生了两个模块“实例”。

这种重复加载在一般情况下不会有太大问题,最多内存占用大一点而已,不会影响程序的正确性。但是,我们也可以轻易设想到一些意外的情况。例如,在Jscex中,每个Task对象我都会给定一个ID,不断增长。要实现这点我们需要维护一个“种子”,全局唯一。之前这个种子定义在闭包内部,但由于Jscex模块会被加载多次,这样从不同模块“实例”生成的Task对象,它们的ID便有可能重复。当然,解决这个问题也并不困难,只需要将种子定义在根对象上即可,不同的模块“实例”共享相同的根对象。

还有个问题可能就显得隐蔽些了,我们可以通过一个简单的实验来观察结果。我们先来定义一个jeffz-a模块,其中暴露出一个MyType类型:

module.exports.MyType = function () { }

然后将其发布到npm上。然后再写一个jeffz-b模块,依赖jeffz-a,并将jeffz-a中定义的MyType类型直接暴露出去:

module.exports.MyType = require("jeffz-a").MyType;

接着将jeffz-b也发布置npm上。再重新写一个测试模块,使用npm安装jeffz-a和jeffz-b,最终目录会是这样的:

./node_modules
├── jeffz-a
└── jeffz-b
    └── node_modules
        └── jeffz-a

在测试模块内,我们来测试实例与类型之间的关系:

var a = require("jeffz-a");
var b = require("jeffz-b");

console.log(new a.MyType() instanceof a.MyType); // true
console.log(new b.MyType() instanceof b.MyType); // true

console.log(new a.MyType() instanceof b.MyType); // false
console.log(new b.MyType() instanceof a.MyType); // false

从表面上看,jeffz-b和jeffz-a暴露出的应该是相同的MyType类型,它们的对象通过instanceof相互判断应该都返回true,但实际上由于jeffz-b中的jeffz-a,与我们直接加载的jeffz-a模块是不同的实例,因此MyType类型自然也不是同一个了。

这对于Jscex的影响在于,Jscex的异步模块在取消时,原本是通过判断异常对象是否为CanceledError类型来决定Task的状态为cancelled还是faulted。但由于Node.js可能会将相同的模块加载为多个实例,因此即便抛出的的确是某个实例的CancelledError,也无法通过另一个实例内部的判断。因此,目前Jscex的判断方式修改为检查异常对象的isCancellation字段,简单地解决了这个问题。

当然,Node.js这种“重复加载”的影响也并非完全是负面的,至少它天然的解决了多版本共存的问题。例如,express v2.5.2依赖mime v1.2.4,但我们程序自身又想使用mime v1.2.5。此时,express内部自然使用mime v1.2.4,而我们自己的程序使用的便是mime v1.2.5。

有些情况下您可能也想避免这种重复加载,这就必须手动地删除模块内部被间接依赖的模块,将其移动到模块查询路径的公用部分上了。就目前看来,这些操作必须手动进行,因为npm在安装模块时不会关心依赖的模块是否已经安装过了(例如在NODE_PATH环境变量标识的路径里),它一定会重新下载所有依赖的模块。可惜如果您使用的是托管形式的Node.js服务,则很有可能无法做到这一点。

因此,我们在编写Node.js模块的时候,便事先考虑下它会被重复加载时的情况吧。

4 回复

只要不要多个目录里放置同样的模块就不会重复加载。

再看看文章。

cdzwm 这贴回的,真是水

我不太赞同你关于javascript的观点,语言应该是各有优点,而且用途也不一样。 你的提出的模块加载问题对我有些帮助,谢谢你了。

回到顶部