當(dāng) a
模塊執(zhí)行時(shí)依賴 b
模塊,b
模塊的執(zhí)行又反過來依賴 a
模塊窿祥,此時(shí)就發(fā)生了循環(huán)依賴株憾。循環(huán)依賴在平常的業(yè)務(wù)代碼里比較罕見,一般遇到就意味著代碼架構(gòu)是時(shí)候認(rèn)真梳理一下了。
但在依賴關(guān)系復(fù)雜的系統(tǒng)里嗤瞎,是有可能出現(xiàn)循環(huán)依賴的情況墙歪。讓我們一起來看看在 Commonjs
(nodejs
)、ES module
贝奇、Amd
(RequireJS
)和 Cmd
(Seajs
)各種主流模塊標(biāo)準(zhǔn)下的循環(huán)依賴表現(xiàn)及其背后的原理虹菲。
Commonjs
我們來看看node官方文檔里提供的 循環(huán)依賴demo。
// 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);
執(zhí)行 main.js
掉瞳,輸出如下:
$ 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
這里在執(zhí)行 a.js
時(shí)届惋,依賴 b.js
,而執(zhí)行 b.js
時(shí)菠赚,反過來又依賴 a.js
的輸出,造成了循環(huán)依賴郑藏,然而程序并不會(huì)陷入無限循環(huán)衡查,這里到底發(fā)生了什么?根據(jù)官方原文:
In order to prevent an infinite loop, an unfinished copy of the a.js exports object is returned to the b.js module.
翻譯過來就是必盖,模塊被循環(huán)依賴時(shí)拌牲,只會(huì)輸出當(dāng)前執(zhí)行完成的導(dǎo)出值。也就是說歌粥,b.js
在依賴未執(zhí)行完成的 a.js
時(shí)塌忽,并不會(huì)等待 a.js
執(zhí)行完,而是直接輸出當(dāng)前執(zhí)行過的 export
對(duì)象失驶,也就是例程中的第二行:
// a.js
exports.done = false;
除此之外土居,我們還注意到一點(diǎn),main.js
在執(zhí)行 require('./b.js')
時(shí)嬉探,為什么 log 都沒打印出來擦耀?很顯然 node 在這里做了緩存,而且緩存時(shí)機(jī)必須是模塊執(zhí)行完成之后涩堤,畢竟 main.js
最后輸出的 a.done
和 b.done
都是 true
眷蜓。
ES module
關(guān)于 ES module
的循環(huán)依賴表現(xiàn),我這里提供了2個(gè)比較有代表性的 demo胎围,都是運(yùn)行在 node 端吁系。
demo1
// a.mjs
console.log('a starting');
export default {
done: true,
}
import b from './b.mjs';
console.log('in a, b.done = %j', b.done);
console.log('a done');
// b.mjs
console.log('b starting');
export default {
done: true,
}
import a from './a.mjs';
console.log('in b, a.done = %j', a.done);
console.log('b done');
執(zhí)行 a.mjs
,輸出如下
$ node --experimental-modules a.mjs
b starting
ReferenceError: a is not defined
如果 ES module
和 Commonjs
一樣都是運(yùn)行時(shí)加載/導(dǎo)出白魂,那么按照 js 代碼的執(zhí)行順序汽纤,b.mjs
讀取 a.done
時(shí)不應(yīng)該拋出 undefined
異常;另外碧聪,雖然入口模塊是 a.mjs
冒版,但先打印出的是 b starting
,所以不難猜想:
ES module
不是動(dòng)態(tài)解析逞姿,且依賴模塊優(yōu)先執(zhí)行
demo2
// a.mjs
import b from './b.mjs';
console.log('a starting');
console.log(b());
export default function () {
return 'run func A';
}
console.log('a done');
// b.mjs
import a from './a.mjs';
console.log('b starting');
console.log(a());
export default function () {
return 'run func B';
}
console.log('b done');
執(zhí)行 a.mjs
辞嗡,輸出如下
$ node --experimental-modules a.mjs
b starting
run func A
b done
a starting
run func B
a done
啥情況捆等?怎么把導(dǎo)出對(duì)象 object
改為導(dǎo)出函數(shù) function
就不會(huì)報(bào) undefined
異常?接下來讓我們帶著以上的結(jié)論和問題來探究 ES module
原理续室。
ES module 原理
這里只會(huì)簡(jiǎn)短闡述原理栋烤,詳見 ES modules: A cartoon deep-dive。實(shí)際上 ES module
從加載入口模塊到所有模塊實(shí)例的執(zhí)行主要經(jīng)歷了三步:構(gòu)建挺狰、實(shí)例化和運(yùn)行明郭。
- 構(gòu)建
從入口模塊開始,根據(jù)
import
關(guān)鍵字遍歷依賴樹丰泊,每遍歷一個(gè)模塊則生成該模塊的模塊記錄(module record)
薯定,最后生成整個(gè)模塊圖譜(module graph)
。
注意瞳购,這一步是 ES module
和 Commonjs
的本質(zhì)區(qū)別:
因?yàn)?ES module
需要支持瀏覽器端话侄,而構(gòu)建過程要獲取所有的模塊文件來繪制模塊依賴圖譜,如果參考 Commonjs
的做法把模塊解析和運(yùn)行放在一起学赛,那么冗長(zhǎng)的下載過程將會(huì)嚴(yán)重阻塞主線程導(dǎo)致應(yīng)用長(zhǎng)時(shí)間不可用年堆,所以 ES module
在構(gòu)建過程不會(huì)實(shí)例化和執(zhí)行任何的js代碼,也就是所謂的 靜態(tài)解析
過程盏浇。
這同時(shí)也解釋了為何不支持使用表達(dá)式/變量的 import
語句:
// 報(bào)錯(cuò)
let module = 'my_module';
import { foo } from module;
所有的模塊記錄都會(huì)被緩存在 模塊映射(module map)
中变丧,被依賴多次的模塊也只會(huì)存在唯一一條映射記錄,從而避免模塊的重復(fù)下載和實(shí)例化绢掰。
- 實(shí)例化
根據(jù)模塊記錄的關(guān)系痒蓬,在內(nèi)存中把模塊的導(dǎo)入
import
和導(dǎo)出export
連接在一起,也稱為活綁定(live bindings)
滴劲。
JS引擎會(huì)為每個(gè)模塊記錄創(chuàng)建 模塊環(huán)境記錄(module environment record)
谊却,用來關(guān)聯(lián)模塊實(shí)例和模塊的導(dǎo)入/導(dǎo)出值。引擎會(huì)先采用 深度優(yōu)先后序遍歷(depth first post-order traversal)
哑芹,將模塊及其依賴的導(dǎo)出 export
連接到內(nèi)存中(直到依賴樹末端)炎辨,然后逐層返回再把模塊相對(duì)應(yīng)的導(dǎo)入 import
連接到內(nèi)存的同一位置。這也解釋了為什么導(dǎo)出模塊的值變更時(shí)聪姿,導(dǎo)入模塊也能捕捉到該值的變更碴萧。
需要注意的是,實(shí)例化只是JS引擎在內(nèi)存中綁定模塊間關(guān)系末购,并沒有執(zhí)行任何代碼破喻,也就是說這些連接好的內(nèi)存空間中并沒有存儲(chǔ)變量值,然而盟榴,在此過程中導(dǎo)出函數(shù)將會(huì)被初始化曹质,即所謂的 函數(shù)具有提升作用
。
這使循環(huán)依賴的問題自然而然地被解決:
JS引擎不需要關(guān)心是否存在循環(huán)依賴,只需要在代碼運(yùn)行的時(shí)候羽德,從內(nèi)存空間中讀取該導(dǎo)出值几莽。
我們回到上面提供的 ES module
循環(huán)依賴的例程。
第一個(gè)例程 b.mjs
模塊(簡(jiǎn)稱 b
模塊)在獲取 a.mjs
模塊(簡(jiǎn)稱 a
模塊)的導(dǎo)出值時(shí)宅静,a
模塊的對(duì)象 { done: true }
并沒有被聲明和賦值章蚣,所以會(huì)拋出 undefined
異常。
第二個(gè)例程姨夹,由于函數(shù)具有提升作用纤垂,b
模塊獲取 a
模塊導(dǎo)出值時(shí),a
模塊的 foo
函數(shù)已經(jīng)被聲明磷账,不會(huì)拋出異常峭沦。
- 運(yùn)行
也就是往內(nèi)存空間中填充真實(shí)值。
JS引擎會(huì)采用和實(shí)例化時(shí)一樣的深度優(yōu)先后序遍歷來執(zhí)行模塊及其依賴的頂級(jí)代碼(即除函數(shù)聲明之外的代碼)逃糟,所以會(huì)出現(xiàn) demo1 中的 log 順序熙侍。
nodejs
已經(jīng)實(shí)現(xiàn)了對(duì) ES module
的支持,目前只是作為一個(gè)實(shí)驗(yàn)特性履磨,我會(huì)找時(shí)間研究 node
實(shí)現(xiàn) Commonjs
和 ES module
的底層源碼,大家敬請(qǐng)期待庆尘。
RequireJS
RequireJS
和 Seajs
都是主要針對(duì)瀏覽器端的模塊加載器剃诅,模塊加載流程離不開這幾點(diǎn):
- 根據(jù)加載器規(guī)則尋找模塊,并通過插入script標(biāo)簽異步加載驶忌;
- 在模塊代碼中通過詞法分析找出依賴模塊并加載矛辕,遞歸此過程直到依賴樹末端;
- 綁定
load
事件付魔,當(dāng)依賴模塊都加載完成時(shí)執(zhí)行回調(diào)函數(shù)聊品;
當(dāng)然加載器還涉及緩存機(jī)制、容錯(cuò)處理和一些復(fù)雜的配置等几苍,有興趣的同學(xué)可以看看源碼自行研究翻屈,這里就不詳細(xì)說了。
這里我們把 Commonjs
的 demo 稍微改動(dòng)下妻坝,使其運(yùn)行在瀏覽器端:
<!-- index.html -->
<html>
<body>
<script data-main="./app.js" src="./require.js"></script>
</body>
</html>
// app.js
define(['./a', './b'], function(a, b) {
console.log('app starting');
console.log('in app', a, b);
});
// a.js
define(['./b', 'exports'], function(b, exports) {
console.log('a starting');
exports.done = false;
console.log('in a, b.done =', b.done);
console.log('a done');
exports.done = true;
});
// b.js
define(['./a', 'exports'], function(a, exports) {
console.log('b starting');
exports.done = false;
console.log('in b, a.done =', a.done);
console.log('b done');
exports.done = true;
});
啟動(dòng) http-server:
# npm install -g http-server
$ http-server
打開 chrome伸眶,查看 console 控制臺(tái)輸出:
b starting
b.js:4 in b, a.done = undefined
b.js:5 b done
a.js:2 a starting
a.js:4 in a, b.done = true
a.js:5 a done
app.js:2 app starting
app.js:3 in app {done: true} {done: true}
首先打印的是 b
模塊中的 console.log('b starting')
,而不是 app
模塊中的 console.log('app starting')
刽宪,可以看出 Requirejs
是遵循 依賴前置
原則:demo 中 a
模塊依賴 b
模塊厘贼,在 a
模塊回調(diào)執(zhí)行前,會(huì)先確保 b
模塊執(zhí)行完畢圣拄,所以 b
模塊中 a.done = undefined
嘴秸。需要注意的是,如果不使用 exports
包來導(dǎo)出模塊返回值而選擇直接 return
的話,b
模塊中訪問 a
模塊導(dǎo)出值將會(huì)報(bào) undefined
異常岳掐,相當(dāng)于說 exports
包為模塊的導(dǎo)出預(yù)置了一個(gè)空對(duì)象(詳見 RequireJS API)凭疮。
所以 RequireJS
在解決循環(huán)依賴時(shí),假設(shè)模塊都沒有執(zhí)行過(沒有緩存記錄)的前提下岩四,總會(huì)有其中一個(gè)模塊讀取依賴值是 空對(duì)象
或者 undefined
哭尝。
Seajs
那么同樣的 demo 運(yùn)行在 Seajs
框架下是什么效果呢?稍微改動(dòng)下代碼使其符合 Cmd
規(guī)范:
<!-- index.html -->
<html>
<body>
<script src="./sea.js"></script>
<script>
seajs.use('./app.js');
</script>
</body>
</html>
// app.js
define(function(require) {
var a = require('./a');
var b = require('./b');
console.log(a, b);
});
// a.js
define(function(require, exports) {
console.log('a starting');
exports.done = false;
var b = require('./b');
console.log('in a, b.done =', b.done);
console.log('a done');
exports.done = true;
});
// b.js
define(function(require, exports) {
console.log('b starting');
exports.done = false;
var a = require('./a');
console.log('in b, a.done =', a.done);
console.log('b done');
exports.done = true;
});
控制臺(tái)輸出:
app.js:2 app starting
a.js:2 a starting
b.js:2 b starting
b.js:5 in b, a.done = false
b.js:6 b done
a.js:5 in a, b.done = true
a.js:6 a done
app.js:5 in app {done: true} {done: true}
和 RequireJS
的 log 不一樣(但和 Commonjs
的 demo 輸出完全一致)剖煌,這里是先打印 app starting
材鹦,印證了 Seajs
所遵循的 依賴就近
原則,就是模塊只有在被 require
的時(shí)候才會(huì)執(zhí)行耕姊。所以 Seajs
和 Commonjs
解決循環(huán)依賴的辦法都是一樣的簡(jiǎn)單粗暴桶唐,需要的時(shí)候就去緩存中實(shí)時(shí)取副本,取到什么就是什么茉兰。
無論是哪一種規(guī)范尤泽,都沒有局限于在哪一端運(yùn)行,譬如 Commonjs
和 ES module
都支持在 node
端或?yàn)g覽器端運(yùn)行规脸。為了解決各大瀏覽器對(duì)于這些模塊化標(biāo)準(zhǔn)的支持度不一的問題坯约,我們一般使用 webpack、browserify 等構(gòu)建工具處理模塊代碼莫鸭,下一期會(huì)著重講解 webpack 是如何實(shí)現(xiàn) Commonjs
和 ES module
等模塊標(biāo)準(zhǔn)的闹丐。
PS:本文章涉及的所有 demo 已放在 github 上。