目錄
- 什麼是迴圈載入
- 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.參考