Commonjs、esm、Amd 和 Cmd 的循环依赖表现和原理

a 模块执行时依赖 b 模块,b 模块的执行又反过来依赖 a 模块,此时就发生了循环依赖。循环依赖在平常的业务代码里比较罕见,一般遇到就意味着代码架构是时候认真梳理一下了。

但在依赖关系复杂的系统里,是有可能出现循环依赖的情况。让我们一起来看看在 Commonjsnodejs)、ES moduleAmdRequireJS)和 CmdSeajs)各种主流模块标准下的循环依赖表现及其背后的原理。

Commonjs

我们来看看node官方文档里提供的 循环依赖demo

// a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');
// b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');
// main.js
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);

执行 main.js,输出如下:

$ node main.js

main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true

这里在执行 a.js 时,依赖 b.js,而执行 b.js 时,反过来又依赖 a.js 的输出,造成了循环依赖,然而程序并不会陷入无限循环,这里到底发生了什么?根据官方原文:

In order to prevent an infinite loop, an unfinished copy of the a.js exports object is returned to the b.js module.

翻译过来就是,模块被循环依赖时,只会输出当前执行完成的导出值。也就是说,b.js 在依赖未执行完成的 a.js 时,并不会等待 a.js 执行完,而是直接输出当前执行过的 export 对象,也就是例程中的第二行:

// a.js
exports.done = false;

除此之外,我们还注意到一点,main.js 在执行 require('./b.js') 时,为什么 log 都没打印出来?很显然 node 在这里做了缓存,而且缓存时机必须是模块执行完成之后,毕竟 main.js 最后输出的 a.doneb.done 都是 true


ES module

关于 ES module 的循环依赖表现,我这里提供了2个比较有代表性的 demo,都是运行在 node 端。

demo1

// a.mjs
console.log('a starting');
export default {
  done: true,
}
import b from './b.mjs';
console.log('in a, b.done = %j', b.done);
console.log('a done');
// b.mjs
console.log('b starting');
export default {
  done: true,
}
import a from './a.mjs';
console.log('in b, a.done = %j', a.done);
console.log('b done');

执行 a.mjs,输出如下

$ node --experimental-modules a.mjs

b starting
ReferenceError: a is not defined

如果 ES moduleCommonjs 一样都是运行时加载/导出,那么按照 js 代码的执行顺序,b.mjs 读取 a.done 时不应该抛出 undefined 异常;另外,虽然入口模块是 a.mjs,但先打印出的是 b starting,所以不难猜想:

ES module 不是动态解析,且依赖模块优先执行

demo2

// a.mjs
import b from './b.mjs';
console.log('a starting');
console.log(b());
export default function () {
  return 'run func A';
}
console.log('a done');
// b.mjs
import a from './a.mjs';
console.log('b starting');
console.log(a());
export default function () {
  return 'run func B';
}
console.log('b done');

执行 a.mjs,输出如下

$ node --experimental-modules a.mjs

b starting
run func A
b done
a starting
run func B
a done

啥情况?怎么把导出对象 object 改为导出函数 function 就不会报 undefined 异常?接下来让我们带着以上的结论和问题来探究 ES module 原理。

ES module 原理

这里只会简短阐述原理,详见 ES modules: A cartoon deep-dive。实际上 ES module 从加载入口模块到所有模块实例的执行主要经历了三步:构建实例化运行

  • 构建

从入口模块开始,根据 import 关键字遍历依赖树,每遍历一个模块则生成该模块的 模块记录(module record),最后生成整个 模块图谱(module graph)

解析模块生成模块记录

注意,这一步是 ES moduleCommonjs 的本质区别:

因为 ES module 需要支持浏览器端,而构建过程要获取所有的模块文件来绘制模块依赖图谱,如果参考 Commonjs 的做法把模块解析和运行放在一起,那么冗长的下载过程将会严重阻塞主线程导致应用长时间不可用,所以 ES module 在构建过程不会实例化和执行任何的js代码,也就是所谓的 静态解析 过程

这同时也解释了为何不支持使用表达式/变量的 import 语句:

// 报错
let module = 'my_module';
import { foo } from module;

所有的模块记录都会被缓存在 模块映射(module map) 中,被依赖多次的模块也只会存在唯一一条映射记录,从而避免模块的重复下载和实例化。

模块映射
  • 实例化

根据模块记录的关系,在内存中把模块的导入 import 和导出 export 连接在一起,也称为 活绑定(live bindings)

JS引擎会为每个模块记录创建 模块环境记录(module environment record),用来关联模块实例和模块的导入/导出值。引擎会先采用 深度优先后序遍历(depth first post-order traversal),将模块及其依赖的导出 export 连接到内存中(直到依赖树末端),然后逐层返回再把模块相对应的导入 import 连接到内存的同一位置。这也解释了为什么导出模块的值变更时,导入模块也能捕捉到该值的变更。

模块实例通过导入/导出变量在内存中建立关系

需要注意的是,实例化只是JS引擎在内存中绑定模块间关系,并没有执行任何代码,也就是说这些连接好的内存空间中并没有存储变量值,然而,在此过程中导出函数将会被初始化,即所谓的 函数具有提升作用

这使循环依赖的问题自然而然地被解决:

JS引擎不需要关心是否存在循环依赖,只需要在代码运行的时候,从内存空间中读取该导出值。

我们回到上面提供的 ES module 循环依赖的例程。

第一个例程 b.mjs 模块(简称 b 模块)在获取 a.mjs 模块(简称 a 模块)的导出值时,a 模块的对象 { done: true } 并没有被声明和赋值,所以会抛出 undefined 异常。

第二个例程,由于函数具有提升作用,b 模块获取 a 模块导出值时,a 模块的 foo 函数已经被声明,不会抛出异常。

  • 运行

也就是往内存空间中填充真实值。

JS引擎会采用和实例化时一样的深度优先后序遍历来执行模块及其依赖的顶级代码(即除函数声明之外的代码),所以会出现 demo1 中的 log 顺序。

nodejs 已经实现了对 ES module 的支持,目前只是作为一个实验特性,我会找时间研究 node 实现 CommonjsES module 的底层源码,大家敬请期待。


RequireJS

RequireJSSeajs 都是主要针对浏览器端的模块加载器,模块加载流程离不开这几点:

  1. 根据加载器规则寻找模块,并通过插入script标签异步加载;
  2. 在模块代码中通过词法分析找出依赖模块并加载,递归此过程直到依赖树末端;
  3. 绑定 load 事件,当依赖模块都加载完成时执行回调函数;

当然加载器还涉及缓存机制、容错处理和一些复杂的配置等,有兴趣的同学可以看看源码自行研究,这里就不详细说了。

这里我们把 Commonjs 的 demo 稍微改动下,使其运行在浏览器端:

<!-- index.html  -->
<html>
  <body>
    <script data-main="./app.js" src="./require.js"></script>
  </body>
</html>
// app.js
define(['./a', './b'], function(a, b) {
  console.log('app starting');
  console.log('in app', a, b);
});
// a.js
define(['./b', 'exports'], function(b, exports) {
  console.log('a starting');
  exports.done = false;
  console.log('in a, b.done =', b.done);
  console.log('a done');
  exports.done = true;
});
// b.js
define(['./a', 'exports'], function(a, exports) {
  console.log('b starting');
  exports.done = false;
  console.log('in b, a.done =', a.done);
  console.log('b done');
  exports.done = true;
});

启动 http-server:

# npm install -g http-server
$ http-server

打开 chrome,查看 console 控制台输出:

b starting
b.js:4 in b, a.done = undefined
b.js:5 b done
a.js:2 a starting
a.js:4 in a, b.done = true
a.js:5 a done
app.js:2 app starting
app.js:3 in app {done: true} {done: true}

首先打印的是 b 模块中的 console.log('b starting'),而不是 app 模块中的 console.log('app starting'),可以看出 Requirejs 是遵循 依赖前置 原则:demo 中 a 模块依赖 b 模块,在 a 模块回调执行前,会先确保 b 模块执行完毕,所以 b 模块中 a.done = undefined。需要注意的是,如果不使用 exports 包来导出模块返回值而选择直接 return 的话,b 模块中访问 a 模块导出值将会报 undefined 异常,相当于说 exports 包为模块的导出预置了一个空对象(详见 RequireJS API)。

所以 RequireJS 在解决循环依赖时,假设模块都没有执行过(没有缓存记录)的前提下,总会有其中一个模块读取依赖值是 空对象 或者 undefined


Seajs

那么同样的 demo 运行在 Seajs 框架下是什么效果呢?稍微改动下代码使其符合 Cmd 规范:

<!-- index.html -->
<html>
  <body>
    <script src="./sea.js"></script>
    <script>
      seajs.use('./app.js');
    </script>
  </body>
</html>
// app.js
define(function(require) {
  var a = require('./a');
  var b = require('./b');
  console.log(a, b);
});
// a.js
define(function(require, exports) {
  console.log('a starting');
  exports.done = false;
  var b = require('./b');
  console.log('in a, b.done =', b.done);
  console.log('a done');
  exports.done = true;
});
// b.js
define(function(require, exports) {
  console.log('b starting');
  exports.done = false;
  var a = require('./a');
  console.log('in b, a.done =', a.done);
  console.log('b done');
  exports.done = true;
});

控制台输出:

app.js:2 app starting
a.js:2 a starting
b.js:2 b starting
b.js:5 in b, a.done = false
b.js:6 b done
a.js:5 in a, b.done = true
a.js:6 a done
app.js:5 in app {done: true} {done: true}

RequireJS 的 log 不一样(但和 Commonjs 的 demo 输出完全一致),这里是先打印 app starting,印证了 Seajs 所遵循的 依赖就近 原则,就是模块只有在被 require 的时候才会执行。所以 SeajsCommonjs 解决循环依赖的办法都是一样的简单粗暴,需要的时候就去缓存中实时取副本,取到什么就是什么

无论是哪一种规范,都没有局限于在哪一端运行,譬如 CommonjsES module 都支持在 node 端或浏览器端运行。为了解决各大浏览器对于这些模块化标准的支持度不一的问题,我们一般使用 webpack、browserify 等构建工具处理模块代码,下一期会着重讲解 webpack 是如何实现 CommonjsES module 等模块标准的。

PS:本文章涉及的所有 demo 已放在 github 上。

Reference

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342