前言
看到這個標題,估計有同學會想,又要重複造輪子麼?其實重複造輪子在大多數情況下確實是不太可取的,既浪費了精力又浪費了時間。但這並不能說明重複造輪子完全不可取,比如你想要某個輪子的精簡版,又比如你想學習某個輪子的製造方法,重複造輪子也可以是有意義的。
簡介
接下來,我們就來學學某個輪子簡易版製造方法,這個輪子就是模組載入工具。
說起模組載入工具,估計大家就會想起webpack、commonjs等,更“久遠”一點的會想起requirejs和seajs。這些工具都源於前端的模組化思想。
為什麼前端需要模組化?這主要得益於前端技術的發展,使得前端不再像以前那樣只能展示一下靜態內容,撐死加上幾個飛來飛去的動畫。現在的前端內容越來越豐富,我們可以播放視訊,可以協同工作,還可以玩遊戲。這就導致了前端程式碼量劇增。當程式碼行數噌噌噌往上漲時,模組化思想就自然而然地出來了。
對於前端來說,最簡單的模組化就是拆分成多個檔案,然後在html裡就會出現如下的程式碼:
1 2 3 4 5 6 7 |
<script src="/js/module_a/a1.js"></script> <script src="/js/module_a/a2.js"></script> <script src="/js/module_b/b1.js"></script> <script src="/js/module_c/c1.js"></script> <script src="/js/module_c/c2.js"></script> <script src="/js/module_c/c3.js"></script> <script src="/js/module_c/c4.js"></script> |
各位有沒有覺得這種程式碼有點兒難看?像這樣的程式碼不止難看,依賴也不清晰,假如上面的module_b只是因為module_a的需要才引入的,那麼當我們去掉module_a時還得搜一下相關文件或者原始碼,當我們檢索出確確實實只有module_a才依賴了module_b,我們才敢放心的把module_b給去掉。
因此,就衍生了像requirejs之類模組載入工具,同時還能處理依賴關係。其實像requirejs和webpack之類的構建工具處理模組化時很相似,只是處理模組依賴的時機不同,requirejs是直接在瀏覽器裡處理,而webpack則是在上線前就將模組進行打包。而在程式碼上兩者最大的差異就是,requirejs需要每個模組包裹一層依賴程式碼(其實這層程式碼也可以藉由構建工具生成),而webpack則會在打包後的程式碼裡注入一下模組化的指令碼。事實上這兩者也不是水火不容,這主要看專案的技術選型。
說了那麼多,接下來就來進入正題,我們這次就是來造一個簡易版的類似requirejs的模組載入工具,注意是簡易版,所以這個輪子最好不要直接投入到生產環境中,造這個輪子更多的目的是為了一起學習XD。
需求
使用方式我們就做得簡單一點,只暴露一個方法出來:define方法。
當我們需要定義一個模組時,可以像如下方式編寫程式碼:
1 2 3 4 5 6 7 |
define(['/js/a.js', '/js/b.js'], function(a, b) { return { doSth: function() { a.a(b) } }; }); |
每個模組都用define來定義,宣告依賴的模組和回撥方法。回撥中可以返回一個物件,也可以不返回值。如果返回物件則會被注入到依賴這個模組的模組回撥方法中,如果不返回值則注入空物件。同時依賴的模組可以是純文字檔案或json檔案,如果純文字,注入進來的會是該檔案的字串內容,如果是json檔案則注入json物件:
1 2 3 4 5 6 7 8 |
define(['/json/a.json', '/html/a.html'], function(data, html) { return { doSth: function() { console.log(JSON.stringify(data)); // 輸出a.json的內容 console.log(html); // 輸出a.html的內容 } } }); |
設計與思考
我們這裡有如下幾個問題需要思考一下:
- 如何注入依賴?
- 如何獲取依賴模組的絕對路徑?
- 如何載入依賴的模組?
- 如何處理迴圈依賴?
針對這幾個問題我們來對這個模組載入工具進行設計。
如何注入依賴?
需要注入依賴到當前模組,就得保證依賴是先於當前模組載入並執行完,這樣我們就需要維護一個模組佇列,保證模組載入的順序和儲存模組的狀態。
當遇到define方法進行模組定義時,先獲取依賴,將依賴的載入順序置於當前模組之前,這個我們通過維護一個模組的狀態列表就可以達成。狀態設計成以下三種:
- LOADING:模組正在載入中。
- WAITING:模組已經載入完畢,正在等待依賴模組載入。
- DEFINED:模組和其依賴均已經載入完畢,並且執行過回撥,完成模組定義。
每次定義模組,我們就檢查該模組所依賴的模組狀態,如果依賴都已定義,則進入執行回撥階段;如果依賴未完全就位,則設定為等待中,將未載入的模組放入載入列表進行載入。具體流程如下:
1 2 3 |
模組定義 --> 檢查依賴 --> 依賴都已就位 --> 注入依賴,執行回撥 --> 完成定義 | |--> 依賴未完全就位 --> 未載入模組放入載入列表 --> 載入模組 --> 依賴模組均已完成定義 --> 注入依賴,執行回撥 --> 完成定義 |
值得一提的是,模組的依賴也會有自己的依賴,所以當依賴一旦複雜起來,上面的流程就是迴圈執行的。
注意:當前模組的定義是在載入完畢之後才會進行的,因為模組未載入完畢是無法執行其中的js程式碼的。
如何獲取依賴模組的絕對路徑?
這裡我們用到了一個小技巧,就是直接使用瀏覽器的a標籤來實現。具體程式碼實現如下:
1 2 3 4 5 6 7 8 9 |
var a = document.createElement('a'); a.style.display = 'none'; document.body.appendChild(a); // 獲取絕對路徑 var getAbsoluteURI = function(url) { a.href = url; return a.href; }; |
這樣,我們不再需要小心翼翼地去拼url,全部交給瀏覽器去做,保證又快又好。
注意,在低版本ie裡要使用a.getAttribute(‘href’, 4)的方式獲取href。
如何載入依賴的模組?
這裡的模組分兩種,一種是js模組,一種是文字內容(比如html檔案或json檔案)。
對於js模組,我們直接使用script標籤來實現,這一點和我們用過的jsonp跨域的方式很像。即是動態建立一個script標籤,將script的src設定為我們要載入的模組,然後監聽script的onload事件或onerror事件,在模組載入完後刪除script標籤,然後做其他的一些模組相關操作。程式碼大概如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
var script = document.createElement('script'); script.type = 'text/javascript'; script.charset = 'utf-8'; script.onload = script.onerror = function(e) { // 監聽指令碼載入執行 var script = e.target || e.srcElement; // 清理指令碼節點 if(script && script.parentNode) { // 清除事件 script.onload = script.onerror = null; // 清除script標籤 script.parentNode.removeChild(script); } }; script.src = url; (document.getElementsByTagName('head')[0] || document.body).appendChild(script); |
注意:同樣是低版本ie,不支援script的onload事件,這時候我們要監聽script的onreadystatechange事件,通過判斷script的readystate狀態來斷定是否載入完成。
對於文字內容的載入,這就更簡單了。我們直接通過ajax請求就可以獲取,對於json檔案就再做一層解析就可以了。
如何處理迴圈依賴?
所謂迴圈依賴,就是出現如下那樣你依賴我、我依賴你的情況:
1 2 3 4 5 6 7 8 9 |
// a.js define(['./b.js'], function(b) { return {}; }); // b.js define(['./a.js'], function(a) { return {}; }); |
不過這裡的依賴有兩種,分為弱依賴和強依賴。弱依賴是可以解決的,因為兩個模組之間不是直接依賴,比如下面程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// a.js define(['./b.js'], function(b) { return { a1: function() {console.log(b.b2)}, a2: function() {return 'I am a'} }; }); // b.js define(['./a.js'], function(a) { return { b1: function() {console.log(a.a2)}, b2: function() {return 'I am b'} }; }); |
模組間的依賴不是一個閉環,呼叫模組的任意一個方法都會有一個終結,這就是弱依賴,在程式碼裡我們通過強行注入一個空物件給其中一個模組,並執行其中其回撥來解決這種y。比如上面程式碼中,我們可以強行執行a模組,並且賦值注入的b變數為一個空物件,因為在執行回撥的時候b變數沒有被直接使用,而是在a模組的某個方法裡被使用。這時候我們可以不管b模組是否已定義。等到a模組被強行定義好之後,再去按照正常的方式去定義b模組。最重要的一步,b模組定義完成之後我們要把b模組裡返回的物件拷貝到先前注入到a模組的空物件中,從完成了弱依賴的解決。
為什麼可以這麼做呢?因為js這裡是傳引用呼叫的。我們在定義a模組的時候,先把引用傳進去,反正a模組沒有直接使用到這個依賴,所以它也不關心我們傳進去的物件有沒有東西。等到我們的b模組完成後,再在這個引用指向的物件裡填充資料。
也只有這種特殊的依賴情況我們可以解決,其他的迴圈依賴均被稱為強依賴,會直接形成死鎖,無法被打破。
開工
STEP1
首先,先把我們需要用到的用來維護模組的變數定義起來:
1 2 3 4 5 6 7 8 |
var MODULES = []; // 存放涉及到的所有模組的資訊,包含每個模組的url、依賴和回撥 var STATUS = {}; // 模組的狀態 var RESULTS = {}; // 模組的回撥返回的結果 var STACK = []; // 當前待載入的模組棧 var LOADING = 1; // 載入中 var WAITING = 2; // 等待中 var DEFINED = 3; // 已定義 |
STEP2
接著,把我們需要暴露出去的介面進行實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
/** * 暴露出去的define介面 */ window.define = function(deps, callback) { var args = [].slice.call(arguments, 0); STACK.push(args); // 對於頁面中仍未被檢測過的指令碼進行處理 var list = document.getElementsByTagName('script'); for(var i=list.length-1; i>=0; i--) { var script = list[i]; if(!script.nowhasload) { script.nowhasload = true; if(!script.src && script.innerHTML.search(/\s*define\s*\(/) >= 0) { // 內嵌模組定義語句指令碼 args = STACK.pop(); while(args) { runLoading.apply(window, args); args = STACK.pop(); } } else { // 外嵌模組定義語句指令碼 addScriptListener(list[i]); } } } }; |
這裡對當前頁面中的script標籤做了檢查,因為使用define方法的地方可能是內嵌指令碼,也可能是外部指令碼。針對內嵌指令碼做特殊處理的原因主要是內嵌指令碼是不能作為一個模組被依賴的,它只能是整個依賴鏈的入口。而外嵌指令碼是可以在弱依賴這個環裡的。
上面的程式碼裡用到了兩個未實現的方法:runLoading和addScriptListener。其中runLoading用來檢查模組的依賴並對依賴進行載入。addScriptListener則對已經載入完的指令碼新增監聽器,目的是為了在指令碼載入完後對指令碼進行標記,同時繼續檢查快取中待載入的模組和等待中的模組。
addScriptListener方法實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
/* * 偵測指令碼載入情況 */ var addScriptListener = (function() { // 指令碼載入完成回撥 var onScriptLoad = function(script) { var url = formatURL(script.src); if(!url) return; // 檢查棧中快取 var arr = STACK.pop(); if(arr) { arr.unshift(url); runLoading.apply(window, arr); } // 當前模組不處於等待中的話,則標記為已定義 if(STATUS[url] !== WAITING) STATUS[url] = DEFINED; // 清理指令碼節點 if(script && script.parentNode) { // 清除事件 script.onload = script.onerror = null; // 清除script標籤 script.parentNode.removeChild(script); } // 載入完後檢查等待中的模組 runWaiting(); }; return function(script) { // 載入成功 或 失敗 script.onload = script.onerror = function(e) { onScriptLoad(e.target || e.srcElement || this); }; }; })(); |
上面的程式碼中的runWaiting方法就是用來檢查等待中模組。
STEP3
實現runLoading方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
/** * 處理模組進入等待佇列 */ var runLoading = function(url, deps, callback) { // 如果自身是內嵌指令碼的話,則使用時間戳作為url if(typeof url !== 'string') { callback = deps; deps = url; url = './' + (seed++) + '.js' } url = formatURL(url); if(STATUS[url] === DEFINED) return; // 已定義 // 載入依賴模組 for(var i=0,l=deps.length; i<l; i++) { deps[i] = formatURL((deps[i] || ''), url); // 格式化依賴列表中的url loadResource(deps[i]); // 載入資源 } STATUS[url] = WAITING; // 存在依賴,當前模組標記為等待中 // 放進模組佇列中 MODULES.push({ url: url, deps: deps, callback: callback }); // 檢查等待中的模組 runWaiting(); }; |
runLoading裡的邏輯很簡單,就是對依賴進行載入,然後將將自身置為等待中的模組。而runWaiting的程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
/* * 對等待中的模組進行定義 */ var runWaiting = (function() { // 檢查所有檔案是否都載入 var isFinishLoaded = function() { for(var url in STATUS) { if(STATUS[url] === LOADING) return false; } return true; }; // 檢查依賴列表是否都載入完成 var isListLoaded = function(deps) { for(var i=deps.length-1; i>=0; i--) { if(STATUS[deps[i]] !== DEFINED) return false; } return true; }; return function() { if(!MODULES.length) return; for(var i=MODULES.length-1; i >= 0; ) { var item = MODULES[i]; if(STATUS[item.url] !== DEFINED) { if(!isListLoaded(item.deps)) { // 存在未定義的檔案,且依賴列表中也存在未定義的檔案,則跳過 i--; continue; } else { // 依賴列表中的檔案都已定義,則進行定義自己 runDefining(item); } } // 刪除已經定義的檔案,然後重新遍歷 MODULES.splice(i, 1); i = MODULES.length - 1; } if(MODULES.length>0 && isFinishLoaded()) { // 存在迴圈引用,可以嘗試強行定義,不過只能解決弱依賴引用,無法解決強依賴引用 var item = MODULES.pop(); runDefining(item); runWaiting(); } }; })(); |
這裡遍歷一遍等待中的模組,針對依賴都已經就位(載入完並且定義完)的情況下,就開始執行自身的定義。對於迴圈依賴的問題,就用上面提到的方法,強行打破。其中runDefining就是執行定義的方法,其程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
/** * 執行模組定義 */ var runDefining = function(item) { var args = []; // 遍歷依賴列表 for(var i=0,len=item.deps.length; i<len; i++) { var it = item.deps[i]; RESULTS[it] = RESULTS[it] || {}; args.push(RESULTS[it]); } if(item.callback) { // 注入依賴並執行 var result = item.callback.apply(window, args) || {}; // 合併依賴注入結果 var ret = RESULTS[item.url] || {}; if(typeof result === 'object') { for(var key in result) ret[key] = result[key]; } else { ret = result; } // 將定義好的檔案放入快取 RESULTS[item.url] = ret; } STATUS[item.url] = DEFINED; }; |
執行定義的過程就是把依賴定義完的結果注入到模組的回撥中,然後執行模組的回撥,把返回的結果快取起來,以供依賴當前模組的模組使用。
整個流程很簡單,當載入完一個模組並發現這個模組存在依賴的情況下,就先讓當前模組處於等待狀態,優先載入依賴。等所有依賴都定義完了,再去執行這個模組的定義。對於依賴的處理也同樣。
STEP4
到這裡,只剩下最後一部分了——就是載入相關的邏輯:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
/* * 解析檔案型別,並進行載入 */ var loadResource = (function() { // 載入依賴文字 var loadText = function(url, callback) { if(!url) return; // 未載入過 if(STATUS[url] != null) return; // 載入文字 STATUS[url] = LOADING; // 標記為載入中 var xhr = new window.XMLHttpRequest(); xhr.onreadystatechange = function() { if(xhr.readyState == 4) { var text = xhr.responseText || ''; STATUS[url] = DEFINED; // 標記為已定義 RESULTS[url] = text; // 儲存結果 if(callback) callback(text); // 針對json的處理 // 載入完後檢查等待中的模組 runWaiting(); } }; xhr.open('GET', url, true); xhr.send(null); }; // 載入依賴JSON var loadJSON = function(url) { loadText(url, function(text) { // 解析JSON RESULTS[url] = JSON.parse(text); }); }; // 載入依賴指令碼 var loadScript = function(url) { if(STATUS[url]) return; // 已載入則返回 STATUS[url] = LOADING; // 標記當前模組為載入中 // 使用script標籤新增到文件中,載入執行完再刪除 var script = document.createElement('script'); script.nowhasload = true; script.type = 'text/javascript'; script.charset = 'utf-8'; addScriptListener(script); // 監聽指令碼載入執行 script.src = url; (document.getElementsByTagName('head')[0] || document.body).appendChild(script); }; return function(url) { var arr = url.split('.'); var type = arr.pop(); if(type === 'js') loadScript(url); else if(type === 'json') loadJSON(url); else loadText(url); }; })(); |
這段程式碼沒什麼好說的,就跟上面提到的一樣,針對js使用script標籤,針對其他文字則走ajax請求。
收工
其實把上面貼出來的程式碼拼起來,就是一個完整、可用的簡易版模組載入工具了。就如同開始所說的,這是拿來學習用的輪子,如果想拿來直接用其實也沒什麼問題,不過有些相容性的問題或者功能的擴充就得自己完善(比如低版本ie,比如支援配置根路徑等)。
想看完整的程式碼的話,請戳這裡。