前端模組化之迴圈載入

forcheng發表於2020-07-12

目錄

  • 什麼是迴圈載入
  • 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指令碼 執行的順序如下:

① 輸出字串 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 doneb指令碼執行完畢,回到之前的a指令碼

a指令碼繼續從第4行開始執行,隨後輸出字串in a, b.done = true,接著a指令碼中輸出的done變數被設定為true,最後輸出字串 a donea指令碼執行完畢,回到之前的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.參考

前端模組化:CommonJS,AMD,CMD,ES6

你可能不知道的 JavaScript 模組化野史

Module 的載入實現

相關文章