今天来讲一讲 Node 循环依赖的问题,以官网上的例子结合 Node 源码来分析为什么循环依赖不会导致死循环,以及循环依赖可能造成的问题。
什么是循环依赖
循环依赖是两个或多个模块之间的关系,它们直接或间接地相互依赖以正常运行。
循环依赖的案例
官网上给出的例子是这样的:
a.js
:
console.log('a starting'); |
b.js
:
console.log('b starting'); |
main.js
:
console.log('main starting'); |
官网的解释是: main.js
先加载 a.js
,然后 a.js
中会加载 b.js
,但是在 b.js
中又加载了 a.js
。这个时候为了防止无限循环,会将a.js
未完成的 exports
对象返回给 b.js
模块,接着 b.js
完成加载,并且它的 exports
对象被提供给 a.js
模块。
由此可以看出,之所以不会发生依赖的死循环,是因为模块能够导出未完成的 exports
对象。那么问题来了,为什么模块没有执行完,却能导出对象呢?
下面通过分析模块源码 lib/module.js 来解答这个问题。要注意的是,核心模块和文件模块(用户编写的模块)的加载是不同的,本文只讨论文件模块的加载。为了便于理解,会对源码进行简化。
Module 构造函数
在 Node 中,每个模块在被 require
导入的时候都会创建一个模块实例,即 Module
实例,并且 Node 会缓存每个模块的实例,以便在下次 require
该模块的时候可以直接从缓存中返回。
模块实例有一个 exports
属性,初始化为空对象。当我们在文件模块中通过 module.exports
或 exports
来导出的时候,其实就是在给模块实例的 exports
添加属性或者直接重写它。
// Module 构造函数 |
require 方法
require
方法定义在 Module
的原型链上,被每个模块实例共享。
Module.prototype.require = function(id) { |
require
内部调用 Module._load
方法,下面是简化后的 _load
方法。
Module._load = function(request, parent, isMain) { |
上面的代码中,以模块的绝对路径作为模块id,优先从缓存中获取模块实例的 exports
属性。如果模块实例不在缓存中,则创建模块实例并存入缓存,最后根据模块id调用 module.load
加载该模块。
加载模块
模块的加载通过 module.load
方法完成,该方法根据模块的绝对路径确定文件扩展名,不同的文件扩展名采用不同的加载方法。
Module.prototype.load = function(filename) { |
以 .js
扩展名为例,处理方法如下:
Module._extensions['.js'] = function(module, filename) { |
module._compile
方法对模块文件进行编译执行。
Module.prototype._compile = function(content, filename) { |
Module.wrap = function(script) { |
vm.runInThisContext
这个方法会在 V8 虚拟机环境中编译代码,并在当前全局的上下文中运行代码并返回结果。在全局上下文运行的好处是在模块中我们可以使用一些全局变量,如:process
、console
等。具体参考:vm 的官方文档。
上面的代码使用 Module.wrap
将模块代码包装在函数中,这样就避免了作用域被污染,接着通过执行 vm.runInThisContext
返回一个可执行的函数,最后传入当前模块实例的 exports
属性、模块实例的 this
,以及require
方法、完整文件路径和文件目录来执行该函数。
由此也可以看出,module.exports
和 exports
并不是全局的,而是在执行模块代码的包装函数时传入的参数(当前模块实例的 exports
)。这也解释了为什么在文件模块中重写 exports
会无法导出,因为它只能改变函数形参的引用,而无法实际影响到当前模块实例的 exports
属性。
循环依赖
在分析完模块的整个加载过程后,回到上面那个问题:为什么模块没有执行完,却能导出对象呢?关键就在于,在加载模块时,如果模块没有缓存,会先创建模块实例,然后存入缓存,再编译执行模块代码。
以官网的例子来说:
a.js
先加载,所以先缓存a.js
的模块实例,然后编译执行a.js
。在执行
a.js
的过程中,先导出exports.done = false
,此时a.js
模块实例的exports
属性值为{ done: false }
。接着加载b.js
。b.js
在执行过程中发现需要加载a.js
,此时由于a.js
模块已经被缓存,所以直接获取到缓存中的a.js
模块实例的exports
属性,值为{ done: false }
,然后继续执行。b.js
执行完毕返回,a.js
继续执行。
这种循环依赖导致的问题很明显:
b.js
在执行过程中获取到的a.js
的导出可能是不完整的。如果
a.js
在加载b.js
后重写了module.exports
,b.js
中获取到的a.js
的导出还是维持着旧的引用。
具体的解决方案可以参考 Maples7 的博客:Node.js 中的模块循环依赖及其解决。