示例代碼托管在我的代碼倉: "http://www.github.com/dashnowords/blogs" 博客園地址: "《大史住在大前端》原創博文目錄" 華為雲社區地址: "【你要的前端打怪升級指南】" [TOC] 一. 概述 許多前端工程師沉浸在使用腳手架工具的快感中,認為 這種前端模塊化 ...
目錄
示例代碼托管在我的代碼倉:http://www.github.com/dashnowords/blogs
博客園地址:《大史住在大前端》原創博文目錄
華為雲社區地址:【你要的前端打怪升級指南】
一. 概述
許多前端工程師沉浸在使用腳手架工具的快感中,認為require.js
這種前端模塊化的庫已經過氣了,的確如果只從使用場景來看,在以webpack
為首的自動化打包趨勢下,大部分的新代碼都已經使用CommonJs或ES Harmony規範實現前端模塊化,require.js
的確看起來沒什麼用武之地。但是前端模塊化的基本原理卻基本都是一致的,無論是實現了模塊化載入的第三方庫源碼,還是打包工具生成的代碼中,你都可以看到類似的模塊管理和載入框架,所以研究require.js
的原理對於前端工程師來說幾乎是不可避免的,即使你繞過了require.js
,也會在後續學習webpack的打包結果時學習類似的代碼。研究模塊化載入邏輯對於開發者理解javascript回調的運行機制非常有幫助,同時也可以提高抽象編程能力。
二. require.js
2.1 基本用法
require.js
是一個實現了AMD
(不清楚AMD
規範的同學請戳這裡【AMD模塊化規範】)模塊管理規範的庫(require.js
同時也能夠識別CMD
規範的寫法),基本的使用方法也非常簡單:
類庫引入,在主頁
index.html
中引入require.js
:<script src="require.js" data-main="main.js"></script>
data-main
自定義屬性指定了require.js
完成初始化後應該載入執行的第一個文件。在
main.js
中調用require.config
傳入配置參數,並通過require
方法傳入主啟動函數://main.js require.config(( baseUrl:'.', paths:{ jQuery:'lib/jQuery.min', business1:'scripts/business1', business2:'scripts/business2', business3:'scripts/business3' } )) require(['business1','business2'],function(bus1,bus2){ console.log('主函數執行'); bus2.welcome(); });
模塊定義通過
define
函數定義define(id?:string, deps?:Array<string>, factory:function):any
訪問
index.html
後的模塊載入順序:訪問的順序從
require
方法執行開始打亂,main.js
中的require
方法調用聲明瞭對business1
和business2
兩個模塊的依賴,那麼最後一個參數(主方法)不會立即解析,而是等待依賴模塊載入,當下載到定義business1
模塊的文件scripts/business1.js
後,寫在該文件中的define
方法會被執行,此時又發現當前模塊依賴business3
模塊,程式又會延遲生成business1
模塊的工廠方法(也就是scripts/business1.js
中傳入define
方法的最後一個函數參數),轉而先去載入business3
這個模塊,如果define
方法沒有聲明依賴,或者聲明的依賴都已經載入,就會執行傳入的工廠方法生成指定模塊,不難理解模塊的解析是從葉節點開始最終在根節點也就是主工廠函數結束的。所以模塊文件載入順序和工廠方法執行順序基本是相反的,最先載入的模塊文件中的工廠方法可能最後才被運行(也可能是亂序,但符合依賴關係),因為需要等待它依賴的模塊先載入完成,運行順序可參考下圖(運行結果來自第三節中的demo):
2.2 細說API設計
require.js
在設計上貫徹了多態原則,API非常精簡。
模塊定義的方法只有一個define
,但是包含了非常多情況:
1個參數
function
類型將參數判定為匿名模塊的工廠方法,僅起到作用域隔離的作用。
object
類型將模塊識別為數據模塊,可被其他模塊引用。
2個參數
string
+function | object
第一參數作為模塊名,第二參數作為模塊的工廠方法或數據集。
array<string>
+function | object
第一參數作為依賴列表,第二參數作為匿名模塊工廠方法或數據集。
3個參數
第一個參數作為模塊名,第二個參數作為依賴列表,第三個參數作為工廠方法或數據集。
deps : array<string>
依賴列表中成員的解析包含
/
或./
或../
判定為依賴資源的地址
不包含上述字元
判定為依賴模塊名
模塊載入方法require
也是諸多方法的集合:
1個參數
string
類型按照模塊名或地址來載入模塊。
array
類型當做一組模塊名或地址來載入,無載入後回調。
2個參數
第一個參數作為依賴數組,第二個參數作為工廠方法。
在這樣的設計中,不同參數類型對應的函數重載在
require.js
內部進行判定分發,使得由用戶編寫的調用邏輯顯得更加簡潔一致。
三. 造輪子
作為前端工程師,只學會使用方法是遠遠不夠的,本節中我們使用“造輪子”的方法造一個簡易的require.js
,以便探究其中的原理。本節使用的示例中,先載入require.js
,入口文件為main.js
,主邏輯中前置依賴為business1
和business2
兩個模塊,business1
依賴於business3
模塊,business2
依賴於jQuery
。如下所示:
3.1 模塊載入執行的步驟
上一節在分析require.js
執行步驟時我們已經看到,當一個模塊依賴於其他模塊時,它的工廠方法(require
或define
的最後一個參數)是需要先緩存起來的,程式需要等待依賴模塊都載入完成後才會執行這個工廠方法。需要註意的是,工廠方法的執行順序只能從依賴樹的葉節點開始,也就是說我們需要一個棧結構來限制它的執行順序,每次先檢測棧頂模塊的依賴是否全部下載解析完畢,如果是,則執行出棧操作並執行這個工廠方法,然後再檢測新的棧頂元素是否滿足條件,以此類推。
define
方法的邏輯是非常類似的,現在moduleCache
中登記一個新模塊,如果沒有依賴項,則直接執行工廠函數,如果有依賴項,則將工廠函數推入unResolvedStack
待解析棧,然後依次對聲明的依賴項調用require
方法進行載入。
我們會在每一個依賴的文件解析完畢觸發onload
事件時將對應模塊的緩存信息中的load
屬性設置為true
,然後執行檢測方法,來檢測unResolvedStack
的棧頂元素的依賴項是否都已經都已經完成解析(解析完畢的依賴項在moduleCache
中記錄的對應模塊的load
屬性為true),如果是則執行出棧操作並執行這個工廠方法,然後再次運行檢測方法,直到棧頂元素當前無法解析或棧為空。
3.2 代碼框架
我們使用基本的閉包自執行函數的代碼結構來編寫requireX.js
(示例中只實現基本功能):
;(function(window, undefined){
//模塊路徑記錄
let modulePaths = {
main:document.scripts[0].dataset.main.slice(0,-3) //data-main傳入的路徑作為跟模塊
};
//模塊載入緩存記錄
let moduleCache = {};
//待解析的工廠函數
let unResolvedStack = [];
//匿名模塊自增id
let anonymousIndex = 0;
//空函數
let NullFunc =()=>{};
/*moduleCache中記錄的模塊信息定義*/
class Module {
constructor(name, path, deps=[],factory){
this.name = name;//模塊名
this.deps = deps;//模塊依賴
this.path = path;//模塊路徑
this.load = false;//是否已載入
this.exports = {};//工廠函數返回內容
this.factory = factory || NullFunc;//工廠函數
}
}
//模塊載入方法
function _require(...rest){
//...
}
//模塊定義方法
function _define(...rest){
}
//初始化配置方法
_require.config = function(conf = {}){
}
/**
*一些其他的內部使用的方法
*/
//全局掛載
window.require = _require;
window.define = _define;
//從data-main指向開始解析
_require('main');
})(window);
3.3 關鍵函數的代碼實現
下麵註釋覆蓋率超過90%了,不需要再多說什麼。
- 載入方法
_require
(省略了許多條件判斷,只保留了核心邏輯)
function _require(...rest){
let paramsNum = rest.length;
switch (paramsNum){
case 1://如果只有一個字元串參數,則按模塊名對待,如果只有一個函數模塊,則直接執行
if (typeof rest[0] === 'string') {
return _checkModulePath(rest[0]);
}
break;
case 2:
if (Object.prototype.toString.call(rest[0]).slice(8,13) === 'Array' && typeof rest[1] === 'function'){
//如果依賴為空,則直接運行工廠函數,並傳入預設參數
return _define('anonymous' + anonymousIndex++, rest[0], rest[1]);
}else{
throw new Error('參數類型不正確,require函數簽名為(deps:Array<string>, factory:Function):void');
}
break;
}
}
如果傳入一個字元,則將其作為模塊名傳入_checkModulePath
方法檢測是否有註冊路徑,如果有路徑則去獲取定義這個模塊的文件,如果傳入兩個參數,則運行_define
方法將其作為匿名模塊的依賴和工廠函數處理。
- 模塊定義方法
_define
function _define(id, deps, factory){
let modulePath = modulePaths[id];//獲取模塊路徑,可能是undefined
let module = new Module(id, modulePath, deps, factory);//註冊一個未載入的新模塊
moduleCache[id] = module;//模塊實例掛載至緩存列表
_setUnResolved(id, deps, factory);//處理模塊工廠方法延遲執行邏輯
}
- 延遲執行工廠方法的函數
_setUnResolved
function _setUnResolved(id, deps, factory) {
//壓棧操作緩存要延遲執行的工廠函數
unResolvedStack.unshift({id, deps,factory});
//遍歷依賴項數組對每個依賴執行檢測路徑操作,檢測路徑存在後對應的是js文件獲取邏輯
deps.map(dep=>_checkModulePath(dep));
}
- 模塊載入邏輯
_loadModule
function _loadModule(name, path) {
//如果存在模塊的緩存,表示已經登記,不需要再次獲取,在其onload回調中修改標記後即可被使用
if(name !== 'root' && moduleCache[name]) return;
//如果沒有緩存則使用jsonp的方式進行首次載入
let script = document.createElement('script');
script.src = path + '.js';
script.defer = true;
//初始化待載入模塊緩存
moduleCache[name] = new Module(name,path);
//載入完畢後回調函數
script.onload = function(){
//修改已登記模塊的載入解析標記
moduleCache[name].load = true;
//檢查待解析模塊棧頂元素是否可解析
_checkunResolvedStack();
}
console.log(`開始載入${name}模塊的定義文件,地址為${path}.js`);
//開始執行腳本獲取
document.body.appendChild(script);
}
- 檢測待解析工廠函數的方法
_checkunResolvedStack
function _checkunResolvedStack(){
//如果沒有待解析模塊,則直接返回
if (!unResolvedStack.length)return;
//否則查看棧頂元素的依賴是否已經全部載入
let module = unResolvedStack[0];
//獲取聲明的依賴數量
let depsNum = module.deps.length;
//獲取已載入的依賴數量
let loadedDepsNum = module.deps.filter(item=>moduleCache[item].load).length;
//如果依賴已經全部解析完畢
if (loadedDepsNum === depsNum) {
//獲取所有依賴的exports輸出
let params = module.deps.map(dep=>moduleCache[dep].exports);
//運行待解析模塊的工廠函數並掛載至解析模塊的exports輸出
moduleCache[module.id].exports = module.factory.apply(null,params);
//待解析模塊出棧
unResolvedStack.shift();
//遞歸檢查
return _checkunResolvedStack();
}
}
示例的效果是頁面中提示語緩慢顯示出來。的完整的示例代碼可從篇頭的github
倉庫中獲取,歡迎點星星。