前沿 寫在文章的最前面 前沿 寫在文章的最前面 這篇文章講的是,我怎麼去寫一個 requirejs 。 去 github 上fork一下,順便star~ requirejs,眾所周知,是一個非常出名的js模塊化工具,可以讓你使用模塊化的方式組織代碼,並非同步載入你所需要的部分。balabala 等等好 ...
前沿 寫在文章的最前面
這篇文章講的是,我怎麼去寫一個 requirejs 。
requirejs,眾所周知,是一個非常出名的js模塊化工具,可以讓你使用模塊化的方式組織代碼,並非同步載入你所需要的部分。balabala 等等好處不計其數。
之所以寫這篇文章,是做一個總結。目前打算動一動,換一份工作。感謝 一線碼農 大大幫忙推了攜程,得到了面試的機會。
面試的時候,聊著聊著感覺問題都問在了自己的“點”上,應答都挺順利,於是就慢慢膨脹了。在說到模塊化的時候,我腦子一抽,憑著感覺說了一下requirejs實現的大概步驟,充滿了表現欲望,廢話一堆。僥幸不可能當場讓我寫一遍,算是過了,事後嘗試了一下,在這裡跟大家分享一下我的實現。
結構劃分
上面是我劃分的項目結構:
- tool,
工具模塊
,存放便捷方法,很多地方需要用到。 - async,非同步處理模塊,主要實現了
promise
和deferred
。邏輯上的非同步。 - requirejs ->
loader
,amd載入器,處理模塊的依賴和非同步載入。物理上的非同步。
因為對於非同步流程式控制制方面,研究過一段時間,所以這裡第一時間想到的就是 promise ,如果用這個來做,所有的模塊放入字典,路徑做key,promise做value,所有依賴都結束之後,才進行下一步操作。 不用管複雜的依賴關係,把邏輯儘量簡單化:
- 首先有一個字典,存放所有的模塊。key放地址,value放promise,promise在模塊載入完畢的時候resolve。
- 如果依賴某個模塊,先根據路徑從字典找key,存在就用該promise,不存在就去載入該模塊並放入字典,並使用該模塊的promise。
- 所有的模塊,我只用它的 promise ,在它的回調中寫我的後續操作。它的resolve應該單獨抽離出來,屬於非同步載入方面。
大致思路有了,當然實際寫的時候肯定困難重重,不過沒關係,遇到問題再去解決。
考慮到代碼的簡易性,以及我的個人習慣。我打算用類似於 jquery 的 $.Deferred() 和它的promise
,與es6的promise有一定的出入。這樣代碼書寫更簡易,並且邏輯上更清晰,es6的promise用起來確實稍顯麻煩。我需要的是一個 pub/sub
模式,一個地方觸發,多個回調執行的並行方式,es6的promise,需要在then中一次次返回,並且resolve起來也不方便,最最主要的是需要 polyfill 一下,而我想自己寫,寫我熟悉且喜歡的代碼 。
callbacks模塊
回調模塊 callbacks
,熟悉jquery的朋友接下來可能會覺得使用方式很熟悉,沒錯,我受jq的影響算是比較深的。以前在學習jq源碼的時候,就覺得這個很好用,你可以從我的代碼裡面看到jq的影子 :
1 import _ from '../tool/tool'; 2 3 /** 4 * 基礎回調模塊 5 * 6 * @export 7 * @returns callbacks 8 */ 9 export default function () { 10 let list = [], 11 _args = (arguments[0] || '').split(' '), // 參數數組 12 fireState = 0, // 觸髮狀態 0-未觸發過 1-觸發中 2-觸發完畢 13 stopOnFalse = ~_args.indexOf('stopOnFalse'), // stopOnFalse - 如果返回false就停止 14 once = ~_args.indexOf('once'), // once - 只執行一次,即執行完畢就清空 15 memory = ~_args.indexOf('memory') ? [] : null, // memory - 保持狀態 16 fireArgs = []; // fire 參數 17 18 /** 19 * 添加回調函數 20 * 21 * @param {any} cb 22 * @returns callbacks 23 */ 24 function add(cb) { 25 if (memory && fireState == 2) { // 如果是memory模式,並且已經觸發過 26 cb.apply(null, fireArgs); 27 } 28 29 if (disabled()) return this; // 如果被disabled 30 31 list.push(cb); 32 return this; 33 } 34 35 /** 36 * 觸發 37 * 38 * @param {any} 任意參數 39 * @returns callbacks 40 */ 41 function fire() { 42 if (disabled()) return this; // 如果被禁用 43 44 fireArgs = _.makeArray(arguments); // 保存 fire 參數 45 46 fireState = 1; // 觸發中 47 48 _.each(list, (index, cb) => { // 依次觸發回調 49 if (cb.apply(null, fireArgs) === false && stopOnFalse) { // stopOnFalse 模式下,遇到false會停止觸發 50 return false; 51 } 52 }); 53 54 fireState = 2; // 觸髮結束 55 56 if (once) disable(); // 一次性列表 57 58 return this; 59 } 60 61 function disable() { // 禁止 62 list = undefined; 63 return this; 64 } 65 66 function disabled() { // 獲取是否被禁止 67 return !list; 68 } 69 70 return { 71 add: add, 72 fire: fire, 73 disable: disable, 74 disabled: disabled 75 }; 76 }View Code
這是一個工廠方法,每次所需的對象由該方法生成,用閉包來隱藏局部變數,私有方法。而最後暴露(發佈)出來的對象,用 pub/sub 模式,提供了 訂閱
, 觸發
,禁用
,查看禁用
4個方法。 這裡要說的是 ,提供了3個參數:stopOnFalse
、once
、memory
。觸發的時候,按照訂閱順序依次觸發,如果是 stopOnFalse
模式,當某個訂閱的函數,返回是 false 的時候,停止整個觸發過程。 如果是 once
,表示每個函數只能執行一次,在執行過後,會被移除隊列。而 memory
狀態下,在 callback 觸發後,會被保持狀態,之後添加的方法,添加後會直接執行。
這三種模式,傳參的時候直接傳入字元串,可以隨意組合,用空格分開,比如:callbacks('once memory')
該模塊用於整個項目中,處理所有的回調。使用方式類似於jquery的:$.Callbacks(...)
deferred 模塊
deferred ,是對promise的父級模塊,主要提供了 觸發 和 訂閱 2個方法。 promise 是對 deferred 的一個再封裝,僅僅暴露出其中的 訂閱 方法。
從概念上來說,很像 C# 中的委托和事件。
1 import _ from '../tool/tool'; 2 import callbacks from './callbacks'; 3 4 /** 5 * deferred 模塊 6 * 7 * @export 8 * @returns deferred 9 */ 10 export default function () { 11 let tuples = [ // 用於存放一系列回調的 tuple 結構 12 // 方法名 - 介面名稱 - 回調列表 - 最終狀態 13 ['resolve', 'then', callbacks('once memory'), 'resolved'], 14 ['reject', 'catch', callbacks('once memory'), 'rejected'] 15 ]; 16 17 let _state = 'pending'; // 當前狀態 18 19 let dfd = { // 返回的延遲對象 20 state: function () { 21 return _state; 22 }, // 狀態 23 promise: function () { // promise - 僅提供介面用於註冊/訂閱 24 let self = this; 25 let pro = { 26 state: self.state 27 }; 28 _.each(tuples, (i, tuple) => { // 訂閱介面 29 pro[tuple[1]] = self[tuple[1]]; 30 }); 31 return pro; 32 } 33 }; 34 35 _.each(tuples, (i, tuple) => { 36 dfd[tuple[0]] = function () { // 觸發 37 if (_state != "pending") return this; 38 tuple[2].fire.apply(tuple[2], _.makeArray(arguments)); 39 _state = tuple[3]; 40 return this; 41 }; 42 dfd[tuple[1]] = function (cb) { // 綁定 43 tuple[2].add(cb); 44 return this; 45 }; 46 }); 47 48 return dfd; 49 }View Code
deferred
使用了 callbacks
模塊來處理其中所有的回調函數。是一個工廠方法,deferred()
返回的是一個deferred對象(發佈),包含了3種狀態:pending
,resolved
,rejected
;提供了 then
和 catch
去訂閱;通過 resolve
和 reject
去 改變(觸發) 狀態。
deferred 對象,提供了一個 promise() 方法去返回一個promise對象,區別就是promise對象屏蔽了觸發的方法。就像委托和事件,前者可以訂閱和觸發,而後者只能訂閱。之所以如此,是想只提供訂閱的介面,而如何觸發,何時觸發,由我自己控制,是我邏輯內部的事情,而其他部分,只需要知道也只能去訂閱。
Tuple ,是一種約定的、按照某個規則進行存儲的數據結構(類?), c# ,typescript 中都有這個東西,之前在學習jq的時候,看到了它的內部也這麼用,於是學到了。其實在我看來,使用tuple,就是節約代碼,笑。不必要去定義某個類,或者其他的東西,只需要在定義和使用的時候,遵循某個約定好的規則,那麼就可以省去一大堆的代碼,讓邏輯部分也清晰不少。
all 模塊
1 import deferred from './deferred'; 2 import _ from '../tool/tool'; 3 4 export default function (promises) { 5 promises = _.makeArray(promises); 6 let len = promises.length, // promise 個數 7 resNum = 0, // resolve 的數量 8 argsArr = new Array(len), // 每個reject的參數 9 dfd = deferred(), // 用於當前task控制的deferred 10 pro = dfd.promise(); // 用於當前返回的promise 11 12 if (len === 0) { // 如果是個空數組,直接就返回了 13 dfd.resolve(); 14 return pro; 15 } 16 17 function addThen() { // 檢測是否全部完成 18 resNum++; 19 let args = _.makeArray(arguments); 20 let index = args.shift(); // 當前參數在promises中的索引 21 22 if (args.length <= 1) { // 保存到數組,用戶回調 23 argsArr[index] = args[0]; 24 } else { 25 argsArr[index] = args; 26 } 27 28 if (resNum >= len) { // 如果所有promise都resolve完畢 29 dfd.resolve(argsArr); 30 } 31 } 32 33 function addCatch() { // 如果某個promise發生了reject 34 var args = _.makeArray(arguments); 35 dfd.reject(...args); 36 } 37 38 _.each(promises, (index, promise) => { 39 promise.then(function () { 40 addThen(index, ...arguments); 41 }).catch(addCatch); 42 }); 43 44 return pro; 45 }View Code
all,其實就是es6中, Promise.all
或者 $.when
的一種實現。參數是一系列的promise,本身返回一個promise對象,在所有參數中的promise對象都處於 resolved狀態
時,本身也會被resolve掉,由此來執行通過then訂閱的方法。
all本身,是通過一個觸發器來實現在最後一個promise完成時回調。內部用一個int值來存儲resolved的參數的個數,給每個參數通過 then 添加一個回調來執行這個觸發器,當 完成數量 >= 參數個數
的時候,就表示所有promise已經完成,可以進行後續的操作。 用 >= 來代替 == 是個好習慣 :D
模塊分析 模塊定義、模塊獲取
到此為止,async 部分已經完成,準備工作已經做好。我們開始 amd 模塊部分的分析。
amd 模塊在我看來,主要分為兩個部分:模塊定義
、模塊獲取
。先說模塊獲取:
模塊獲取
模塊的獲取,並不複雜。先從字典中根據路徑(key)去找該模塊,如果有該模塊,就去載入。如果不存在,就去載入該js,根據onload來確定該模塊的名稱(如果是匿名模塊);然後根據該模塊的返回值==》 一個promise,給該promise添加一個回調,去管理 getModule 的返回值狀態==》另一個promise。在使用一個模塊的時候,從本質上來講,是給該模塊的promise的then介面添加回調函數,一層層往下處理。
模塊定義
這裡的重點是 載入模塊,大家都知道,amd的每個模塊,對應一個js文件,載入模塊就是去載入這個js。
再看看模塊的定義,有 3種重載:
- define(sender)
- define(deps,sender)
- define(name,deps,sender)
sender 是一個函數,或者某個對象。deps 是一個數組,表示該模塊依賴的其他模塊。name 是表示當前模塊是一個命名模塊,強制使用該名稱,一般是打包工具生成這種模塊,不建議自己直接這麼寫。
從上面我們可以看到,模塊是通過執行一個函數,用傳參的方式把所要用到的模塊載入到某個地方保存起來。那麼看到這個你們有沒有想到什麼呢?我首先想到的就是 jsonp ,動態執行一個函數,把數據放進去,對得上,完美。從這個思路,我實驗了一下,在這裡直接說結論: script標簽在動態載入到頁面後,首先去伺服器拿對應地址的數據,然後在文件下載完全後,執行該js文件中的內容,執行完畢後,會觸發該script標簽的load事件。
也就是說,通過給load事件註冊方法,我們可以知道最後一個載入的模塊(js文件),來自哪裡,什麼時候執行完全。這樣就確定了,並行載入多個js文件時,匿名模塊所屬來源。這裡不討論相容的問題,低版本ie對應的是其他事件:onreadystatechange,我沒用過。
在模塊載入後,我們用一個函數來將模塊填充到字典中,類似於一個 觸發器
,每次載入一個模塊,模塊中包含這個函數並執行,處理依賴關係,並將最後的結果保存。
在模塊的載入中,因為可能會同時載入多個模塊(js文件),並不能確定到底是哪一個先載入完全。但是我們知道,js是單線程,在js文件下載完全後,會先把js文件中的內容執行完畢,然後再觸發load事件,這個順序是可以保證的,所以就可以使用一個變數來保存最近載入的模塊,來知道匿名模塊的所屬路徑。
不論是匿名模塊,還是命名模塊,都可能依賴其他的模塊,所以並不能確定在模塊載入完之後,就可以立即使用,要等待所有的依賴項都載入完畢,所以一個模塊的最終返回值我使用的一個promise來保存。這樣就可以方便的在狀態變更後才添加下一步的處理操作,從邏輯上簡化整個流程式控制制。
模塊入口 require
1 /** 2 * 程式入口, require 3 * 4 * @export 5 * @param {any} deps 依賴項 6 * @param {any} callback 程式入口 7 */ 8 export function requireModule(deps, callback) { 9 setTimeout(function () { // 避免阻塞同文件中,使用名稱定義的模塊 10 deps = deps.map(url => getModule(_.resolvePath(core.rootUrl, url))); 11 all(deps).then(function (args) { 12 callback(...args); 13 }); 14 }, 0); 15 }View Code
這裡的代碼比較簡單,唯一要註意的就是這個 setTimeout(action,0)
。因為js是單線程,從上往下依次執行。模塊可能會被打包工具合併成一個文件,那麼在一個文件中就含有了模塊入口、命名模塊。如果模塊入口在最上方,,,在依賴某個命名模塊的時候,就會試圖去載入這個名稱的js文件,而這註定是會失敗的。所以使用一個setTimeout,把模塊入口的邏輯,放入事件隊列中,讓js邏輯線程優先去執行文件後面的代碼,就避免了這個問題。
loader 模塊代碼
1 import core from './core'; 2 import deferred from './async/deferred'; 3 import all from './async/all'; 4 import _ from './tool/tool'; 5 6 let lastNameDfd = null; // 最後一個載入的module的name的 deferred 7 8 9 /** 10 * 程式入口, require 11 * 12 * @export 13 * @param {any} deps 依賴項 14 * @param {any} callback 程式入口 15 */ 16 export function requireModule(deps, callback) { 17 setTimeout(function () { // 避免阻塞同文件中,使用名稱定義的模塊 18 deps = deps.map(url => getModule(_.resolvePath(core.rootUrl, url))); 19 all(deps).then(function (args) { 20 callback(...args); 21 }); 22 }, 0); 23 } 24 25 /** 26 * 模塊定義,url,deps,sender 27 * 28 * @export 29 */ 30 export function defineModule() { 31 let args = _.makeArray(arguments); 32 let name = "", // 模塊名稱 33 proArr, // 模塊依賴 34 sender; // 模塊的主體 35 36 let argsLen = args.length; // 參數的個數,用來重載 37 38 if (argsLen == 1) { // 重載一下 sender 39 proArr = []; 40 sender = args[0]; 41 } 42 else if (argsLen == 2) { // deps,sender 43 proArr = args[0]; 44 sender = args[1]; 45 } 46 else if (argsLen == 3) { // name,deps,sender 47 name = args[0]; 48 proArr = args[1]; 49 sender = args[2]; 50 } 51 else { 52 throw Error('參數個數異常'); 53 } 54 55 let dfdThen = (_name, lastModule) => { 56 _name = _.normalizePath(_name); // 名稱,路徑 57 58 proArr = proArr.map(url => { // 各個依賴項 59 url = _.resolvePath(_name, url); // 以當前路徑為基準,合併路徑 60 return getModule(url); 61 }); 62 63 all(proArr).then(function (_args) { // 在依賴項載入完畢後,進行模塊處理 64 _args = _args || []; 65 let result; // 最終結果 66 let _type = _.type(sender); // 回調模塊類型 67 68 if (_type == "function") { 69 result = sender(..._args); 70 } 71 else if (_type == "object") { 72 result = sender; 73 } 74 else { 75 throw Error("參數類型錯誤"); 76 } 77 78 lastModule.resolve(result); 79 80 }); 81 }; 82 83 if (argsLen < 3) { // 如果是匿名模塊,使用 onload 來判斷js的名稱/路徑 84 lastNameDfd = deferred(); // 先獲取當前模塊名稱 85 86 lastNameDfd.then(dfdThen); 87 } 88 else { // 如果是自定義模塊名,直接觸發,命名模塊直接添加 89 let lastModule = deferred(); 90 let dictName = _.resolvePath(core.rootUrl, name); 91 core.dict[dictName] = lastModule; 92 93 let namedDfd = deferred().then(dfdThen); 94 95 setTimeout(function () { // 避免同文件中,多個命名模塊註冊阻塞,先把名字註冊了,具體內容等待一下 event loop 96 namedDfd.resolve(dictName, lastModule); 97 }, 0); 98 } 99 100 } 101 102 /** 103 * 根據 路徑/名稱 ,載入/獲取模塊的promise 104 * 105 * @param {any} name 106 * @returns promise 107 */ 108 function getModule(name) { 109 let dict = core.dict; 110 if (dict[name]) { 111 return dict[name]; 112 } 113 114 let script = addScript(name); 115 116 let dfd = deferred(); 117 dict[name] = dfd; 118 119 script.onload = function () { // 模塊載入完畢,立馬會觸發 load 事件,由此來確定模塊所屬 120 let lastModule = deferred(); 121 lastNameDfd.resolve(name, lastModule); // 綁定當前模塊的名稱 122 123 lastModule.then(result => { // 在模塊載入完畢之後,觸發該模塊的 resolve 124 dfd.resolve(result); 125 }); 126 }; 127 128 return dfd.promise(); 129 } 130 131 /** 132 * 添加 script 標簽 133 * 134 * @export 135 * @param {any} name 136 * @returns 137 */ 138 export function addScript(name) { 139 let script = document.createElement('script'); 140 script.type = "text/javascript"; 141 script.async = true; 142 script.charset = "utf-8"; 143 script.src = name + ".js"; 144 document.head.appendChild(script); 145 return script; 146 }View Code
core 模塊
1 /** 2 * 預設核心載體 3 */ 4 export default { 5 /** 6 * 版本 7 */ 8 ver: "0.0.1", 9 /** 10 * 模塊定義名稱 11 */ 12 defineName: "define", 13 /** 14 * 程式入口函數 15 */ 16 requireName: "require", 17 /** 18 * 暴露的全局名稱,可用於配置 19 */ 20 coreName: "requirejs", 21 /** 22 * 根目錄,入口文件目錄 23 */ 24 rootUrl: "", 25 /** 26 * 依賴模塊存儲字典 27 */ 28 dict: { // 模塊字典 {key:string,value:promise} 29 30 } 31 };View Code
core,主要存的是一些配置信息,和模塊的字典,比較簡單。
總結、Github
寫到這裡,就已經結束了。本文講了對於requirejs,我的實現思路,列舉了可能遇到的問題,及我的解決方式。希望能給大家的學習提供點幫助。
上面是github的地址,求star啊,作為一個虛榮的人,我對這個很看重的,哈哈,也就這點追求了。再次感激 一線碼農 大哥的推薦,還有 linkFly 的經驗指導。