本文不是一篇 JavaScript 模塊化或者 RequireJS 的教程,只是從 RequireJS 的源碼來剖析了動態載入腳本和處理非同步的思想,如果你想閱讀一篇有關 RequireJS API 的文章,那麼這並不適合你,如果你對 RequireJS 載入腳本和處理回調的原理感興趣,那麼本篇文章一... ...
引言
俗話說的好,不喜歡研究原理的程式員不是好的程式員,不喜歡讀源碼的程式員不是好的 jser。這兩天看到了有關前端模塊化的問題,才發現 JavaScript 社區為了前端工程化真是煞費苦心。今天研究了一天前端模塊化的問題,先是大概瞭解了下模塊化的標準規範,然後瞭解了一下 RequireJs 的語法和使用方法,最後研究了下 RequireJs 的設計模式和源碼,所以想記錄一下相關的心得,剖析一下模塊載入的原理。
一、認識 RequireJs
在開始之前,我們需要瞭解前端模塊化,本文不討論有關前端模塊化的問題,有關這方面的問題可以參考阮一峰的系列文章 Javascript 模塊化編程。
使用 RequireJs 的第一步:前往官網 http://requirejs.org/;
第二步:下載文件;
第三步:在頁面中引入 requirejs.js 並設置 main 函數;
1 <script type="text/javascript" src="scripts/require.js" data-main="scripts/main.js"></script>
然後我們就可以在 main.js 文件里編程了,requirejs 採用了 main 函數式的思想,一個文件即為一個模塊,模塊與模塊之間可以依賴,也可以毫無干系。使用 requirejs ,我們在編程時就不必將所有模塊都引入頁面,而是需要一個模塊,引入一個模塊,就相當於 Java 當中的 import 一樣。
定義模塊:
1 //直接定義一個對象 2 define({ 3 color: "black", 4 size: "unisize" 5 }); 6 //通過函數返回一個對象,即可以實現 IIFE 7 define(function () { 8 //Do setup work here 9 10 return { 11 color: "black", 12 size: "unisize" 13 } 14 }); 15 //定義有依賴項的模塊 16 define(["./cart", "./inventory"], function(cart, inventory) { 17 //return an object to define the "my/shirt" module. 18 return { 19 color: "blue", 20 size: "large", 21 addToCart: function() { 22 inventory.decrement(this); 23 cart.add(this); 24 } 25 } 26 } 27 );
導入模塊:
1 //導入一個模塊 2 require(['foo'], function(foo) { 3 //do something 4 }); 5 //導入多個模塊 6 require(['foo', 'bar'], function(foo, bar) { 7 //do something 8 });
關於 requirejs 的使用,可以查看官網 API ,也可以參考 RequireJS 和 AMD 規範 ,本文暫不對 requirejs 的使用進行講解。
二、main 函數入口
requirejs 的核心思想之一就是使用一個規定的函數入口,就像 C++ 的 int main(),Java 的 public static void main(),requirejs 的使用方式是把 main 函數緩存在 script 標簽上。也就是將腳本文件的 url 緩存在 script 標簽上。
1 <script type="text/javascript" src="scripts/require.js" data-main="scripts/main.js"></script>
初來乍到電腦同學一看,哇!script 標簽難道還有什麼不為人知的屬性嗎?嚇得我趕緊打開了 W3C 查看相關 API,併為自己的 HTML 基礎知識感到慚愧,可是遺憾的是 script 標簽並沒有相關的屬性,甚至這都不是一個標準的屬性,那麼它到底是什麼玩意呢?下麵直接上一部分 requirejs 源碼:
1 //Look for a data-main attribute to set main script for the page 2 //to load. If it is there, the path to data main becomes the 3 //baseUrl, if it is not already set. 4 dataMain = script.getAttribute('data-main');
實際上在 requirejs 中只是獲取在 script 標簽上緩存的數據,然後取出數據載入而已,也就是跟動態載入腳本是一樣的,具體是怎麼操作,在下麵的講解中會放出源碼。
三、動態載入腳本
這一部分是整個 requirejs 的核心,我們知道在 Node.js 中載入模塊的方式是同步的,這是因為在伺服器端所有文件都存儲在本地的硬碟上,傳輸速率快而且穩定。而換做了瀏覽器端,就不能這麼幹了,因為瀏覽器載入腳本會與伺服器進行通信,這是一個未知的請求,如果使用同步的方式載入,就可能會一直阻塞下去。為了防止瀏覽器的阻塞,我們要使用非同步的方式載入腳本。因為是非同步載入,所以與模塊相依賴的操作就必須得在腳本載入完成後執行,這裡就得使用回調函數的形式。
我們知道,如果顯示的在 HTML 中定義腳本文件,那麼腳本的執行順序是同步的,比如:
1 //module1.js 2 console.log("module1");
1 //module2.js 2 console.log("module2");
1 //module3.js 2 console.log("module3");
1 <script type="text/javascript" src="scripts/module/module1.js"></script> 2 <script type="text/javascript" src="scripts/module/module2.js"></script> 3 <script type="text/javascript" src="scripts/module/module3.js"></script>
那麼在瀏覽器端總是會輸出:
但是如果是動態載入腳本的話,腳本的執行順序是非同步的,而且不光是非同步的,還是無序的:
1 //main.js 2 console.log("main start"); 3 4 var script1 = document.createElement("script"); 5 script1.src = "scripts/module/module1.js"; 6 document.head.appendChild(script1); 7 8 var script2 = document.createElement("script"); 9 script2.src = "scripts/module/module2.js"; 10 document.head.appendChild(script2); 11 12 var script3 = document.createElement("script"); 13 script3.src = "scripts/module/module3.js"; 14 document.head.appendChild(script3); 15 16 console.log("main end");
使用這種方式載入腳本會造成腳本的無序載入,瀏覽器按照先來先運行的方法執行腳本,如果 module1.js 文件比較大,那麼極其有可能會在 module2.js 和 module3.js 後執行,所以說這也是不可控的。要知道一個程式當中最大的 BUG 就是一個不可控的 BUG ,有時候它可能按順序執行,有時候它可能亂序,這一定不是我們想要的。
註意這裡的還有一個重點是,"module" 的輸出永遠會在 "main end" 之後。這正是動態載入腳本非同步性的特征,因為當前的腳本是一個 task ,而無論其他腳本的載入速度有多快,它都會在 Event Queue 的後面等待調度執行。這裡涉及到一個關鍵的知識 — Event Loop ,如果你還對 JavaScript Event Loop 不瞭解,那麼請先閱讀這篇文章 深入理解 JavaScript 事件迴圈(一)— Event Loop。
四、導入模塊原理
在上一小節,我們瞭解到,使用動態載入腳本的方式會使腳本無序執行,這一定是軟體開發的噩夢,想象一下你的模塊之間存在上下依賴的關係,而這時候他們的載入順序是不可控的。動態載入同時也具有非同步性,所以在 main.js 腳本文件中根本無法訪問到模塊文件中的任何變數。那麼 requirejs 是如何解決這個問題的呢?我們知道在 requirejs 中,任何文件都是一個模塊,一個模塊也就是一個文件,包括主模塊 main.js,下麵我們看一段 requirejs 的源碼:
1 /** 2 * Creates the node for the load command. Only used in browser envs. 3 */ 4 req.createNode = function (config, moduleName, url) { 5 var node = config.xhtml ? 6 document.createElementNS('http://www.w3.org/1999/xhtml', 'html:script') : 7 document.createElement('script'); 8 node.type = config.scriptType || 'text/javascript'; 9 node.charset = 'utf-8'; 10 node.async = true; 11 return node; 12 };
在這段代碼中我們可以看出, requirejs 導入模塊的方式實際就是創建腳本標簽,一切的模塊都需要經過這個方法創建。那麼 requirejs 又是如何處理非同步載入的呢?傳說江湖上最高深的醫術不是什麼靈丹妙藥,而是以毒攻毒,requirejs 也深得其精髓,既然動態載入是非同步的,那麼我也用非同步來對付你,使用 onload 事件來處理回調函數:
1 //In the browser so use a script tag 2 node = req.createNode(config, moduleName, url); 3 4 node.setAttribute('data-requirecontext', context.contextName); 5 node.setAttribute('data-requiremodule', moduleName); 6 7 //Set up load listener. Test attachEvent first because IE9 has 8 //a subtle issue in its addEventListener and script onload firings 9 //that do not match the behavior of all other browsers with 10 //addEventListener support, which fire the onload event for a 11 //script right after the script execution. See: 12 //https://connect.microsoft.com/IE/feedback/details/648057/script-onload-event-is-not-fired-immediately-after-script-execution 13 //UNFORTUNATELY Opera implements attachEvent but does not follow the script 14 //script execution mode. 15 if (node.attachEvent && 16 //Check if node.attachEvent is artificially added by custom script or 17 //natively supported by browser 18 //read https://github.com/requirejs/requirejs/issues/187 19 //if we can NOT find [native code] then it must NOT natively supported. 20 //in IE8, node.attachEvent does not have toString() 21 //Note the test for "[native code" with no closing brace, see: 22 //https://github.com/requirejs/requirejs/issues/273 23 !(node.attachEvent.toString && node.attachEvent.toString().indexOf('[native code') < 0) && 24 !isOpera) { 25 //Probably IE. IE (at least 6-8) do not fire 26 //script onload right after executing the script, so 27 //we cannot tie the anonymous define call to a name. 28 //However, IE reports the script as being in 'interactive' 29 //readyState at the time of the define call. 30 useInteractive = true; 31 32 node.attachEvent('onreadystatechange', context.onScriptLoad); 33 //It would be great to add an error handler here to catch 34 //404s in IE9+. However, onreadystatechange will fire before 35 //the error handler, so that does not help. If addEventListener 36 //is used, then IE will fire error before load, but we cannot 37 //use that pathway given the connect.microsoft.com issue 38 //mentioned above about not doing the 'script execute, 39 //then fire the script load event listener before execute 40 //next script' that other browsers do. 41 //Best hope: IE10 fixes the issues, 42 //and then destroys all installs of IE 6-9. 43 //node.attachEvent('onerror', context.onScriptError); 44 } else { 45 node.addEventListener('load', context.onScriptLoad, false); 46 node.addEventListener('error', context.onScriptError, false); 47 } 48 node.src = url;
註意在這段源碼當中的監聽事件,既然動態載入腳本是非同步的的,那麼乾脆使用 onload 事件來處理回調函數,這樣就保證了在我們的程式執行前依賴的模塊一定會提前載入完成。因為在事件隊列里, onload 事件是在腳本載入完成之後觸發的,也就是在事件隊列裡面永遠處在依賴模塊的後面,例如我們執行:
1 require(["module"], function (module) { 2 //do something 3 });
那麼在事件隊列裡面的相對順序會是這樣:
相信細心的同學可能會註意到了,在源碼當中不光光有 onload 事件,同時還添加了一個 onerror 事件,我們在使用 requirejs 的時候也可以定義一個模塊載入失敗的處理函數,這個函數在底層也就對應了 onerror 事件。同理,其和 onload 事件一樣是一個非同步的事件,同時也永遠發生在模塊載入之後。
談到這裡 requirejs 的核心模塊思想也就一目瞭然了,不過其中的過程還遠不直這些,博主只是將模塊載入的實現思想拋了出來,但 requirejs 的具體實現還要複雜的多,比如我們定義模塊的時候可以導入依賴模塊,導入模塊的時候還可以導入多個依賴,具體的實現方法我就沒有深究過了, requirejs 雖然不大,但是源碼也是有兩千多行的... ...但是只要理解了動態載入腳本的原理過後,其思想也就不難理解了,比如我現在就可以想到一個簡單的實現多個模塊依賴的方法,使用計數的方式檢查模塊是否載入完全:
1 function myRequire(deps, callback){ 2 //記錄模塊載入數量 3 var ready = 0; 4 //創建腳本標簽 5 function load (url) { 6 var script = document.createElement("script"); 7 script.type = 'text/javascript'; 8 script.async = true; 9 script.src = url; 10 return script; 11 } 12 var nodes = []; 13 for (var i = deps.length - 1; i >= 0; i--) { 14 nodes.push(load(deps[i])); 15 } 16 //載入腳本 17 for (var i = nodes.length - 1; i >= 0; i--) { 18 nodes[i].addEventListener("load", function(event){ 19 ready++; 20 //如果所有依賴腳本載入完成,則執行回調函數; 21 if(ready === nodes.length){ 22 callback() 23 } 24 }, false); 25 document.head.appendChild(nodes[i]); 26 } 27 }
實驗一下是否能夠工作:
1 myRequire(["module/module1.js", "module/module2.js", "module/module3.js"], function(){ 2 console.log("ready!"); 3 });
Yes, it's work!
總結
requirejs 載入模塊的核心思想是利用了動態載入腳本的非同步性以及 onload 事件以毒攻毒,關於腳本的載入,我們需要註意一下幾點:
- 在 HTML 中引入 <script> 標簽是同步載入;
- 在腳本中動態載入是非同步載入,且由於被載入的腳本在事件隊列的後端,因此總是會在當前腳本之後執行;
- 使用 onload 和 onerror 事件可以監聽腳本載入完成,以非同步的事件來處理非同步的事件;
參考文獻:
阮一峰 — RequireJS 和 AMD 規範
阮一峰 — Javascript 模塊化編程
requirejs.org — requirejs api