Hello World
Spiga

Node.js中相同模块是否会被加载多次?

2011-12-27 23:19 by 老赵, 3458 visits

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.5.2 ./node_modules/express 
├── mkdirp@0.0.7
├── qs@0.4.0
├── mime@1.2.4
└── connect@1.8.5

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

$ npm install mime
mime@1.2.4 ./node_modules/mime 

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

./node_modules
├── mime
└── express
    └── node_modules
        ├── mkdirp
        ├── qs
        ├── mime
        └── 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模块的时候,便事先考虑下它会被重复加载时的情况吧。

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

26 条回复

  1. GhostHorse
    111.174.107.*
    链接

    GhostHorse 2011-12-28 09:28:41

    沙发也抢了,花了我一年多遍历老赵的文章和共享的ppt,从好多都看不懂,现在才能看懂一点点了,能说说怎么对那么多语言感兴趣, 那些语言的什么 吸引了你

  2. 老赵
    admin
    链接

    老赵 2011-12-28 13:40:42

    @GhostHorse

    因为我的工作是编程,各种语言都能做出差不多的东西,但编程体验对我很重要,学习多种语言就知道怎么可以改进自己编程生活。对于不能改进很难改进的情况,比如Java,就十分深恶痛绝。

  3. mathgl
    171.105.49.*
    链接

    mathgl 2012-01-01 23:39:51

    看知乎,如果不看全部答案,不需要帐号....

  4. jsyzthz
    222.128.9.*
    链接

    jsyzthz 2012-01-03 11:32:38

    老赵能推荐个国外的ASP.NET虚拟主机空间啊,便宜点的。空间大小20-30M就OK了,支持access的

  5. 老赵
    admin
    链接

    老赵 2012-01-03 17:46:51

    @mathgl

    不清楚,反正我还在等“兼听则明”的大神留邮箱。

  6. 链接

    Gabriel Zhang 2012-01-05 17:57:00

    md文件用什么软件打开呢

  7. 老赵
    admin
    链接

    老赵 2012-01-05 21:38:42

    @Gabriel Zhang

    就是Markdown格式了,用某些Markdown编辑器或浏览器可以看到转化后的结果,比如我现在用Mou编辑md文件,就像我博客这样实时预览的,很不错。

  8. oztoto
    114.244.81.*
    链接

    oztoto 2012-01-07 09:21:11

    请问老赵,VS2010企业版个人买要多少钱啊,我怎么在微软的主页上就没看到售价呢?

  9. Raindy Long
    218.19.51.*
    链接

    Raindy Long 2012-01-07 10:49:53

    试着玩了一下jscex,确实挺有趣的,因为自己不是专门做研发的,所以暂时说不出个所以然来,免得说错了不好意思呢,以后玩多了有感觉再来SOSO。

    8年前学和用javascript的时候,那时候它还没有现在的红火,现在早已忘得差不多了,BS自己没有KEEP住不断的学习,老赵对技术的美的追求让我重新有了学习的冲动啊,呵呵。

    睡觉前运行了一下quick-start.js,醒来发现“Infinity”输出了,不过这也是预料中的结果。

  10. Cat Chen
    106.187.35.*
    链接

    Cat Chen 2012-01-09 13:55:30

    这说明了你不能把你的依赖项暴露出去,你的 exports 里面不能带有任何 require 进来的类型,除非你准备把那个类型也 exports 出去,否则别人在使用你的库的时候再 require 一个同名库就不一样了。

    这个原则到底怎么执行好,best practice 是什么,估计还要一段时间去发现。暂时来说,最好还是不要 exports 任何 require 的东西,但这对 Deferred 库来说是个问题。

  11. 老赵
    admin
    链接

    老赵 2012-01-09 21:45:02

    @Cat Chen

    有些情况暴露些require里的东西也是很常见的,平时写程序,例如依赖第三方某个库,也可能直接就把其中的类型暴露出去了啊。

  12. jovi
    116.12.234.*
    链接

    jovi 2012-01-13 12:17:12

    上面的吵架好黄好暴力.... 一个技术贴怎么吵成这样.....

  13. cony138
    112.20.91.*
    链接

    cony138 2012-06-21 23:15:04

    请问老赵,如果模块A依赖于模块B,而模块B又依赖于模块A,那么这种情况会怎样?一般在设计依赖的时候,怎么规避这样的情况?

  14. 老赵
    admin
    链接

    老赵 2012-06-22 08:23:26

    @cony138

    A依赖B,B依赖A,意味着A和B应该是同一个包。设计依赖的时候就考虑分层咯,只有上层依赖下层,反之则不行。

已自动隐藏某些不合适的评论内容(主题无关,争吵谩骂,装疯卖傻等等),如需阅读,请准备好眼药水并点此登陆后查看(如登陆后仍无法浏览请留言告知)。

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我