如何實現(xiàn)一個異步模塊加載器

作者:youngwind
原文鏈接:https://github.com/youngwind/blog/issues/98
引言
異步是前端中的一個重點。
今天就結合模塊,和大家講分享一下:如何實現(xiàn)一個簡單的模塊加載器。
正文
最近參考 require.js 的API,自己動手實現(xiàn)了一個簡單的異步模塊加載器: fake-requirejs。
為什么要做這樣一個東西呢?
原因是:我一直覺得自己對模塊化這方面的理解不夠深入。
即便用了很長時間的 webpack,看了很多模塊化相關的資料,比如:
模塊化的發(fā)展歷史 amd commonjs 和 cmd規(guī)范之爭
等等。
然而,我依然覺得自己的理解流于表面,所以決定自己動手實現(xiàn)一個。
目標的選擇
本來一開始的目標是webpack的,但是后來考慮到:
webpack是建立在模塊化基礎上的一個構建工具。
且webpack的實現(xiàn)也相當?shù)膹碗s,而我希望能夠刻意區(qū)分開模塊化和構建這兩個概念。
因為這有助于我集中有限的精力研究模塊化這一個概念,所以后來決定實現(xiàn)requirejs,這是一個相對來說比較簡單的異步模塊加載器。
雖然現(xiàn)在使用它的人已經越來越少了,但是正因為其簡單和純粹,倒是非常適合現(xiàn)在的我。
注:請確保掌握了requirejs的基本用法再往下閱讀。
剛開始敲代碼的時候,我就在想如何實現(xiàn)require函數(shù)和define函數(shù),但是后來我發(fā)現(xiàn)我錯了,因為這陷入了面向過程編程的誤區(qū),正確的方式應該是面向對象編程。
所以,我重新進行了思考。
問題:這里都有哪些類型的對象呢?
答案:至少有模塊(Module)這一類對象
那模塊類對象有哪些數(shù)據(jù)呢?
Module.id // 模塊id
Module.name // 模塊名字
Module.src // 模塊的真實的uri路徑
Module.dep // 模塊的依賴
Module.cb // 模塊的成功回調函數(shù)
Module.errorFn // 模塊的失敗回調函數(shù)
Module.STATUS // 模塊的狀態(tài)(等待中、正在網(wǎng)絡請求、準備執(zhí)行、執(zhí)行成功、出現(xiàn)錯誤……)
又有哪些對應的操作這些數(shù)據(jù)的方法呢?
Module.prototype.init // 初始化,用來賦予各種基本值
Module.prototype.fetch // 通過網(wǎng)絡請求獲取模塊
Module.prototype.analyzeDep // 分析、處理模塊的依賴
Module.prototype.execute // 運算該模塊
依賴分析與處理
順著上面的思路一步步寫,我碰到了一個難點:
如何分析和處理模塊的依賴?
舉個例子:
// 入口main.js
require(['a', 'b'], function (a, b) {
a.hi();
b.goodbye();
}, function () {
console.error('Something wrong with the dependent modules.');
});
我們的目標是:
當模塊a和b都準備好之后,再執(zhí)行成功回調函數(shù);一旦a或b有任意一個失敗,都執(zhí)行失敗回調函數(shù)。
這個跟使用Promise.all和Promise.race很像,但這一次我們是要實現(xiàn)它們。
怎么辦呢?
我想了一個方法:記數(shù)法, 分兩步走。
為Module原型新增Module.depCount屬性,初始值為該模塊依賴模塊數(shù)組的長度。
假如 depCount === 0,說明該模塊依賴的模塊都已經運算好了,通過setter觸發(fā)執(zhí)行該模塊。
某模塊執(zhí)行成功之后,Module.STATUS === 5,通過setter觸發(fā)下一步。
通過對象mapDepToModule,查找到依賴與該模塊的所有模塊,那么讓那些模塊都執(zhí)行depCount--。
注:對象mapDepToModule的作用是:
映射被依賴模塊到依賴模塊之間的關系。
結構如下圖所示:
舉個例子:
當模塊a準備好之后,我們就遍歷mapDepToModule['a']對應的數(shù)組,里面的每一項都執(zhí)行depCount--。

下面是一些關鍵的代碼:
Module.prototype.analyzeDep = function () {
// ...
let depCount = this.dep ? this.dep.length : 0;
Object.defineProperty(this, 'depCount', {
get() {
return depCount;
},
set(newDepCount) {
depCount = newDepCount;
if (newDepCount === 0) {
console.log(`模塊${this.name}的依賴已經全部準備好`);
this.execute(); // 如果depCount===0,執(zhí)行該模塊
}
}
});
this.depCount = depCount;
// ...
};
Object.defineProperty(this, 'status', {
get () {
return status;
},
set (newStatus) {
status = newStatus;
if (status === 5) {
// 假如某個模塊已經準備好了(STATUS===5),
// 那么找出依賴于這個模塊的所有模塊,讓他們都執(zhí)行depCount--
let depedModules = mapDepToModule[this.name];
if (!depedModules) return;
depedModules.forEach((module) => {
setTimeout(() => {
module.depCount--;
});
});
}
}
})
雖然我們都說循環(huán)依賴是一種不好的現(xiàn)象,應該在設計之初盡量避免。
但是,隨著項目越滾越大,誰又能保證一定不會出現(xiàn)?
所以:
作為一個合格的模塊加載器,必須解決循環(huán)依賴的問題。
那么,讓我們先來看看別人是怎么處理的吧。
Commonjs和ES6的循環(huán)依賴 http://www.ruanyifeng.com/blog/2015/11/circular-dependency.html
seajs的循環(huán)依賴 https://github.com/seajs/seajs/issues/732
requirejs的循環(huán)依賴 http://requirejs.cn/docs/api.html#circular
http://www.ruanyifeng.com/blog/2015/11/circular-dependency.html
這里我們不討論各種處理方式孰優(yōu)孰劣,我們只關注:
如何實現(xiàn)requireJS API文檔中那樣的功能?
仔細觀察下面的例子:
a 與 b 出現(xiàn)循環(huán)依賴:
// main.js
require(['a','b'], function (a, b) {
a.hi();
b.goodbye();
}, function () {
console.error('Something wrong with the dependent modules.');
});
// a.js
define(['b'],function (b) {
var hi = function () {
console.log('hi');
};
b.goodbye();
return {
hi: hi
}
});
// b.js
define(['require', 'a'], function (require) {
var goodbye = function () {
console.log('goodbye');
};
// 因為在運算b的時候,a還沒準備好,所以不能直接拿到a,只能用require再發(fā)起一次新的任務
require(['a'], function (a) {
a.hi();
});
return {
goodbye: goodbye
}
});
我們能看到:
模塊b的回調函數(shù)中,并不能直接引用到a,需要使用require方法包住。
那么問題來了:
在原先的設計中, 每一個define是跟一個模塊一一對應的,require只能用一次,用于主入口模塊(如:main.js)的加載。
但是,現(xiàn)在在模塊b的回調函數(shù)中,又出現(xiàn)require(['a']),這顯然是亂套了。
至此,我發(fā)現(xiàn)require不應該僅僅是用于主入口模塊的加載,require應該對應更高層次的抽象概念:我將它命名為:任務(Task),這是一個有別于Module的新的類。
每一次調用require,相當于新建一個Task(任務)。
這個任務的功能是:當任務的所有依賴都準備好之后,執(zhí)行該任務的成功回調函數(shù)。
有沒有發(fā)現(xiàn)這個Task原型與Module很像?
它們都有依賴、回調、狀態(tài),都需要分析依賴、執(zhí)行回調函數(shù)等方法。
但是又有些不同,比如Task沒有網(wǎng)絡請求,所以不需要fetch這樣的方法。
所以,我讓Task繼承了Module,然后重寫某些方法。
關鍵代碼如下:
// before
require = function (dep, cb, errorFn) {
// mainEntryModule是主入口模塊
modules[mainEntryModule.name] = mainEntryModule;
mainEntryModule.dep = dep;
mainEntryModule.cb = cb;
mainEntryModule.errorFn = errorFn;
mainEntryModule.analyzeDep();
};
// after
require = function (dep, cb, errorFn) {
let task = new Task(dep, cb, errorFn);
task.analyzeDep();
};
// 引入新的類: Task(任務)
function Task(dep, cb, errorFn) {
this.tid = ++tid;
this.init(dep, cb, errorFn);
}
// Task類繼承于Module類
Task.prototype = Object.create(Module.prototype);
至此,我們就完成了一個簡單的異步模塊加載器。
最后
歡迎加我微信(winty230),拉你進技術群,長期交流學習...
歡迎關注「前端Q」,認真學前端,做個專業(yè)的技術人...


