通俗讲解JS模块的循环引用问题

2,642 阅读6分钟

通俗讲解JS模块的循环引用问题

JS模块的循环引用

JS模块的循环引用是指执行a.js的时候依赖b模块,执行b.js的时候依赖a模块,由此出现循环,产生循环引用。我们谈的循环引用问题,其实是指循环引用的容错问题,允许循环引用的代码执行。

// a.js
var b = require('b');

// b.js
var a = require('a');

常见解释

在谈到CommonJS和ESM处理模块循环引用的区别时,经常会看到这样一个结论:

CommonJS模块是加载时执行。一旦出现某个模块被“循环加载”,就只输出已经执行的部分,没有执行的部分不会输出。 ESM模块对导出模块,变量,对象是动态引用,遇到模块加载命令import时不会去执行模块,只是生成一个指向被加载模块的引用。 ES6根本不会关心是否发生了"循环加载",只是生成一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。

疑问

我看到这里时对CommonJS处理循环引用的方式没有什么疑问,毕竟本质上还是用的缓存解决循环问题,这个和通常处理循环的问题思路差不多,比如对象的深拷贝。

但看到ESM模块的解释时,却有一种云里雾里的感觉。为什么生成一个引用循环问题就解决了呢,而且JS不是值传递吗?

怎么去弄清楚这个问题呢,一个简单的方法就是去看webpack编译的代码,那里完整实现了CommonJS和ESM模块,用代码来解释这个问题,我们用node官方的例子。

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);

输出结果

$ 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

commonJS处理循环引用

对于这个例子,我们来看一下commonJS的webpack编译结果。

(() => {
	var __webpack_modules__ = {
		"./src/commonJS/a.js": (
			__unused_webpack_module,
			exports,
			__webpack_require__
		) => {
			console.log("a starting");
			exports.done = false;
			const b = __webpack_require__("./src/commonJS/b.js");
			console.log("in a, b.done = %j", b.done);
			exports.done = true;
			console.log("a done");
		},

		"./src/commonJS/b.js": (
			__unused_webpack_module,
			exports,
			__webpack_require__
		) => {
			console.log("b starting");
			exports.done = false;
			const a = __webpack_require__("./src/commonJS/a.js");
			console.log("in b, a.done = %j", a.done);
			exports.done = true;
			console.log("b done");
		},
	};

	var __webpack_module_cache__ = {};

	function __webpack_require__(moduleId) {
		var cachedModule = __webpack_module_cache__[moduleId];
		if (cachedModule !== undefined) {
			return cachedModule.exports;
		}
		var module = (__webpack_module_cache__[moduleId] = {
			exports: {},
		});

		__webpack_modules__[moduleId](
			module,
			module.exports,
			__webpack_require__
		);

		return module.exports;
	}

	(() => {
		console.log("main starting");
		const a = __webpack_require__("./src/commonJS/a.js");
		const b = __webpack_require__("./src/commonJS/b.js");
		console.log("in main, a.done = %j, b.done = %j", a.done, b.done);
	})();
})();

webpack中commonJS模块化的实现,就是将a.js和b.js的内容外面包装一层函数,放入__webpack_modules__ 这个对象中。定义一个__webpack_require__方法,这样require一个模块就相当于给这包装后的函数传入一个exports对象,执行这个包装后的函数,将结果挂到传入的exports对象上。

处理循环引用,就是在执行这个require函数时,判断这个模块是否执行过,如果执行过就直接返回缓存结果,如下所示:

var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
	return cachedModule.exports;
}

可以看出commonJS处理循环引用问题用的还是常见的缓存思路。

ESM处理循环引用

我们再来看一下ESM的webpack编译结果,因为ESM是无法重复导出一个变量的,上面的例子需要有一些修改,但这不影响我们分析。

a.js

console.log("a starting");
import { b } from "./b.js";
console.log("in a b is ", b);
var a = true;
export { a };
console.log("a done");

b.js

console.log("b starting");
import { a } from "./a.js";
console.log("in b a is ", a);
var b = true;
export { b };
console.log("b done");

main.js

import { a } from "./a.js";
console.log("main starting");
import { b } from "./b.js";
console.log("in main, a = %j, b = %j", a, b);

webpack编译结果

(() => {
	"use strict";
	var __webpack_modules__ = ({

		"./src/esm/a.js":

			((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

				__webpack_require__.r(__webpack_exports__);
				__webpack_require__.d(__webpack_exports__, {
					"a": () => (a)

				});
				var _b_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/esm/b.js");
				console.log("a starting");

				console.log("in a b is ", _b_js__WEBPACK_IMPORTED_MODULE_0__.b);
				var a = true;

				console.log("a done");



			}),

		"./src/esm/b.js":
			((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

				__webpack_require__.r(__webpack_exports__);
				__webpack_require__.d(__webpack_exports__, {
					"b": () => (b)

				});
				var _a_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/esm/a.js");
				console.log("b starting");

				console.log("in b a is ", _a_js__WEBPACK_IMPORTED_MODULE_0__.a);
				var b = true;

				console.log("b done");



			})


	});

	var __webpack_module_cache__ = {};

	function __webpack_require__(moduleId) {
		var cachedModule = __webpack_module_cache__[moduleId];
		if (cachedModule !== undefined) {
			return cachedModule.exports;

		}
		var module = __webpack_module_cache__[moduleId] = {
			exports: {}

		};

		__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
		return module.exports;

	}



	(() => {
		__webpack_require__.d = (exports, definition) => {
			for (var key in definition) {
				if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
					Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });

				}

			}

		};

	})();


	(() => {
		__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))

	})();


	(() => {
		__webpack_require__.r = (exports) => {
			if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
				Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });

			}
			Object.defineProperty(exports, '__esModule', { value: true });

		};

	})();


	var __webpack_exports__ = {};
	(() => {
		__webpack_require__.r(__webpack_exports__);
		var _a_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/esm/a.js");
		var _b_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./src/esm/b.js");

		console.log("main starting");

		console.log("in main, a = %j, b = %j", _a_js__WEBPACK_IMPORTED_MODULE_0__.a, _b_js__WEBPACK_IMPORTED_MODULE_1__.b);

	})();


})()
	;

执行结果

b starting
in b a is  undefined
b done
a starting
in a b is  true
a done
main starting
in main, a = true, b = true

这次我们直接来看处理循环引用的部分,如下:

function __webpack_require__(moduleId) {
		var cachedModule = __webpack_module_cache__[moduleId];
		if (cachedModule !== undefined) {
			return cachedModule.exports;
		}
		var module = (__webpack_module_cache__[moduleId] = {
			exports: {},
		});

		__webpack_modules__[moduleId](
			module,
			module.exports,
			__webpack_require__
		);

		return module.exports;
	}

这里我们发现,webpack编译后的代码与commonJS是一样的,单从处理循环引用这件事上来说:

ESM也是同样的方式,还是通过缓存的方式来处理循环引用的。只是处理时机不同,一个是在执行时处理,一个是在编译时处理。

缓存之外的区别,并不是指处理循环引用上的区别,而是commonJS和ESM模块的区别。

我们还是从代码来看ESM模块的区别,ESM怎么实现的导出引用。

var __webpack_modules__ = {
	"./src/esm/a.js":

		((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

			__webpack_require__.r(__webpack_exports__);
			__webpack_require__.d(__webpack_exports__, {
				"a": () => (a)

			});
			var _b_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/esm/b.js");
			console.log("a starting");

			console.log("in a b is ", _b_js__WEBPACK_IMPORTED_MODULE_0__.b);
			var a = true;

			console.log("a done");



		}),
}

这里我们发现,ESM编译后的代码,不是直接将导出变量挂在到__webpack_exports__对象上,而是通过定义一个函数的方式取这个变量。

我们再来看__webpack_require__.d这个函数的实现

__webpack_require__.d = (exports, definition) => {
	for (var key in definition) {
		if (
			__webpack_require__.o(definition, key) &&
			!__webpack_require__.o(exports, key)
		) {
			Object.defineProperty(exports, key, {
				enumerable: true,
				get: definition[key],
			});
		}
	}
};

代码也很简单,通过给key设置get方法的方式,去调用定义的函数,去取export的值,通过这样的方式实现所谓的“引用”。

这里可能又会有一个疑问,在commonJS里也定义get方法,能不能实现传递“引用”呢,我们再写一个例子。

定义a.js返回value的值,1秒后再次变更value的值。

main.js

const a = require("./a");
console.log(a.value);
setTimeout(() => {
	console.log(a.value);
});

a.js

let value = 1;
setTimeout(() => {
	value = 2;
}, 2000);
const obj = {
	value: value,
};
module.exports = obj;

输出结果

1
1

如我们预期,commonJS的返回值不会改变,实现不了引用效果。

我们将module.exports值也改成通过get返回值来试试呢

a.js

let value = 1;
setTimeout(() => {
	value = 2;
}, 2000);
const obj = {
	value: value,
};
Object.defineProperty(obj, "value", {
	enumerable: true,
	get: () => {
		return value;
	},
});
module.exports = obj;

输出结果

1
2

这时我们发现commonJS也实现了导出"引用",并没有那么神秘,我们发现用的还是值传递,JS中也只有值传递。

总结

本文从webpack的模块实现角度,通俗解释了模块的循环问题中的一些常见疑惑。结合一下上面代码,总结一下这里谈到的问题:

  • commonJS和ESM的webpack实现都是通过缓存来处理循环引用问题的。
  • commonJS和ESM处理循环引用的时机不同,一个是运行时,一个是在编译时。
  • JS中只有值传递,webapck的ESM默认通过重写get方法的方式实现“引用”。

参考链接

nodejs.org/api/modules…

nodejs.org/api/esm.htm…