目錄
- 什么是循環加載
- CommonJS 模塊的循環加載
- ES6 模塊的循環加載
- 小結
- 參考
1.什么是循環加載
“循環加載”簡單來說就是就是腳本之間的相互依賴,比如a.js依賴b.js,而b.js又依賴a.js。例如:
// a.js
const b = require('./b.js')
// b.js
const a = require('./a.js')
對於循環依賴,如果沒有處理機制,則會造成遞歸循環,而遞歸循環是應該被避免的。並且在實際的項目開發中,我們很難避免循環依賴的存在,比如很有可能出現a文件依賴b文件,b文件依賴c文件,c文件依賴a文件這種情形。
也因此,對於循環依賴問題,其解決方案不是不要寫循環依賴(無法避免),而是從模塊化規范上提供相應的處理機制去識別循環依賴並做處理。
接下來將介紹現在主流的兩種模塊化規范 CommonJS 模塊和 ES6 模塊是如何處理循環依賴以及它們有什么差異。
2.CommonJS 模塊的循環加載
CommonJS 模塊規范使用 require 語句導入模塊,module.exports 語句導出模塊。
CommonJS 模塊是運行時加載:
運行時遇到模塊加載命令 require,就會去執行這個模塊,輸出一個對象(即
module.exports屬性),然后再從這個對象的屬性上取值,輸出的屬性是一個值的拷貝,即一旦輸出一個值,模塊內部這個值發生了變化不會影響到已經輸出的這個值。
CommonJS 的一個模塊,就是一個腳本文件。require 命令第一次加載該腳本,就會執行整個腳本,然后在內存生成一個對象。對於同一個模塊無論加載多少次,都只會在第一次加載時運行一次,之后再重復加載,就會直接返回第一次運行的結果(除非手動清除系統緩存)。
// module
{
id: '...', //模塊名,唯一
exports: { ... }, //模塊輸出的各個接口
loaded: true, //模塊的腳本是否執行完畢
...
}
上述代碼是一個 Node 的模塊對象,而用到這個模塊時,就會從對象的 exports屬性中取值。
CommonJS 模塊解決循環加載的策略就是:一旦某個模塊被循環加載,就只輸出已經執行的部分,沒有執行的部分不輸出。
用一個 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);
main腳本執行結果如下:

main腳本 執行的順序如下:
① 輸出字符串 main starting 后,加載a腳本
② 進入 a 腳本,a腳本中輸出的done變量被設置為false,隨后輸出字符串 a starting,然后加載 b腳本
③ 進入 b 腳本,隨后輸出字符串 b starting,接着b腳本中輸出的done變量被設置為false,然后加載 a腳本,發現了循環加載,此時不會再去執行a腳本,只輸出已經執行的部分(即輸出a腳本中的變量done,此時其值為false),隨后輸出字符串in b, a.done = false,接着b腳本中輸出的done變量被設置為true,最后輸出字符串 b done,b腳本執行完畢,回到之前的a腳本
④ a腳本繼續從第4行開始執行,隨后輸出字符串in a, b.done = true,接着a腳本中輸出的done變量被設置為true,最后輸出字符串 a done,a腳本執行完畢,回到之前的main腳本
⑤ main腳本繼續從第3行開始執行,加載b腳本,發現b腳本已經被加載了,將不再執行,直接返回之前的結果,最終輸出字符串in main, a.done = true, b.done = true,至此main腳本執行完畢
3.ES6 模塊的循環加載
ES6 模塊規范使用 import 語句導入模塊中的變量,export 語句導出模塊中的變量。
ES6 模塊是編譯時加載:
編譯時遇到模塊加載命令 import,不會去執行這個模塊,只會輸出一個只讀引用,等到真的需要用到這個值時(即運行時),再通過這個引用到模塊中取值。換句話說,模塊內部這個值改變了,仍舊可以根據輸出的引用獲取到最新變化的值。
跟 CommonJS 模塊一樣,ES6 模塊也不會再去執行重復加載的模塊,並且解決循環加載的策略也一樣:一旦某個模塊被循環加載,就只輸出已經執行的部分,沒有執行的部分不輸出。
但ES6 模塊的循環加載與 CommonJS 存在本質上的不同。由於 ES6 模塊是動態引用,用 import從一個模塊加載變量,那個變量不會被緩存(是一個引用),所以只需要保證真正取值時能夠取到值,即已經聲明初始化,代碼就能正常執行。
以下代碼示例,是用 Node 來加載 ES6 模塊,所以使用.mjs后綴名。(從Node v13.2 版本開始,才默認打開了 ES6 模塊支持)
實例一:
// a.mjs
import { bar } from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';
// b.mjs
import { foo } from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';
執行 a腳本,會發現直接報錯,如下圖:

簡單分析一下a腳本執行過程:
① 開始執行a腳本,加載b腳本
② 進入b腳本,加載a腳本,發現了循環加載,此時不會再去執行a腳本,只輸出已經執行的部分,但此時a腳本中的foo變量還未被初始化,接着輸出字符串a.mjs,之后嘗試輸出foo變量時,發現foo變量還未被初始化,所以直接拋出異常
因為foo變量是用let關鍵字聲明的變量,let關鍵字在執行上下文的創建階段,只會創建變量而不會被初始化(undefined),並且 ES6 規定了其初始化過程是在執行上下文的執行階段(即直到它們的定義被執行時才初始化),使用未被初始化的變量將會報錯。詳細了解let關鍵字,可以參考這篇文章深入理解JS:var、let、const的異同。
實例二:用 var 代替 let 進行變量聲明。
// a.mjs
import { bar } from './b';
console.log('a.mjs');
console.log(bar);
export var foo = 'foo';
// b.mjs
import { foo } from './a';
console.log('b.mjs');
console.log(foo);
export var bar = 'bar';
執行 a腳本,將不會報錯,其結果如下:

這是因為使用 var 聲明的變量都會在執行上下文的創建階段時作為變量對象的屬性被創建並初始化(undefined),所以加載b腳本時,a腳本中的foo變量雖然沒有被賦值,但已經被初始化,所以不會報錯,可以繼續執行。
4.小結
ES6 模塊與 CommonJS 模塊都不會再去執行重復加載的模塊,並且解決循環加載的策略也一樣:一旦某個模塊被循環加載,就只輸出已經執行的部分,沒有執行的部分不輸出。但由於 CommonJS 模塊是運行時加載而 ES6 模塊是編譯時加載,所以也存在一些不同。
5.參考
