前端模塊化之循環加載

目錄html

  • 什麼是循環加載
  • CommonJS 模塊的循環加載
  • ES6 模塊的循環加載
  • 小結
  • 參考

1.什麼是循環加載前端

「循環加載」簡單來講就是就是腳本之間的相互依賴,好比a.js依賴b.js,而b.js又依賴a.js。例如:node

// a.js
const b = require('./b.js')

// b.js
const a = require('./a.js')

對於循環依賴,若是沒有處理機制,則會形成遞歸循環,而遞歸循環是應該被避免的。而且在實際的項目開發中,咱們很難避免循環依賴的存在,好比頗有可能出現a文件依賴b文件,b文件依賴c文件,c文件依賴a文件這種情形。es6


也所以,對於循環依賴問題,其解決方案不是不要寫循環依賴(沒法避免),而是從模塊化規範上提供相應的處理機制去識別循環依賴並作處理。api


接下來將介紹如今主流的兩種模塊化規範 CommonJS 模塊和 ES6 模塊是如何處理循環依賴以及它們有什麼差別。緩存


2.CommonJS 模塊的循環加載模塊化

CommonJS 模塊規範使用 require 語句導入模塊,module.exports 語句導出模塊。post

CommonJS 模塊是運行時加載:ui

運行時遇到模塊加載命令 require,就會去執行這個模塊,輸出一個對象(即module.exports屬性),而後再從這個對象的屬性上取值,輸出的屬性是一個值的拷貝,即一旦輸出一個值,模塊內部這個值發生了變化不會影響到已經輸出的這個值。code


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 的加載實現

相關文章
相關標籤/搜索