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

javascript/jquery

浏览数:148

2019-8-22

AD:资源代下载服务

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

作者:西山以南